Compare commits
11 Commits
e8e2765c27
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b1f58614e | |||
| 80fb01526c | |||
| d3f0393332 | |||
| 09f5f06baa | |||
| e42fce5bd1 | |||
| 85f742ad2b | |||
| cc466f7bd4 | |||
| a4e17023d0 | |||
| 0de344c7ad | |||
| 44aef38631 | |||
| 30388533c0 |
@@ -14,7 +14,7 @@ Light Code - 一个直觉、高效的代码编辑器前端界面
|
|||||||
- **语言**: TypeScript 5
|
- **语言**: TypeScript 5
|
||||||
- **UI库**: React 19.2.3
|
- **UI库**: React 19.2.3
|
||||||
- **样式**: Tailwind CSS 4
|
- **样式**: Tailwind CSS 4
|
||||||
- **组件库**: Radix UI (Dialog, Slot, etc.)
|
- **组件库**: Radix UI (Dialog, Slot, etc.), Shadcn UI, @tankstack/react-table
|
||||||
- **状态管理**: Zustand valtio
|
- **状态管理**: Zustand valtio
|
||||||
- **图标**: Lucide React
|
- **图标**: Lucide React
|
||||||
- **工具库**:
|
- **工具库**:
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -1,11 +1,11 @@
|
|||||||
# next-simple-template
|
# kevisual-center v2.0.0
|
||||||
|
|
||||||
主要是前端界面
|
主要是前端界面
|
||||||
|
|
||||||
## 下载
|
## 技术栈
|
||||||
|
- Next.js 16.1.1 (App Router)
|
||||||
```bash
|
- TypeScript 5
|
||||||
ev clone -i https://kevisual.cn/root/ai/kevisual/next/next-simple-template/kevisual.json
|
- React 19.2.3
|
||||||
ev upload
|
- Tailwind CSS 4
|
||||||
ev sync
|
- Radix UI, Shadcn UI
|
||||||
```
|
- Zustand valtio
|
||||||
@@ -10,7 +10,7 @@ const nextConfig: NextConfig = {
|
|||||||
distDir: 'dist',
|
distDir: 'dist',
|
||||||
basePath: basePath,
|
basePath: basePath,
|
||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
transpilePackages: ['@kevisual/api'],
|
transpilePackages: ['@kevisual/api', "@kevisual/use-config", "@kevisual/remote-app", "@kevisual/router"],
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
|
|||||||
25
package.json
25
package.json
@@ -11,10 +11,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
"@kevisual/api": "^0.0.26",
|
"@base-ui/react": "^1.1.0",
|
||||||
|
"@kevisual/api": "^0.0.44",
|
||||||
"@kevisual/cache": "^0.0.5",
|
"@kevisual/cache": "^0.0.5",
|
||||||
"@kevisual/query": "^0.0.38",
|
"@kevisual/query": "^0.0.39",
|
||||||
"@kevisual/router": "^0.0.60",
|
"@kevisual/router": "^0.0.70",
|
||||||
"@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",
|
||||||
@@ -26,7 +27,8 @@
|
|||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@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",
|
||||||
"antd": "^6.2.1",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"antd": "^6.2.3",
|
||||||
"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",
|
||||||
@@ -36,20 +38,25 @@
|
|||||||
"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.6",
|
||||||
"react": "19.2.3",
|
"radix-ui": "^1.4.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",
|
||||||
"valtio": "^2.3.0",
|
"valtio": "^2.3.0",
|
||||||
"zustand": "^5.0.10"
|
"vaul": "^1.1.2",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@kevisual/context": "^0.0.4",
|
||||||
|
"@kevisual/remote-app": "^0.0.4",
|
||||||
"@kevisual/types": "^0.0.12",
|
"@kevisual/types": "^0.0.12",
|
||||||
|
"@kevisual/use-config": "^1.0.30",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^25",
|
"@types/node": "^25",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|||||||
2351
pnpm-lock.yaml
generated
2351
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
214
public/sse-test.html
Normal file
214
public/sse-test.html
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SSE 测试</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 50px auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.status.connected { background: #d4edda; color: #155724; }
|
||||||
|
.status.connecting { background: #fff3cd; color: #856404; }
|
||||||
|
.status.disconnected { background: #f8d7da; color: #721c24; }
|
||||||
|
.events {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 10px;
|
||||||
|
height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
.event-item {
|
||||||
|
padding: 8px;
|
||||||
|
margin: 5px 0;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.event-item .time {
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.event-item .data {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>SSE / HTTP Stream 测试</h1>
|
||||||
|
<div>
|
||||||
|
<label>URL: </label>
|
||||||
|
<input type="text" id="url" value="http://localhost:4005/root/v3/" style="width: 400px;">
|
||||||
|
<label>类型: </label>
|
||||||
|
<select id="streamType">
|
||||||
|
<option value="sse">SSE (EventSource)</option>
|
||||||
|
<option value="stream">HTTP Stream (Fetch)</option>
|
||||||
|
</select>
|
||||||
|
<button onclick="connect()">连接</button>
|
||||||
|
<button onclick="disconnect()">断开</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status disconnected">未连接</div>
|
||||||
|
<h3>事件日志</h3>
|
||||||
|
<div id="events" class="events"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let eventSource = null;
|
||||||
|
let abortController = null;
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
const url = document.getElementById('url').value;
|
||||||
|
const type = document.getElementById('streamType').value;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
alert('请输入 URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect();
|
||||||
|
const eventsDiv = document.getElementById('events');
|
||||||
|
eventsDiv.innerHTML = '';
|
||||||
|
updateStatus('connecting', '正在连接...');
|
||||||
|
|
||||||
|
if (type === 'sse') {
|
||||||
|
connectSSE(url);
|
||||||
|
} else {
|
||||||
|
connectStream(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSSE(url) {
|
||||||
|
try {
|
||||||
|
eventSource = new EventSource(url);
|
||||||
|
|
||||||
|
eventSource.onopen = function() {
|
||||||
|
updateStatus('connected', '已连接 (SSE)');
|
||||||
|
addEvent('系统', 'SSE 连接已建立');
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = function(event) {
|
||||||
|
addEvent('消息', event.data, 'message');
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = function(error) {
|
||||||
|
// 如果 readyState 是 CLOSED,说明是后端主动关闭,不重连
|
||||||
|
if (eventSource.readyState === EventSource.CLOSED) {
|
||||||
|
updateStatus('disconnected', '连接已关闭');
|
||||||
|
addEvent('系统', '后端已关闭连接,无须重连');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是其他错误状态,显示错误信息,不自动重连
|
||||||
|
updateStatus('disconnected', '连接错误');
|
||||||
|
addEvent('错误', 'SSE 连接发生错误');
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.addEventListener('data', function(event) {
|
||||||
|
addEvent('自定义事件', event.data, 'data');
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
updateStatus('disconnected', '连接失败: ' + e.message);
|
||||||
|
addEvent('错误', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectStream(url) {
|
||||||
|
try {
|
||||||
|
abortController = new AbortController();
|
||||||
|
updateStatus('connected', '已连接 (Stream)');
|
||||||
|
addEvent('系统', 'HTTP Stream 连接已建立');
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/plain, text/event-stream'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
addEvent('系统', 'Stream 连接已关闭');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解码并处理数据
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// 按行处理
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
addEvent('数据', line, 'stream');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== 'AbortError') {
|
||||||
|
updateStatus('disconnected', '连接错误: ' + e.message);
|
||||||
|
addEvent('错误', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
if (abortController) {
|
||||||
|
abortController.abort();
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
updateStatus('disconnected', '已断开');
|
||||||
|
addEvent('系统', '连接已关闭');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(type, message) {
|
||||||
|
const statusDiv = document.getElementById('status');
|
||||||
|
statusDiv.className = 'status ' + type;
|
||||||
|
statusDiv.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEvent(type, data, eventType = '') {
|
||||||
|
const eventsDiv = document.getElementById('events');
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'event-item';
|
||||||
|
|
||||||
|
const time = new Date().toLocaleTimeString();
|
||||||
|
item.innerHTML = `
|
||||||
|
<div class="time">[${time}] ${eventType ? '[' + eventType + '] ' : ''}${type}</div>
|
||||||
|
<div class="data">${escapeHtml(data)}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
eventsDiv.insertBefore(item, eventsDiv.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
skills/page/SKILL.md
Normal file
9
skills/page/SKILL.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
name: new-page
|
||||||
|
description: 创建一个新页面
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考当前的文档
|
||||||
|
|
||||||
|
|
||||||
|
`./references/*.ts`
|
||||||
205
skills/page/references/page.tsx
Normal file
205
skills/page/references/page.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { appDomainStatus, useDomainStore } from './store/index ';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { pick } from 'es-toolkit';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { LayoutUser } from '@/modules/layout/LayoutUser';
|
||||||
|
import { LayoutMain } from '@/modules/layout';
|
||||||
|
|
||||||
|
const TableList = () => {
|
||||||
|
const { list, setShowEditModal, setFormData, deleteDomain } = useDomainStore();
|
||||||
|
useEffect(() => {
|
||||||
|
// Initial load is handled by the parent component
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>域名</TableHead>
|
||||||
|
<TableHead>应用ID</TableHead>
|
||||||
|
<TableHead>UID</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{list.map((domain) => (
|
||||||
|
<TableRow key={domain.id}>
|
||||||
|
<TableCell>{domain.id}</TableCell>
|
||||||
|
<TableCell>{domain.domain}</TableCell>
|
||||||
|
<TableCell>{domain.appId}</TableCell>
|
||||||
|
<TableCell>{domain.uid}</TableCell>
|
||||||
|
<TableCell>{domain.status}</TableCell>
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditModal(true);
|
||||||
|
setFormData(domain);
|
||||||
|
}}>
|
||||||
|
<Pencil className="w-4 h-4 mr-1" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteDomain(domain)}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FomeModal = () => {
|
||||||
|
const { showEditModal, setShowEditModal, formData, updateDomain } = useDomainStore();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showEditModal) return;
|
||||||
|
if (formData?.id) {
|
||||||
|
reset(formData);
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formData, showEditModal, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
const _formData = pick(data, ['domain', 'appId', 'status', 'id']);
|
||||||
|
if (formData.id) {
|
||||||
|
_formData.id = formData.id;
|
||||||
|
}
|
||||||
|
const res = await updateDomain(_formData);
|
||||||
|
if (res.code === 200) {
|
||||||
|
setShowEditModal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>添加域名</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 className="text-sm font-medium">域名</label>
|
||||||
|
<Input
|
||||||
|
{...control.register('domain', { required: '请输入域名' })}
|
||||||
|
placeholder="请输入域名"
|
||||||
|
className={errors.domain ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.domain && <span className="text-xs text-red-500">{errors.domain.message as string}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">应用ID</label>
|
||||||
|
<Input
|
||||||
|
{...control.register('appId', { required: '请输入应用ID' })}
|
||||||
|
placeholder="请输入应用ID"
|
||||||
|
className={errors.appId ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.appId && <span className="text-xs text-red-500">{errors.appId.message as string}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">状态</label>
|
||||||
|
<Controller
|
||||||
|
name="status"
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value || ''} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{appDomainStatus.map((item) => (
|
||||||
|
<SelectItem key={item} value={item}>{item}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">提交</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const List = () => {
|
||||||
|
const { getDomainList, setShowEditModal, setFormData } = useDomainStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getDomainList();
|
||||||
|
}, [getDomainList]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 w-full h-full">
|
||||||
|
<div className="flex mb-4">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowEditModal(true);
|
||||||
|
setFormData({});
|
||||||
|
}}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
<TableList />
|
||||||
|
<FomeModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return <LayoutMain><List /></LayoutMain>;
|
||||||
|
}
|
||||||
92
skills/page/references/store/index .ts
Normal file
92
skills/page/references/store/index .ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
'use strict';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { query } from '@/modules/query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// 审核,通过,驳回
|
||||||
|
export const appDomainStatus = ['audit', 'auditReject', 'auditPending', 'running', 'stop'] as const;
|
||||||
|
|
||||||
|
type AppDomainStatus = (typeof appDomainStatus)[number];
|
||||||
|
type Domain = {
|
||||||
|
id: string;
|
||||||
|
domain: string;
|
||||||
|
appId?: string;
|
||||||
|
status: AppDomainStatus;
|
||||||
|
data?: any;
|
||||||
|
uid?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
interface Store {
|
||||||
|
getDomainList: () => Promise<any>;
|
||||||
|
updateDomain: (data: { domain: string; id: string; [key: string]: any }, opts?: { refresh?: boolean }) => Promise<any>;
|
||||||
|
deleteDomain: (data: { id: string }) => Promise<any>;
|
||||||
|
getDomainDetail: (data: { domain?: string; id?: string }) => Promise<any>;
|
||||||
|
list: Domain[];
|
||||||
|
setList: (list: Domain[]) => void;
|
||||||
|
formData: any;
|
||||||
|
setFormData: (formData: any) => void;
|
||||||
|
showEditModal: boolean;
|
||||||
|
setShowEditModal: (showEditModal: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDomainStore = create<Store>((set, get) => ({
|
||||||
|
getDomainList: async () => {
|
||||||
|
const res = await query.get({
|
||||||
|
path: 'app.domain.manager',
|
||||||
|
key: 'list',
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ list: res.data?.list || [] });
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
updateDomain: async (data: any, opts?: { refresh?: boolean }) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'app.domain.manager',
|
||||||
|
key: 'update',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
const list = get().list;
|
||||||
|
set({ list: list.map((item) => (item.id === data.id ? res.data : item)) });
|
||||||
|
toast.success('更新成功');
|
||||||
|
if (opts?.refresh ?? true) {
|
||||||
|
get().getDomainList();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '更新失败');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
deleteDomain: async (data: any) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'app.domain.manager',
|
||||||
|
key: 'delete',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
const list = get().list;
|
||||||
|
set({ list: list.filter((item) => item.id !== data.id) });
|
||||||
|
toast.success('删除成功');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
getDomainDetail: async (data: any) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'app.domain.manager',
|
||||||
|
key: 'get',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ formData: res.data });
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
list: [],
|
||||||
|
setList: (list: any[]) => set({ list }),
|
||||||
|
formData: {},
|
||||||
|
setFormData: (formData: any) => set({ formData }),
|
||||||
|
showEditModal: false,
|
||||||
|
setShowEditModal: (showEditModal: boolean) => set({ showEditModal }),
|
||||||
|
}));
|
||||||
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());
|
||||||
@@ -35,7 +35,7 @@ export const AIEditorLink = (props: Props) => {
|
|||||||
folder = folder.slice(0, -1);
|
folder = folder.slice(0, -1);
|
||||||
}
|
}
|
||||||
let baseUri = location.origin;
|
let baseUri = location.origin;
|
||||||
const openUrl = `${baseUri}/root/ai-pages/ai-editor/?folder=${folder}/`;
|
const openUrl = `${baseUri}/root/codepod/?folder=${folder}/`;
|
||||||
openLink(openUrl, '_blank');
|
openLink(openUrl, '_blank');
|
||||||
}}>
|
}}>
|
||||||
<Folder className='h-4 w-4' />
|
<Folder className='h-4 w-4' />
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const AppDeleteModal = () => {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Tips</DialogTitle>
|
<DialogTitle>Tips</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className='w-[400px]'>
|
<div className=''>
|
||||||
<p className='text-sm text-gray-500'>Delete App Introduce</p>
|
<p className='text-sm text-gray-500'>Delete App Introduce</p>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -16,19 +16,14 @@ interface DatePickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DatePicker({ className, value, onChange }: DatePickerProps) {
|
export function DatePicker({ className, value, onChange }: DatePickerProps) {
|
||||||
const [date, setDate] = React.useState<Date | undefined>(
|
const toDate = (val: string | Dayjs | undefined): Date | undefined => {
|
||||||
value ? new Date(typeof value === 'string' ? value : value.toISOString()) : undefined
|
if (!val) return undefined
|
||||||
)
|
return new Date(typeof val === 'string' ? val : val.toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
const date = toDate(value)
|
||||||
if (value) {
|
|
||||||
const dateValue = typeof value === 'string' ? value : value.toISOString()
|
|
||||||
setDate(new Date(dateValue))
|
|
||||||
}
|
|
||||||
}, [value])
|
|
||||||
|
|
||||||
const handleSelect = (selectedDate: Date | undefined) => {
|
const handleSelect = (selectedDate: Date | undefined) => {
|
||||||
setDate(selectedDate)
|
|
||||||
if (selectedDate && onChange) {
|
if (selectedDate && onChange) {
|
||||||
onChange(dayjs(selectedDate))
|
onChange(dayjs(selectedDate))
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,24 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
|
const LabelWithTooltip = ({ label, tips }: { label: string; tips?: string }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm font-medium">{label}</label>
|
||||||
|
{tips && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-4 w-4 p-0">
|
||||||
|
<HelpCircle size={16} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="whitespace-pre-wrap">
|
||||||
|
<p>{tips}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const KeyShareSelect = ({ value, onChange }: { value: string; onChange?: (value: string) => void }) => {
|
export const KeyShareSelect = ({ value, onChange }: { value: string; onChange?: (value: string) => void }) => {
|
||||||
return (
|
return (
|
||||||
<Select value={value || ''} onValueChange={(val) => onChange?.(val)}>
|
<Select value={value || ''} onValueChange={(val) => onChange?.(val)}>
|
||||||
@@ -87,50 +105,22 @@ export const PermissionManager = ({ value, onChange, className }: PermissionMana
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const tips = getTips('share');
|
const shareTips = getTips('share');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<form className={clsx('flex flex-col w-full gap-4', className)}>
|
<form className={clsx('flex flex-col w-full gap-4', className)}>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<LabelWithTooltip label="共享" tips={shareTips} />
|
||||||
<label className="text-sm font-medium">共享</label>
|
|
||||||
{tips && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-4 w-4 p-0">
|
|
||||||
<HelpCircle size={16} />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="whitespace-pre-wrap">
|
|
||||||
<p>{tips}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<KeyShareSelect value={formData?.share} onChange={(value) => onChangeValue('share', value)} />
|
<KeyShareSelect value={formData?.share} onChange={(value) => onChangeValue('share', value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{keys.map((item: any) => {
|
{keys.map((item: any) => {
|
||||||
const tips = getTips(item);
|
const itemTips = getTips(item);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item} className="flex flex-col gap-2">
|
<div key={item} className="flex flex-col gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<LabelWithTooltip label={item} tips={itemTips} />
|
||||||
<label className="text-sm font-medium">{item}</label>
|
|
||||||
{tips && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-4 w-4 p-0">
|
|
||||||
<HelpCircle size={16} />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{tips}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{item === 'expiration-time' && (
|
{item === 'expiration-time' && (
|
||||||
<DatePicker value={formData[item] || ''} onChange={(date) => onChangeValue(item, date)} />
|
<DatePicker value={formData[item] || ''} onChange={(date) => onChangeValue(item, date)} />
|
||||||
)}
|
)}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
type TagsInputProps = {
|
type TagsInputProps = {
|
||||||
@@ -17,13 +16,16 @@ export function TagsInput({ value, onChange, placeholder = "输入用户名,
|
|||||||
const [inputValue, setInputValue] = React.useState("")
|
const [inputValue, setInputValue] = React.useState("")
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === "Enter" || e.key === ",") {
|
const trimmed = inputValue.trim()
|
||||||
|
|
||||||
|
if ((e.key === "Enter" || e.key === ",") && trimmed) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const newValue = inputValue.trim()
|
if (!value.includes(trimmed)) {
|
||||||
if (newValue && !value.includes(newValue)) {
|
onChange([...value, trimmed])
|
||||||
onChange([...value, newValue])
|
setInputValue("")
|
||||||
|
} else {
|
||||||
|
setInputValue("")
|
||||||
}
|
}
|
||||||
setInputValue("")
|
|
||||||
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
|
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
|
||||||
onChange(value.slice(0, -1))
|
onChange(value.slice(0, -1))
|
||||||
}
|
}
|
||||||
@@ -35,9 +37,9 @@ export function TagsInput({ value, onChange, placeholder = "输入用户名,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-wrap gap-2 w-full", className)}>
|
<div className={cn("flex flex-wrap gap-2 w-full", className)}>
|
||||||
{value.map((tag, index) => (
|
{value.map((tag) => (
|
||||||
<div
|
<div
|
||||||
key={`${tag}-${index}`}
|
key={tag}
|
||||||
className="flex items-center gap-1 px-3 py-1 text-sm bg-secondary text-secondary-foreground rounded-md border border-border"
|
className="flex items-center gap-1 px-3 py-1 text-sm bg-secondary text-secondary-foreground rounded-md border border-border"
|
||||||
>
|
>
|
||||||
<span>{tag}</span>
|
<span>{tag}</span>
|
||||||
@@ -45,6 +47,7 @@ export function TagsInput({ value, onChange, placeholder = "输入用户名,
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeTag(tag)}
|
onClick={() => removeTag(tag)}
|
||||||
className="flex items-center justify-center w-4 h-4 rounded hover:bg-muted transition-colors"
|
className="flex items-center justify-center w-4 h-4 rounded hover:bg-muted transition-colors"
|
||||||
|
aria-label={`移除 ${tag}`}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1,16 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
export const getTips = (key: string, lang?: string) => {
|
|
||||||
const tip = keysTips.find((item) => item.key === key);
|
|
||||||
if (tip) {
|
|
||||||
if (lang === 'en') {
|
|
||||||
return tip.enTips;
|
|
||||||
}
|
|
||||||
return tip.tips;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
export const keysTips = [
|
export const keysTips = [
|
||||||
{
|
{
|
||||||
key: 'share',
|
key: 'share',
|
||||||
@@ -79,31 +70,32 @@ export const keysTips = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 创建缓存Map以提升查找性能
|
||||||
|
const tipsMap = new Map(keysTips.map(tip => [tip.key, tip]));
|
||||||
|
|
||||||
|
export const getTips = (key: string, lang?: string) => {
|
||||||
|
const tip = tipsMap.get(key);
|
||||||
|
if (tip) {
|
||||||
|
return lang === 'en' ? tip.enTips : tip.tips;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
export class KeyParse {
|
export class KeyParse {
|
||||||
static parse(metadata: Record<string, any>) {
|
static parse(metadata: Record<string, any>) {
|
||||||
const keys = Object.keys(metadata);
|
return Object.entries(metadata).reduce((acc, [key, value]) => {
|
||||||
const newMetadata = {};
|
const tip = tipsMap.get(key);
|
||||||
keys.forEach((key) => {
|
acc[key] = tip?.parse ? tip.parse(value) : value;
|
||||||
const tip = keysTips.find((item) => item.key === key);
|
return acc;
|
||||||
if (tip && tip.parse) {
|
}, {} as Record<string, any>);
|
||||||
newMetadata[key] = tip.parse(metadata[key]);
|
|
||||||
} else {
|
|
||||||
newMetadata[key] = metadata[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return newMetadata;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static stringify(metadata: Record<string, any>) {
|
static stringify(metadata: Record<string, any>) {
|
||||||
const keys = Object.keys(metadata);
|
return Object.entries(metadata).reduce((acc, [key, value]) => {
|
||||||
const newMetadata = {};
|
const tip = tipsMap.get(key);
|
||||||
keys.forEach((key) => {
|
acc[key] = tip?.stringify ? tip.stringify(value) : value;
|
||||||
const tip = keysTips.find((item) => item.key === key);
|
return acc;
|
||||||
if (tip && tip.stringify) {
|
}, {} as Record<string, any>);
|
||||||
newMetadata[key] = tip.stringify(metadata[key]);
|
|
||||||
} else {
|
|
||||||
newMetadata[key] = metadata[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return newMetadata;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ import clsx from 'clsx';
|
|||||||
// import { IconButton } from '@kevisual/components/button/index.tsx';
|
// import { IconButton } from '@kevisual/components/button/index.tsx';
|
||||||
// import { Select } from '@kevisual/components/select/index.tsx';
|
// import { Select } from '@kevisual/components/select/index.tsx';
|
||||||
import { iText } from './constants';
|
import { iText } from './constants';
|
||||||
import { PermissionManager } from './modules/PermissionManager';
|
import { PermissionManager } from './modules/permission/PermissionManager';
|
||||||
import { toast as message } from 'sonner';
|
import { toast as message } from 'sonner';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -197,7 +197,7 @@ const ShareModal = () => {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>分享</DialogTitle>
|
<DialogTitle>分享</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className='flex flex-col gap-2 w-[400px] '>
|
<div className='flex flex-col gap-2 '>
|
||||||
<PermissionManager
|
<PermissionManager
|
||||||
value={permission}
|
value={permission}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
|
|||||||
168
src/app/config/components/autocomplate.tsx
Normal file
168
src/app/config/components/autocomplate.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Check, ChevronsUpDown } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
|
||||||
|
export interface AutocomplateOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
tags?: readonly string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutocomplateProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
options: AutocomplateOption[]
|
||||||
|
placeholder?: string
|
||||||
|
searchPlaceholder?: string
|
||||||
|
emptyMessage?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Autocomplate({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder = "选择项目...",
|
||||||
|
searchPlaceholder = "搜索...",
|
||||||
|
emptyMessage = "未找到结果",
|
||||||
|
className,
|
||||||
|
}: AutocomplateProps) {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
const [inputValue, setInputValue] = React.useState(value)
|
||||||
|
const [searchValue, setSearchValue] = React.useState("")
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setInputValue(value)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value
|
||||||
|
setInputValue(newValue)
|
||||||
|
onChange(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
setInputValue(selectedValue)
|
||||||
|
onChange(selectedValue)
|
||||||
|
setSearchValue("")
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && searchValue) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSelect(searchValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机获取badge variant
|
||||||
|
const getRandomVariant = (index: number) => {
|
||||||
|
const variants = ['default', 'secondary', 'outline'] as const
|
||||||
|
return variants[index % variants.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={cn("flex-1 font-mono", className)}
|
||||||
|
/>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-10 px-0 shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-125 p-0" align="end">
|
||||||
|
<Command className="**:[[cmdk-input]]:font-mono">
|
||||||
|
<CommandInput
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={searchValue}
|
||||||
|
onValueChange={setSearchValue}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
<div className="py-6 text-center text-sm">
|
||||||
|
<div className="text-muted-foreground mb-2">{emptyMessage}</div>
|
||||||
|
{searchValue && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelect(searchValue)}
|
||||||
|
className="text-primary hover:underline text-sm"
|
||||||
|
>
|
||||||
|
使用 "{searchValue}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
keywords={[option.value, option.label, option.description || '', ...(option.tags || [])]}
|
||||||
|
onSelect={() => handleSelect(option.value)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
inputValue === option.value ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<span className="font-medium font-mono">{option.label}</span>
|
||||||
|
{option.description && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{option.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{option.tags && option.tags.length > 0 && (
|
||||||
|
<div className="flex gap-1 flex-wrap mt-1">
|
||||||
|
{option.tags.map((tag, index) => (
|
||||||
|
<Badge
|
||||||
|
key={tag}
|
||||||
|
variant={getRandomVariant(index)}
|
||||||
|
className="text-[10px] px-1.5 py-0 h-4"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
324
src/app/config/env/page.tsx
vendored
Normal file
324
src/app/config/env/page.tsx
vendored
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { configEnvList } from '@kevisual/use-config/env-config.ts';
|
||||||
|
import { useConfigStore } from '../store/config';
|
||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
|
||||||
|
import { Trash2, Plus, Save, Upload, Download } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Autocomplate, type AutocomplateOption } from '@/app/config/components/autocomplate';
|
||||||
|
|
||||||
|
type EnvItem = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EnvPage() {
|
||||||
|
const { getEnv, updateEnv, envData } = useConfigStore();
|
||||||
|
const [envItems, setEnvItems] = useState<EnvItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEnvData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (envData && typeof envData === 'object') {
|
||||||
|
const data = envData?.data || {};
|
||||||
|
const items = Object.entries(data).map(([key, value], index) => ({
|
||||||
|
key,
|
||||||
|
value: String(value || ''),
|
||||||
|
id: `env-${index}-${Date.now()}`,
|
||||||
|
}));
|
||||||
|
setEnvItems(items);
|
||||||
|
}
|
||||||
|
}, [envData]);
|
||||||
|
|
||||||
|
const loadEnvData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await getEnv();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEnvItem = () => {
|
||||||
|
const newItem: EnvItem = {
|
||||||
|
key: '',
|
||||||
|
value: '',
|
||||||
|
id: `env-new-${Date.now()}`,
|
||||||
|
};
|
||||||
|
setEnvItems([...envItems, newItem]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEnvItem = (id: string) => {
|
||||||
|
setEnvItems(envItems.filter((item) => item.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEnvItem = (id: string, field: 'key' | 'value', newValue: string) => {
|
||||||
|
setEnvItems(
|
||||||
|
envItems.map((item) => (item.id === id ? { ...item, [field]: newValue } : item))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// 验证是否有空的 key
|
||||||
|
const hasEmptyKey = envItems.some((item) => !item.key.trim());
|
||||||
|
if (hasEmptyKey) {
|
||||||
|
toast.error('请填写所有环境变量的键名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证是否有重复的 key
|
||||||
|
const keys = envItems.map((item) => item.key);
|
||||||
|
const uniqueKeys = new Set(keys);
|
||||||
|
if (keys.length !== uniqueKeys.size) {
|
||||||
|
toast.error('存在重复的环境变量键名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为对象
|
||||||
|
const envObject = envItems.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
if (item.key.trim()) {
|
||||||
|
acc[item.key] = item.value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await updateEnv({ ...envData, data: envObject });
|
||||||
|
await loadEnvData();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEnvDescription = (key: string): string => {
|
||||||
|
const config = configEnvList.find((item) => item.title === key);
|
||||||
|
return config?.description || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEnvTags = (key: string): readonly string[] => {
|
||||||
|
const config = configEnvList.find((item) => item.title === key);
|
||||||
|
return config?.tags || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
// 只导出第一级的 key-value
|
||||||
|
const envObject = envItems.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
if (item.key.trim()) {
|
||||||
|
acc[item.key] = item.value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
);
|
||||||
|
|
||||||
|
const json = JSON.stringify(envObject, null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'env-config.json';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success('导出成功');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'application/json';
|
||||||
|
input.onchange = (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(event.target?.result as string);
|
||||||
|
|
||||||
|
// 只读取第一级的 key-value
|
||||||
|
const items: EnvItem[] = [];
|
||||||
|
Object.entries(json).forEach(([key, value]) => {
|
||||||
|
// 只处理第一级,忽略嵌套对象
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
value: String(value || ''),
|
||||||
|
id: `env-${items.length}-${Date.now()}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果是对象,只取第一级的值
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
value: JSON.stringify(value),
|
||||||
|
id: `env-${items.length}-${Date.now()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setEnvItems(items);
|
||||||
|
toast.success('导入成功');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('导入失败,请检查文件格式');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const comboboxOptions = useMemo<AutocomplateOption[]>(() => {
|
||||||
|
return configEnvList.map((config) => ({
|
||||||
|
value: config.title,
|
||||||
|
label: config.title,
|
||||||
|
description: config.description,
|
||||||
|
tags: config.tags,
|
||||||
|
})).sort((a, b) => {
|
||||||
|
// 优先排序:有"常用"标签的排在前面
|
||||||
|
const aHasCommon = a.tags?.some(tag => tag === '常用') ?? false;
|
||||||
|
const bHasCommon = b.tags?.some(tag => tag === '常用') ?? false;
|
||||||
|
|
||||||
|
if (aHasCommon && !bHasCommon) return -1;
|
||||||
|
if (!aHasCommon && bHasCommon) return 1;
|
||||||
|
|
||||||
|
// 其次按字母顺序排序
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading && envItems.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-lg">加载中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col">
|
||||||
|
<div className="flex-none border-b bg-background">
|
||||||
|
<div className="container mx-auto p-6 max-w-5xl">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">环境变量配置</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">管理应用的环境变量配置</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleImport} variant="outline">
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
导入
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport} variant="outline">
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
<Button onClick={addEnvItem} variant="outline">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
添加变量
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={isLoading}>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{isLoading ? '保存中...' : '保存配置'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="container mx-auto p-6 max-w-5xl">
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{envItems.map((item) => (
|
||||||
|
<Card key={item.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex justify-between items-start gap-4">
|
||||||
|
<div className="flex-1 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`key-${item.id}`}>环境变量键</Label>
|
||||||
|
<Autocomplate
|
||||||
|
value={item.key}
|
||||||
|
onChange={(value) => updateEnvItem(item.id, 'key', value)}
|
||||||
|
options={comboboxOptions}
|
||||||
|
placeholder="输入或选择环境变量键"
|
||||||
|
searchPlaceholder="搜索环境变量..."
|
||||||
|
emptyMessage="未找到匹配的环境变量"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.key && getEnvDescription(item.key) && (
|
||||||
|
<CardDescription>{getEnvDescription(item.key)}</CardDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.key && getEnvTags(item.key).length > 0 && (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{getEnvTags(item.key).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="text-xs px-2 py-1 rounded-full bg-secondary text-secondary-foreground"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`value-${item.id}`}>环境变量值</Label>
|
||||||
|
<Input
|
||||||
|
id={`value-${item.id}`}
|
||||||
|
value={item.value}
|
||||||
|
onChange={(e) => updateEnvItem(item.id, 'value', e.target.value)}
|
||||||
|
placeholder="输入环境变量的值"
|
||||||
|
type={
|
||||||
|
item.key.toLowerCase().includes('password') ||
|
||||||
|
item.key.toLowerCase().includes('secret') ||
|
||||||
|
item.key.toLowerCase().includes('key')
|
||||||
|
? 'password'
|
||||||
|
: 'text'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeEnvItem(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{envItems.length === 0 && (
|
||||||
|
<Card className="p-12">
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<p className="text-lg mb-4">暂无环境变量</p>
|
||||||
|
<Button onClick={addEnvItem} variant="outline">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
添加第一个环境变量
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
src/app/config/page.tsx
Normal file
210
src/app/config/page.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useConfigStore } from './store/config';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { LayoutMain } from '@/modules/layout';
|
||||||
|
|
||||||
|
const TableList = () => {
|
||||||
|
const { list, setShowEdit, setFormData, deleteConfig } = useConfigStore();
|
||||||
|
|
||||||
|
interface ConfigItem {
|
||||||
|
id?: string;
|
||||||
|
key?: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (config: ConfigItem) => {
|
||||||
|
setShowEdit(true);
|
||||||
|
setFormData(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (config: ConfigItem) => {
|
||||||
|
if (config.id) {
|
||||||
|
deleteConfig(config.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>文件</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead>更新时间</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{list.map((config) => (
|
||||||
|
<TableRow key={config.id}>
|
||||||
|
<TableCell>{config.key || '-'}</TableCell>
|
||||||
|
<TableCell>{config.description || '-'}</TableCell>
|
||||||
|
<TableCell>{config.createdAt ? new Date(config.createdAt).toLocaleString() : '-'}</TableCell>
|
||||||
|
<TableCell>{config.updatedAt ? new Date(config.updatedAt).toLocaleString() : '-'}</TableCell>
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(config)}>
|
||||||
|
<Pencil className="w-4 h-4 mr-1" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm">
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-48 p-2">
|
||||||
|
<div className="text-sm text-center mb-2">确认删除该配置?</div>
|
||||||
|
<div className="flex gap-2 justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(config);
|
||||||
|
}}>
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormModal = () => {
|
||||||
|
const { showEdit, setShowEdit, formData, setFormData, updateData } = useConfigStore();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showEdit) return;
|
||||||
|
if (formData?.id) {
|
||||||
|
reset(formData);
|
||||||
|
} else {
|
||||||
|
reset({ key: '', description: '' });
|
||||||
|
}
|
||||||
|
}, [formData, showEdit, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
const res = await updateData(data);
|
||||||
|
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 className="text-sm font-medium">文件</label>
|
||||||
|
<Input
|
||||||
|
{...register('key', { required: '请输入文件' })}
|
||||||
|
placeholder="请输入文件"
|
||||||
|
className={errors.key ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.key && <span className="text-xs text-red-500">{errors.key.message as string}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">描述</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return <LayoutMain><List /></LayoutMain>;
|
||||||
|
}
|
||||||
104
src/app/config/store/config.ts
Normal file
104
src/app/config/store/config.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { query } from '@/modules/query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { QueryConfig, Config } from '@kevisual/api/config';
|
||||||
|
|
||||||
|
export const queryConfig = new QueryConfig({ query: query as any });
|
||||||
|
|
||||||
|
interface ConfigStore {
|
||||||
|
list: any[];
|
||||||
|
getConfigList: () => Promise<void>;
|
||||||
|
updateData: (data: any, opts?: { refresh?: boolean }) => Promise<any>;
|
||||||
|
showEdit: boolean;
|
||||||
|
setShowEdit: (showEdit: boolean) => void;
|
||||||
|
formData: any;
|
||||||
|
setFormData: (formData: any) => void;
|
||||||
|
deleteConfig: (id: string) => Promise<void>;
|
||||||
|
detectConfig: () => Promise<void>;
|
||||||
|
onOpenKey: (key: string) => Promise<void>;
|
||||||
|
getEnv: () => Promise<void>;
|
||||||
|
updateEnv: (data: Config) => Promise<void>;
|
||||||
|
envData: Config;
|
||||||
|
setEnvData: (envData: Config) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfigStore = create<ConfigStore>((set, get) => ({
|
||||||
|
list: [],
|
||||||
|
getConfigList: async () => {
|
||||||
|
const res = await queryConfig.listConfig();
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ list: res.data?.list || [] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateData: async (data: any, opts?: { refresh?: boolean }) => {
|
||||||
|
const res = await queryConfig.updateConfig(data);
|
||||||
|
if (res.code === 200) {
|
||||||
|
get().setFormData(res.data);
|
||||||
|
if (opts?.refresh ?? true) {
|
||||||
|
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.deleteConfig({ id });
|
||||||
|
if (res.code === 200) {
|
||||||
|
get().getConfigList();
|
||||||
|
toast.success('删除成功');
|
||||||
|
} else {
|
||||||
|
toast.error('删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
detectConfig: async () => {
|
||||||
|
const res = await queryConfig.detectConfig();
|
||||||
|
if (res.code === 200) {
|
||||||
|
const data = res?.data?.updateList || [];
|
||||||
|
console.log(data);
|
||||||
|
toast.success('检测成功');
|
||||||
|
} else {
|
||||||
|
toast.error('检测失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpenKey: async (key: string) => {
|
||||||
|
const { setFormData, setShowEdit, getConfigList } = get();
|
||||||
|
const res = await queryConfig.getConfigByKey(key as any);
|
||||||
|
if (res.code === 200) {
|
||||||
|
const data = res.data;
|
||||||
|
setFormData(data);
|
||||||
|
setShowEdit(true);
|
||||||
|
getConfigList();
|
||||||
|
} else {
|
||||||
|
console.log(res);
|
||||||
|
toast.error('获取配置失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getEnv: async () => {
|
||||||
|
const res = await queryConfig.getByKey('env.json');
|
||||||
|
if (res.code === 200) {
|
||||||
|
const data = res.data;
|
||||||
|
console.log(data);
|
||||||
|
set({ envData: data });
|
||||||
|
} else {
|
||||||
|
console.log(res);
|
||||||
|
toast.error('获取失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateEnv: async (data: any) => {
|
||||||
|
const res = await queryConfig.updateConfig({ key: 'env.json', ...data });
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('更新成功');
|
||||||
|
} else {
|
||||||
|
console.log(res);
|
||||||
|
toast.error('更新失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
envData: {},
|
||||||
|
setEnvData: (envData: any) => set({ envData }),
|
||||||
|
}));
|
||||||
@@ -123,7 +123,7 @@ const FomeModal = () => {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>添加域名</DialogTitle>
|
<DialogTitle>添加域名</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="p-4 w-[500px]">
|
<div className="p-4">
|
||||||
<form className="w-full flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
<form className="w-full flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<label className="text-sm font-medium">域名</label>
|
<label className="text-sm font-medium">域名</label>
|
||||||
|
|||||||
187
src/app/flowme/channel/page.tsx
Normal file
187
src/app/flowme/channel/page.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useFlowmeChannelStore } from '../store/channel';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { pick } from 'es-toolkit';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { LayoutMain } from '@/modules/layout';
|
||||||
|
|
||||||
|
const TableList = () => {
|
||||||
|
const { list, setShowEdit, setFormData, deleteData } = useFlowmeChannelStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>标题</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead>标签</TableHead>
|
||||||
|
<TableHead>颜色</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{list.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.title}</TableCell>
|
||||||
|
<TableCell>{item.description}</TableCell>
|
||||||
|
<TableCell>{item.tags?.join(', ')}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: item.color }} />
|
||||||
|
{item.color}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<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={() => deleteData(item.id)}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormModal = () => {
|
||||||
|
const { showEdit, setShowEdit, formData, updateData } = useFlowmeChannelStore();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showEdit) return;
|
||||||
|
if (formData?.id) {
|
||||||
|
reset(formData);
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
color: '#007bff',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formData, showEdit, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
const _formData: any = pick(data, ['title', 'description', 'tags', 'link', 'data', 'color']);
|
||||||
|
if (formData.id) {
|
||||||
|
_formData.id = formData.id;
|
||||||
|
}
|
||||||
|
const res = await updateData(_formData);
|
||||||
|
if (res.code === 200) {
|
||||||
|
setShowEdit(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showEdit} onOpenChange={setShowEdit}>
|
||||||
|
<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 className="text-sm font-medium">标题</label>
|
||||||
|
<Input
|
||||||
|
{...control.register('title')}
|
||||||
|
placeholder="请输入标题"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">描述</label>
|
||||||
|
<Input
|
||||||
|
{...control.register('description')}
|
||||||
|
placeholder="请输入描述"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">颜色</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
{...control.register('color')}
|
||||||
|
placeholder="请输入颜色值"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
{...control.register('color')}
|
||||||
|
className="w-12 h-10 p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">提交</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const List = () => {
|
||||||
|
const { getList, setShowEdit, setFormData } = useFlowmeChannelStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getList();
|
||||||
|
}, [getList]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 w-full h-full">
|
||||||
|
<div className="flex mb-4">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowEdit(true);
|
||||||
|
setFormData({});
|
||||||
|
}}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
<TableList />
|
||||||
|
<FormModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return <LayoutMain><List /></LayoutMain>;
|
||||||
|
}
|
||||||
196
src/app/flowme/page.tsx
Normal file
196
src/app/flowme/page.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useFlowmeStore } from './store/index';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { pick } from 'es-toolkit';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { LayoutMain } from '@/modules/layout';
|
||||||
|
|
||||||
|
const TableList = () => {
|
||||||
|
const { list, setShowEdit, setFormData, deleteData } = useFlowmeStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>标题</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead>标签</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead>来源</TableHead>
|
||||||
|
<TableHead>重要性</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{list.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.title}</TableCell>
|
||||||
|
<TableCell>{item.description}</TableCell>
|
||||||
|
<TableCell>{item.tags?.join(', ')}</TableCell>
|
||||||
|
<TableCell>{item.type}</TableCell>
|
||||||
|
<TableCell>{item.source}</TableCell>
|
||||||
|
<TableCell>{item.importance}</TableCell>
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<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={() => deleteData(item.id)}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormModal = () => {
|
||||||
|
const { showEdit, setShowEdit, formData, updateData } = useFlowmeStore();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
control,
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showEdit) return;
|
||||||
|
if (formData?.id) {
|
||||||
|
reset(formData);
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
type: '',
|
||||||
|
source: '',
|
||||||
|
importance: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [formData, showEdit, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
const _formData: any = pick(data, ['title', 'description', 'type', 'source', 'importance', 'tags', 'link', 'data', 'channelId']);
|
||||||
|
if (formData.id) {
|
||||||
|
_formData.id = formData.id;
|
||||||
|
}
|
||||||
|
const res = await updateData(_formData);
|
||||||
|
if (res.code === 200) {
|
||||||
|
setShowEdit(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showEdit} onOpenChange={setShowEdit}>
|
||||||
|
<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 className="text-sm font-medium">标题</label>
|
||||||
|
<Input
|
||||||
|
{...control.register('title')}
|
||||||
|
placeholder="请输入标题"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">描述</label>
|
||||||
|
<Input
|
||||||
|
{...control.register('description')}
|
||||||
|
placeholder="请输入描述"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">类型</label>
|
||||||
|
<Input
|
||||||
|
{...control.register('type')}
|
||||||
|
placeholder="请输入类型"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">来源</label>
|
||||||
|
<Input
|
||||||
|
{...control.register('source')}
|
||||||
|
placeholder="请输入来源"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">重要性</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...control.register('importance', { valueAsNumber: true })}
|
||||||
|
placeholder="请输入重要性等级"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">提交</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const List = () => {
|
||||||
|
const { getList, setShowEdit, setFormData } = useFlowmeStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getList();
|
||||||
|
}, [getList]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 w-full h-full">
|
||||||
|
<div className="flex mb-4">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowEdit(true);
|
||||||
|
setFormData({});
|
||||||
|
}}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
<TableList />
|
||||||
|
<FormModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return <LayoutMain><List /></LayoutMain>;
|
||||||
|
}
|
||||||
103
src/app/flowme/store/channel.ts
Normal file
103
src/app/flowme/store/channel.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { query } from '@/modules/query';
|
||||||
|
import { toast as message } from 'sonner';
|
||||||
|
|
||||||
|
export type FlowmeChannelType = {
|
||||||
|
id: string;
|
||||||
|
uid?: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
link: string;
|
||||||
|
data: Record<string, any>;
|
||||||
|
color: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlowmeChannelStore = {
|
||||||
|
showEdit: boolean;
|
||||||
|
setShowEdit: (showEdit: boolean) => void;
|
||||||
|
formData: Partial<FlowmeChannelType>;
|
||||||
|
setFormData: (formData: Partial<FlowmeChannelType>) => void;
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
list: FlowmeChannelType[];
|
||||||
|
getList: () => Promise<void>;
|
||||||
|
updateData: (data: Partial<FlowmeChannelType>) => Promise<any>;
|
||||||
|
deleteData: (id: string) => Promise<void>;
|
||||||
|
detail: FlowmeChannelType | null;
|
||||||
|
setDetail: (detail: FlowmeChannelType | null) => void;
|
||||||
|
getDetail: (id: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFlowmeChannelStore = create<FlowmeChannelStore>((set, get) => {
|
||||||
|
return {
|
||||||
|
showEdit: false,
|
||||||
|
setShowEdit: (showEdit) => set({ showEdit }),
|
||||||
|
formData: {},
|
||||||
|
setFormData: (formData) => set({ formData }),
|
||||||
|
loading: false,
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
list: [],
|
||||||
|
getList: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'flowme-channel',
|
||||||
|
key: 'list',
|
||||||
|
});
|
||||||
|
set({ loading: false });
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ list: res.data.list });
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateData: async (data) => {
|
||||||
|
const { getList } = get();
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'flowme-channel',
|
||||||
|
key: 'update',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('Success');
|
||||||
|
set({ showEdit: false, formData: res.data });
|
||||||
|
getList();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
deleteData: async (id) => {
|
||||||
|
const { getList } = get();
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'flowme-channel',
|
||||||
|
key: 'delete',
|
||||||
|
data: { id },
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
getList();
|
||||||
|
message.success('Success');
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
detail: null,
|
||||||
|
setDetail: (detail) => set({ detail }),
|
||||||
|
getDetail: async (id) => {
|
||||||
|
set({ detail: null });
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'flowme-channel',
|
||||||
|
key: 'get',
|
||||||
|
data: { id },
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ detail: res.data });
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
107
src/app/flowme/store/index.ts
Normal file
107
src/app/flowme/store/index.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { query } from '@/modules/query';
|
||||||
|
import { toast as message } from 'sonner';
|
||||||
|
|
||||||
|
export type FlowmeType = {
|
||||||
|
id: string;
|
||||||
|
uid?: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
link: string;
|
||||||
|
data: Record<string, any>;
|
||||||
|
channelId?: string;
|
||||||
|
type: string;
|
||||||
|
source: string;
|
||||||
|
importance: number;
|
||||||
|
isArchived: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlowmeStore = {
|
||||||
|
showEdit: boolean;
|
||||||
|
setShowEdit: (showEdit: boolean) => void;
|
||||||
|
formData: Partial<FlowmeType>;
|
||||||
|
setFormData: (formData: Partial<FlowmeType>) => void;
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
list: FlowmeType[];
|
||||||
|
getList: () => Promise<void>;
|
||||||
|
updateData: (data: Partial<FlowmeType>) => Promise<any>;
|
||||||
|
deleteData: (id: string) => Promise<void>;
|
||||||
|
detail: FlowmeType | null;
|
||||||
|
setDetail: (detail: FlowmeType | null) => void;
|
||||||
|
getDetail: (id: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useFlowmeStore = create<FlowmeStore>((set, get) => {
|
||||||
|
return {
|
||||||
|
showEdit: false,
|
||||||
|
setShowEdit: (showEdit) => set({ showEdit }),
|
||||||
|
formData: {},
|
||||||
|
setFormData: (formData) => set({ formData }),
|
||||||
|
loading: false,
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
list: [],
|
||||||
|
getList: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'flowme',
|
||||||
|
key: 'list',
|
||||||
|
});
|
||||||
|
set({ loading: false });
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ list: res.data.list });
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateData: async (data) => {
|
||||||
|
const { getList } = get();
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'flowme',
|
||||||
|
key: 'update',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('Success');
|
||||||
|
set({ showEdit: false, formData: res.data });
|
||||||
|
getList();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
deleteData: async (id) => {
|
||||||
|
const { getList } = get();
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'flowme',
|
||||||
|
key: 'delete',
|
||||||
|
data: { id },
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
getList();
|
||||||
|
message.success('Success');
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
detail: null,
|
||||||
|
setDetail: (detail) => set({ detail }),
|
||||||
|
getDetail: async (id) => {
|
||||||
|
set({ detail: null });
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'flowme',
|
||||||
|
key: 'get',
|
||||||
|
data: { id },
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ detail: res.data });
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
211
src/app/org/components/UserDrawer.tsx
Normal file
211
src/app/org/components/UserDrawer.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { useOrgStore } from '../store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerClose,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Plus, Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UserDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserDrawer = ({ open, onOpenChange }: UserDrawerProps) => {
|
||||||
|
const {
|
||||||
|
orgId,
|
||||||
|
users,
|
||||||
|
getOrg,
|
||||||
|
addUser,
|
||||||
|
removeUser,
|
||||||
|
setUserFormData: setUserFormData,
|
||||||
|
userFormData,
|
||||||
|
showUserEdit,
|
||||||
|
setShowUserEdit,
|
||||||
|
} = useOrgStore();
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && orgId) {
|
||||||
|
getOrg();
|
||||||
|
}
|
||||||
|
}, [open, orgId, getOrg]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showUserEdit) return;
|
||||||
|
// 确保 userFormData 已更新后再重置表单
|
||||||
|
console.log('Resetting form with userFormData:', userFormData);
|
||||||
|
if (userFormData?.id) {
|
||||||
|
reset({ id: userFormData.id, username: userFormData.username, role: userFormData.role || 'member' });
|
||||||
|
} else {
|
||||||
|
reset({ id: '', username: '', role: 'member' });
|
||||||
|
}
|
||||||
|
}, [showUserEdit, userFormData, reset]);
|
||||||
|
|
||||||
|
const handleAddUser = async (data: any) => {
|
||||||
|
const res = await addUser({ ...data, action: 'add' });
|
||||||
|
if (res.code === 200) {
|
||||||
|
setShowUserEdit(false);
|
||||||
|
setUserFormData({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveUser = async (uid: string) => {
|
||||||
|
await removeUser(uid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditUser = (user: any) => {
|
||||||
|
console.log('Editing user:', user);
|
||||||
|
setUserFormData(user);
|
||||||
|
// 使用 setTimeout 确保 userFormData 更新后再打开弹窗
|
||||||
|
setShowUserEdit(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange} direction="right">
|
||||||
|
<DrawerContent className="h-full !max-w-xl ml-auto">
|
||||||
|
<DrawerHeader className="flex flex-row items-center justify-between">
|
||||||
|
<DrawerTitle>用户管理</DrawerTitle>
|
||||||
|
<DrawerClose asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DrawerClose>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-4 px-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setUserFormData({});
|
||||||
|
setShowUserEdit(true);
|
||||||
|
}}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
添加用户
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-4">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>用户ID</TableHead>
|
||||||
|
<TableHead>用户名</TableHead>
|
||||||
|
<TableHead>角色</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>{user.id}</TableCell>
|
||||||
|
<TableCell>{user.username}</TableCell>
|
||||||
|
<TableCell>{user.role || 'member'}</TableCell>
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditUser(user)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveUser(user.id)}>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Dialog open={showUserEdit} onOpenChange={setShowUserEdit}>
|
||||||
|
<DialogContent className='px-4 overflow-hidden'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{userFormData?.id ? '编辑用户' : '添加用户'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="p-4 ">
|
||||||
|
<form className="w-full flex flex-col gap-4" onSubmit={handleSubmit(handleAddUser)}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">用户ID</label>
|
||||||
|
<Input
|
||||||
|
{...register('id', { required: '请输入用户ID' })}
|
||||||
|
placeholder="请输入用户ID"
|
||||||
|
className={errors.id ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.id && <span className="text-xs text-red-500">{errors.id.message as string}</span>}
|
||||||
|
</div>
|
||||||
|
<div>{userFormData?.username}</div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="role"
|
||||||
|
defaultValue={userFormData?.role || 'member'}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">角色</label>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择角色" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="owner">owner</SelectItem>
|
||||||
|
<SelectItem value="admin">admin</SelectItem>
|
||||||
|
<SelectItem value="member">member</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowUserEdit(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">提交</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
209
src/app/org/page.tsx
Normal file
209
src/app/org/page.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useOrgStore } from './store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
|
import { Plus, Pencil, Trash2, Users } from 'lucide-react';
|
||||||
|
import { LayoutMain } from '@/modules/layout';
|
||||||
|
import { UserDrawer } from './components/UserDrawer';
|
||||||
|
|
||||||
|
const TableList = () => {
|
||||||
|
const { list, setShowEdit, setFormData, deleteData, setOrgId } = useOrgStore();
|
||||||
|
const [userDrawerOpen, setUserDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleOpenUserDrawer = (org: any) => {
|
||||||
|
setOrgId(org.id);
|
||||||
|
setUserDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>名称</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead>更新时间</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{list.map((org) => (
|
||||||
|
<TableRow key={org.id}>
|
||||||
|
<TableCell>{org.username}</TableCell>
|
||||||
|
<TableCell>{org.description || '-'}</TableCell>
|
||||||
|
<TableCell>{org.createdAt ? new Date(org.createdAt).toLocaleString() : '-'}</TableCell>
|
||||||
|
<TableCell>{org.updatedAt ? new Date(org.updatedAt).toLocaleString() : '-'}</TableCell>
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenUserDrawer(org)}>
|
||||||
|
<Users className="w-4 h-4 mr-1" />
|
||||||
|
用户管理
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowEdit(true);
|
||||||
|
setFormData(org);
|
||||||
|
}}>
|
||||||
|
<Pencil className="w-4 h-4 mr-1" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm">
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-48 p-2">
|
||||||
|
<div className="text-sm text-center mb-2">确认删除该组织?</div>
|
||||||
|
<div className="flex gap-2 justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
deleteData(org.id);
|
||||||
|
}}>
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<UserDrawer open={userDrawerOpen} onOpenChange={setUserDrawerOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormModal = () => {
|
||||||
|
const { showEdit, setShowEdit, formData, setFormData, updateData } = useOrgStore();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showEdit) return;
|
||||||
|
if (formData?.id) {
|
||||||
|
reset(formData);
|
||||||
|
} else {
|
||||||
|
reset({ username: '', description: '' });
|
||||||
|
}
|
||||||
|
}, [formData, showEdit, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
const res = await updateData(data);
|
||||||
|
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 className="text-sm font-medium">名称</label>
|
||||||
|
<Input
|
||||||
|
{...register('username', { required: '请输入名称' })}
|
||||||
|
placeholder="请输入名称"
|
||||||
|
className={errors.username ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.username && <span className="text-xs text-red-500">{errors.username.message as string}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">描述</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const List = () => {
|
||||||
|
const { getList, setShowEdit, setFormData } = useOrgStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getList();
|
||||||
|
}, [getList]);
|
||||||
|
|
||||||
|
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 />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return <LayoutMain><List /></LayoutMain>;
|
||||||
|
}
|
||||||
143
src/app/org/store/index.ts
Normal file
143
src/app/org/store/index.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
'use client';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { query } from '@/modules/index';
|
||||||
|
import { toast as message } from 'sonner';
|
||||||
|
type OrgStore = {
|
||||||
|
showEdit: boolean;
|
||||||
|
setShowEdit: (showEdit: boolean) => void;
|
||||||
|
formData: any;
|
||||||
|
setFormData: (formData: any) => void;
|
||||||
|
showUserEdit: boolean;
|
||||||
|
setShowUserEdit: (showUserEdit: boolean) => void;
|
||||||
|
userFormData: any;
|
||||||
|
setUserFormData: (userFormData: any) => void;
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
list: any[];
|
||||||
|
getList: () => Promise<void>;
|
||||||
|
updateData: (data: any) => Promise<any>;
|
||||||
|
deleteData: (id: string) => Promise<void>;
|
||||||
|
org: any;
|
||||||
|
setOrg: (org: any) => void;
|
||||||
|
users: { id: string; username: string; role?: string }[];
|
||||||
|
orgId: string;
|
||||||
|
setOrgId: (orgId: string) => void;
|
||||||
|
getOrg: () => Promise<any>;
|
||||||
|
addUser: (data: { userId?: string; username?: string; role?: string }) => Promise<any>;
|
||||||
|
removeUser: (userId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
export const useOrgStore = create<OrgStore>((set, get) => {
|
||||||
|
return {
|
||||||
|
showEdit: false,
|
||||||
|
setShowEdit: (showEdit) => set({ showEdit }),
|
||||||
|
formData: {},
|
||||||
|
setFormData: (formData) => set({ formData }),
|
||||||
|
loading: false,
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
showUserEdit: false,
|
||||||
|
setShowUserEdit: (showUserEdit) => set({ showUserEdit }),
|
||||||
|
userFormData: {},
|
||||||
|
setUserFormData: (userFormData) => set({ userFormData }),
|
||||||
|
list: [],
|
||||||
|
getList: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'org',
|
||||||
|
key: 'list',
|
||||||
|
});
|
||||||
|
set({ loading: false });
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ list: res.data });
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateData: async (data) => {
|
||||||
|
const { getList } = get();
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'org',
|
||||||
|
key: 'update',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('Success');
|
||||||
|
set({ showEdit: false, formData: [] });
|
||||||
|
getList();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
deleteData: async (id) => {
|
||||||
|
const { getList } = get();
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'org',
|
||||||
|
key: 'delete',
|
||||||
|
payload: {
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
getList();
|
||||||
|
message.success('Success');
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
org: {},
|
||||||
|
setOrg: (org) => set({ org }),
|
||||||
|
orgId: '',
|
||||||
|
setOrgId: (orgId) => set({ orgId }),
|
||||||
|
users: [],
|
||||||
|
getOrg: async () => {
|
||||||
|
const { orgId } = get();
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'org',
|
||||||
|
key: 'get',
|
||||||
|
payload: {
|
||||||
|
id: orgId,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
const { org, users } = res.data || {};
|
||||||
|
set({ org, users });
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addUser: async (data) => {
|
||||||
|
const { orgId } = get();
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'org-user',
|
||||||
|
key: 'operate',
|
||||||
|
data: { orgId, ...data, action: 'add' },
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('Success');
|
||||||
|
get().getOrg();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
},
|
||||||
|
removeUser: async (userId: string) => {
|
||||||
|
const { orgId } = get();
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'org-user',
|
||||||
|
key: 'operate',
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
action: 'remove',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('Success');
|
||||||
|
get().getOrg();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
63
src/app/remote/page.tsx
Normal file
63
src/app/remote/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
import { LayoutMain } from "@/modules/layout";
|
||||||
|
import { RemoteApp } from "@kevisual/remote-app";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { QueryRouterServer } from "@kevisual/router/browser";
|
||||||
|
export default function Home() {
|
||||||
|
useEffect(() => {
|
||||||
|
init();
|
||||||
|
}, []);
|
||||||
|
const init = async () => {
|
||||||
|
// const url = new URL('https://kevisual.cn/ws/proxy');
|
||||||
|
const isKevisualEnv = window.location.hostname.endsWith('kevisual.cn');
|
||||||
|
const kevisualWs = 'https://kevisual.cn/ws/proxy';
|
||||||
|
const url = new URL(isKevisualEnv ? kevisualWs : 'https://kevisual.xiongxiao.me/ws/proxy');
|
||||||
|
const token = localStorage.getItem('token') || '';
|
||||||
|
const id = 'remote';
|
||||||
|
const app = new QueryRouterServer();
|
||||||
|
app.route({
|
||||||
|
path: 'web-test',
|
||||||
|
key: 'web-test',
|
||||||
|
description: 'Web Router Studio',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
console.log('Received request at /web-test', ctx.query, ctx.state, ctx);
|
||||||
|
ctx.body = 'Hello from remote route!';
|
||||||
|
}).addTo(app);
|
||||||
|
app.createRouteList()
|
||||||
|
const remoteApp = new RemoteApp({
|
||||||
|
url: url.toString(),
|
||||||
|
token,
|
||||||
|
id,
|
||||||
|
app: app as any,
|
||||||
|
});
|
||||||
|
const connect = await remoteApp.isConnect();
|
||||||
|
if (connect) {
|
||||||
|
console.log('Connected to proxy server');
|
||||||
|
remoteApp.listenProxy();
|
||||||
|
remoteApp.on('message', (event) => {
|
||||||
|
const _msg = event.toString();
|
||||||
|
console.log('Received message from remote app:', _msg);
|
||||||
|
const remote = document.querySelector('#remote')
|
||||||
|
if (remote) {
|
||||||
|
remote.innerHTML += `\n${_msg}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
remoteApp.on('open', () => {
|
||||||
|
console.log('Connection to remote app opened');
|
||||||
|
remoteApp.listenProxy()
|
||||||
|
});
|
||||||
|
remoteApp.on('close', () => {
|
||||||
|
console.log('Connection to remote app closed');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('Not connected to proxy server');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||||
|
<LayoutMain>
|
||||||
|
<div id="remote"></div>
|
||||||
|
</LayoutMain>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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('获取失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
292
src/app/user/page.tsx
Normal file
292
src/app/user/page.tsx
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useUserStore } from './store';
|
||||||
|
import { useLayoutStore } from '@/modules/layout/store';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { LayoutMain } from '@/modules/layout';
|
||||||
|
import { Pencil, Key, User } from 'lucide-react';
|
||||||
|
import PandaPNG from '@/assets/panda.jpg';
|
||||||
|
|
||||||
|
const ProfileCard = () => {
|
||||||
|
const { me, getMe } = useLayoutStore();
|
||||||
|
const { setShowEdit, setShowChangePassword } = useUserStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMe();
|
||||||
|
}, [getMe]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-2xl mx-auto">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
个人信息
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>管理您的个人资料和账户设置</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-20 h-20 rounded-full overflow-hidden border-2 border-gray-200">
|
||||||
|
{me?.avatar ? (
|
||||||
|
<img
|
||||||
|
src={me.avatar}
|
||||||
|
alt="avatar"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={PandaPNG.src}
|
||||||
|
alt="avatar"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{me?.username || '-'}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{me?.description || '暂无描述'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Info Fields */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-[120px_1fr] items-center gap-4">
|
||||||
|
<Label>用户名</Label>
|
||||||
|
<div className="text-gray-700">{me?.username || '-'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[120px_1fr] items-center gap-4">
|
||||||
|
<Label>昵称</Label>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-700">{me?.nickname || '-'}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowEdit(true)}>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[120px_1fr] items-start gap-4">
|
||||||
|
<Label className="mt-2">个人描述</Label>
|
||||||
|
<div className="flex items-center justify-between flex-1">
|
||||||
|
<p className="text-gray-700 text-sm">{me?.description || '暂无描述'}</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowEdit(true)}>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[120px_1fr] items-center gap-4">
|
||||||
|
<Label>用户ID</Label>
|
||||||
|
<div className="text-gray-500 text-sm">{me?.id || '-'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[120px_1fr] items-center gap-4">
|
||||||
|
<Label>密码</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowChangePassword(true)}>
|
||||||
|
<Key className="w-4 h-4 mr-1" />
|
||||||
|
修改密码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Profile Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => setShowEdit(true)}>
|
||||||
|
<Pencil className="w-4 h-4 mr-1" />
|
||||||
|
编辑资料
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditProfileModal = () => {
|
||||||
|
const { showEdit, setShowEdit, setFormData, updateSelf, loading } = useUserStore();
|
||||||
|
const { me, getMe } = useLayoutStore();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showEdit) {
|
||||||
|
reset({
|
||||||
|
nickname: me?.nickname || '',
|
||||||
|
description: me?.description || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [me, showEdit, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
const res = await updateSelf(data);
|
||||||
|
if (res) {
|
||||||
|
setShowEdit(false);
|
||||||
|
setFormData({});
|
||||||
|
await getMe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showEdit} onOpenChange={(open) => {
|
||||||
|
setShowEdit(open);
|
||||||
|
if (!open) setFormData({});
|
||||||
|
}}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑个人资料</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('nickname')}
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>个人描述</Label>
|
||||||
|
<Textarea
|
||||||
|
{...register('description')}
|
||||||
|
placeholder="请输入个人描述"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowEdit(false)}
|
||||||
|
disabled={loading}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? '保存中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChangePasswordModal = () => {
|
||||||
|
const { showChangePassword, setShowChangePassword, loading } = useUserStore();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
const { updateSelf } = useUserStore.getState();
|
||||||
|
const res = await updateSelf({
|
||||||
|
password: data.newPassword,
|
||||||
|
});
|
||||||
|
if (res) {
|
||||||
|
setShowChangePassword(false);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showChangePassword} onOpenChange={setShowChangePassword}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>修改密码</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('newPassword', {
|
||||||
|
required: '请输入新密码',
|
||||||
|
minLength: { value: 6, message: '密码长度至少6位' },
|
||||||
|
})}
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入新密码"
|
||||||
|
className={errors.newPassword ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.newPassword && (
|
||||||
|
<span className="text-xs text-red-500">{errors.newPassword.message as string}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>确认新密码</Label>
|
||||||
|
<Input
|
||||||
|
{...register('confirmPassword', {
|
||||||
|
required: '请确认新密码',
|
||||||
|
validate: (value, formValues) =>
|
||||||
|
value === formValues.newPassword || '两次输入的密码不一致',
|
||||||
|
})}
|
||||||
|
type="password"
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
className={errors.confirmPassword ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<span className="text-xs text-red-500">{errors.confirmPassword.message as string}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowChangePassword(false)}
|
||||||
|
disabled={loading}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading ? '保存中...' : '确认修改'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserProfile = () => {
|
||||||
|
return (
|
||||||
|
<div className="p-6 w-full h-full overflow-auto">
|
||||||
|
<ProfileCard />
|
||||||
|
<EditProfileModal />
|
||||||
|
<ChangePasswordModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return <LayoutMain title="个人信息"><UserProfile /></LayoutMain>;
|
||||||
|
}
|
||||||
57
src/app/user/store/index.ts
Normal file
57
src/app/user/store/index.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { query, queryLogin } from '@/modules/query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
type UserInfo = {
|
||||||
|
avatar: string;
|
||||||
|
description: string | null;
|
||||||
|
id: string;
|
||||||
|
needChangePassword: boolean;
|
||||||
|
nickname: string | null;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
type UserStore = {
|
||||||
|
showEdit: boolean;
|
||||||
|
setShowEdit: (showEdit: boolean) => void;
|
||||||
|
showNameEdit: boolean;
|
||||||
|
setShowNameEdit: (showNameEdit: boolean) => void;
|
||||||
|
showCheckUserExist: boolean;
|
||||||
|
setShowCheckUserExist: (showCheckUserExist: boolean) => void;
|
||||||
|
formData: any;
|
||||||
|
setFormData: (formData: any) => void;
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
updateSelf: (data: any) => Promise<any>;
|
||||||
|
showChangePassword: boolean;
|
||||||
|
setShowChangePassword: (showChangePassword: boolean) => void;
|
||||||
|
};
|
||||||
|
export const useUserStore = create<UserStore>((set, get) => {
|
||||||
|
return {
|
||||||
|
showEdit: false,
|
||||||
|
setShowEdit: (showEdit) => set({ showEdit }),
|
||||||
|
showNameEdit: false,
|
||||||
|
setShowNameEdit: (showNameEdit) => set({ showNameEdit }),
|
||||||
|
showCheckUserExist: false,
|
||||||
|
setShowCheckUserExist: (showCheckUserExist) => set({ showCheckUserExist }),
|
||||||
|
formData: {},
|
||||||
|
setFormData: (formData) => set({ formData }),
|
||||||
|
loading: false,
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
updateSelf: async (data) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'updateSelf',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('Success');
|
||||||
|
set({ formData: res.data });
|
||||||
|
return res.data;
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showChangePassword: false,
|
||||||
|
setShowChangePassword: (showChangePassword) => set({ showChangePassword }),
|
||||||
|
};
|
||||||
|
});
|
||||||
90
src/app/user/store/login.ts
Normal file
90
src/app/user/store/login.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { query, queryLogin } from '@/modules/query';
|
||||||
|
import { basename } from '@/modules/basename';
|
||||||
|
import { toast as message } from 'sonner';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
// 如果自己是在iframe中登录,需要调用这个方法
|
||||||
|
export const postLoginInIframe = (token: string) => {
|
||||||
|
console.log('window.parent !== window', window.parent !== window);
|
||||||
|
if (window.parent === window) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 获取父窗口的来源
|
||||||
|
const parentOrigin = window.location.ancestorOrigins ? window.location.ancestorOrigins[0] : document.referrer;
|
||||||
|
|
||||||
|
// 检查父窗口的来源是否合法
|
||||||
|
const allowedOrigins = ['http://localhost', /^https?:\/\/(.+\.)?on-ai\.ai$/, /^https?:\/\/(.+\.)?xiongxiao\.me$/];
|
||||||
|
|
||||||
|
let targetOrigin: string | null = null;
|
||||||
|
|
||||||
|
// 根据来源动态选择 targetOrigin
|
||||||
|
if (allowedOrigins.some((origin) => (typeof origin === 'string' ? parentOrigin.includes(origin) : origin.test(parentOrigin)))) {
|
||||||
|
targetOrigin = parentOrigin; // 使用合法来源作为 targetOrigin
|
||||||
|
}
|
||||||
|
// 如果找到合法的 targetOrigin,则发送消息
|
||||||
|
if (targetOrigin) {
|
||||||
|
const message = { type: 'login-from-iframe', data: { token } };
|
||||||
|
parent.postMessage(message, targetOrigin);
|
||||||
|
} else {
|
||||||
|
console.warn('Parent origin is not allowed:', parentOrigin);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoginStore = {
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
formData: any;
|
||||||
|
setFormData: (formData: any) => void;
|
||||||
|
login: () => Promise<void>;
|
||||||
|
register: () => Promise<void>;
|
||||||
|
isLogin: boolean;
|
||||||
|
setIsLogin: (isLogin: boolean) => void;
|
||||||
|
};
|
||||||
|
export const useLoginStore = create<LoginStore>((set, get) => {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
formData: {},
|
||||||
|
setFormData: (formData) => set({ formData }),
|
||||||
|
login: async () => {
|
||||||
|
const { formData } = get();
|
||||||
|
const { username, password } = formData;
|
||||||
|
if (!username || !password) {
|
||||||
|
message.error('Please input username and password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({ loading: true });
|
||||||
|
const res = await queryLogin.login({ username, password });
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('Success');
|
||||||
|
set({ isLogin: true });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
if (window.parent !== window) {
|
||||||
|
postLoginInIframe(res.data?.accessToken || '');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
}
|
||||||
|
const search = new URLSearchParams(window.location.search);
|
||||||
|
const redirect = search.get('redirect');
|
||||||
|
if (redirect) {
|
||||||
|
window.location.href = redirect;
|
||||||
|
} else {
|
||||||
|
window.location.href = basename ? basename + '/' : '/';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
register: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
const res = await query.post({ path: 'user', key: 'register' });
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('Success');
|
||||||
|
// 跳到某一个页面
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isLogin: false,
|
||||||
|
setIsLogin: (isLogin) => set({ isLogin }),
|
||||||
|
};
|
||||||
|
});
|
||||||
184
src/app/users/page.tsx
Normal file
184
src/app/users/page.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useUserStore } from './store/user';
|
||||||
|
import { useAdminStore } from './store/admin';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { LayoutMain } from '@/modules/layout';
|
||||||
|
|
||||||
|
const TableList = () => {
|
||||||
|
const { list, setShowEdit, setFormData, deleteData } = useUserStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>用户名</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{list.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>{user.id}</TableCell>
|
||||||
|
<TableCell>{user.username}</TableCell>
|
||||||
|
<TableCell>{user.description || '-'}</TableCell>
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowEdit(true);
|
||||||
|
setFormData(user);
|
||||||
|
}}>
|
||||||
|
<Pencil className="w-4 h-4 mr-1" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteData(user.id)}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormModal = () => {
|
||||||
|
const { showEdit, setShowEdit, formData, setFormData, getList } = useUserStore();
|
||||||
|
const { createNewUser, updateUser } = useAdminStore();
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showEdit) return;
|
||||||
|
if (formData?.id) {
|
||||||
|
reset({ username: formData.username, description: formData.description });
|
||||||
|
} else {
|
||||||
|
reset({ username: '', description: '' });
|
||||||
|
}
|
||||||
|
}, [formData, showEdit, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
let res;
|
||||||
|
if (formData?.id) {
|
||||||
|
res = await updateUser(formData.id, data);
|
||||||
|
} else {
|
||||||
|
res = await createNewUser(data);
|
||||||
|
}
|
||||||
|
if (res?.code === 200) {
|
||||||
|
setShowEdit(false);
|
||||||
|
setFormData({});
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 className="text-sm font-medium">用户名</label>
|
||||||
|
<Input
|
||||||
|
{...register('username', { required: '请输入用户名' })}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
className={errors.username ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.username && <span className="text-xs text-red-500">{errors.username.message as string}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">描述</label>
|
||||||
|
<Input
|
||||||
|
{...register('description')}
|
||||||
|
placeholder="请输入描述"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!formData?.id && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-sm font-medium">密码</label>
|
||||||
|
<Input
|
||||||
|
{...register('password', { required: '请输入密码' })}
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
className={errors.password ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.password && <span className="text-xs text-red-500">{errors.password.message as string}</span>}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const List = () => {
|
||||||
|
const { getList, setShowEdit, setFormData } = useUserStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getList();
|
||||||
|
}, [getList]);
|
||||||
|
|
||||||
|
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 />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return <LayoutMain><List /></LayoutMain>;
|
||||||
|
}
|
||||||
126
src/app/users/store/admin.ts
Normal file
126
src/app/users/store/admin.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { query } from '@/modules';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Result } from '@kevisual/query/query';
|
||||||
|
type AdminStore = {
|
||||||
|
/**
|
||||||
|
* 创建新用户
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
createNewUser: (data: any) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* 删除用户
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
deleteUser: (id: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* 更新用户
|
||||||
|
* @param id
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
updateUser: (id: string, data: any) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置密码
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
resetPassword: (id: string, password?: string) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改用户名
|
||||||
|
* @param id
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
|
changeName: (id: string, name: string) => Promise<Result<any>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否存在
|
||||||
|
* @param name
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
checkUserExist: (name: string) => Promise<boolean | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAdminStore = create<AdminStore>((set) => ({
|
||||||
|
createNewUser: async (data: any) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'createNewUser',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('创建用户成功');
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '创建用户失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteUser: async (id: string) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'deleteUser',
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('删除用户成功');
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '删除用户失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateUser: async (id: string, data: any) => {
|
||||||
|
console.log('updateUser', id, data);
|
||||||
|
toast.success('功能开发中');
|
||||||
|
},
|
||||||
|
resetPassword: async (id: string, password?: string) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'resetPassword',
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
if (res.data.password) {
|
||||||
|
toast.success('new password is ' + res.data.password);
|
||||||
|
} else {
|
||||||
|
toast.success('重置密码成功');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '重置密码失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
changeName: async (id: string, name: string) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'changeName',
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
newName: name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('修改用户名成功');
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '修改用户名失败');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
checkUserExist: async (name: string) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'checkUserExist',
|
||||||
|
data: {
|
||||||
|
username: name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
const user = res.data || {};
|
||||||
|
return !!user.id;
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '检查用户是否存在,请求失败');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
99
src/app/users/store/user.ts
Normal file
99
src/app/users/store/user.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { query } from '@/modules';
|
||||||
|
import { toast as message } from 'sonner';
|
||||||
|
type UserStore = {
|
||||||
|
showEdit: boolean;
|
||||||
|
setShowEdit: (showEdit: boolean) => void;
|
||||||
|
showNameEdit: boolean;
|
||||||
|
setShowNameEdit: (showNameEdit: boolean) => void;
|
||||||
|
showCheckUserExist: boolean;
|
||||||
|
setShowCheckUserExist: (showCheckUserExist: boolean) => void;
|
||||||
|
formData: any;
|
||||||
|
setFormData: (formData: any) => void;
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
list: any[];
|
||||||
|
getList: () => Promise<void>;
|
||||||
|
updateData: (data: any) => Promise<any>;
|
||||||
|
updateSelf: (data: any) => Promise<any>;
|
||||||
|
deleteData: (id: string) => Promise<void>;
|
||||||
|
showChangePassword: boolean;
|
||||||
|
setShowChangePassword: (showChangePassword: boolean) => void;
|
||||||
|
};
|
||||||
|
export const useUserStore = create<UserStore>((set, get) => {
|
||||||
|
return {
|
||||||
|
showEdit: false,
|
||||||
|
setShowEdit: (showEdit) => set({ showEdit }),
|
||||||
|
showNameEdit: false,
|
||||||
|
setShowNameEdit: (showNameEdit) => set({ showNameEdit }),
|
||||||
|
showCheckUserExist: false,
|
||||||
|
setShowCheckUserExist: (showCheckUserExist) => set({ showCheckUserExist }),
|
||||||
|
formData: {},
|
||||||
|
setFormData: (formData) => set({ formData }),
|
||||||
|
loading: false,
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
list: [],
|
||||||
|
getList: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'list',
|
||||||
|
});
|
||||||
|
set({ loading: false });
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ list: res.data });
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateData: async (data) => {
|
||||||
|
const { getList } = get();
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'update',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('Success');
|
||||||
|
set({ showEdit: false, formData: [] });
|
||||||
|
getList();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
updateSelf: async (data) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'updateSelf',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('Success');
|
||||||
|
set({ formData: res.data });
|
||||||
|
return res.data;
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteData: async (id) => {
|
||||||
|
const { getList } = get();
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'user',
|
||||||
|
key: 'delete',
|
||||||
|
payload: {
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
getList();
|
||||||
|
message.success('Success');
|
||||||
|
} else {
|
||||||
|
message.error(res.message || 'Request failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showChangePassword: false,
|
||||||
|
setShowChangePassword: (showChangePassword) => set({ showChangePassword }),
|
||||||
|
};
|
||||||
|
});
|
||||||
48
src/components/ui/badge.tsx
Normal file
48
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -22,9 +22,11 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: "size-9",
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
"icon-sm": "size-8",
|
"icon-sm": "size-8",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
@@ -46,7 +48,7 @@ function Button({
|
|||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
|||||||
310
src/components/ui/combobox.tsx
Normal file
310
src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||||
|
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
|
||||||
|
const Combobox = ComboboxPrimitive.Root
|
||||||
|
|
||||||
|
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||||
|
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Trigger
|
||||||
|
data-slot="combobox-trigger"
|
||||||
|
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon
|
||||||
|
data-slot="combobox-trigger-icon"
|
||||||
|
className="text-muted-foreground pointer-events-none size-4"
|
||||||
|
/>
|
||||||
|
</ComboboxPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Clear
|
||||||
|
data-slot="combobox-clear"
|
||||||
|
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<XIcon className="pointer-events-none" />
|
||||||
|
</ComboboxPrimitive.Clear>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxInput({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
showTrigger = true,
|
||||||
|
showClear = false,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Input.Props & {
|
||||||
|
showTrigger?: boolean
|
||||||
|
showClear?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<InputGroup className={cn("w-auto", className)}>
|
||||||
|
<ComboboxPrimitive.Input
|
||||||
|
render={<InputGroupInput disabled={disabled} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
{showTrigger && (
|
||||||
|
<InputGroupButton
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
asChild
|
||||||
|
data-slot="input-group-button"
|
||||||
|
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<ComboboxTrigger />
|
||||||
|
</InputGroupButton>
|
||||||
|
)}
|
||||||
|
{showClear && <ComboboxClear disabled={disabled} />}
|
||||||
|
</InputGroupAddon>
|
||||||
|
{children}
|
||||||
|
</InputGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxContent({
|
||||||
|
className,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 6,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
anchor,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
ComboboxPrimitive.Positioner.Props,
|
||||||
|
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Portal>
|
||||||
|
<ComboboxPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
anchor={anchor}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<ComboboxPrimitive.Popup
|
||||||
|
data-slot="combobox-content"
|
||||||
|
data-chips={!!anchor}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ComboboxPrimitive.Positioner>
|
||||||
|
</ComboboxPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.List
|
||||||
|
data-slot="combobox-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Item
|
||||||
|
data-slot="combobox-item"
|
||||||
|
className={cn(
|
||||||
|
"data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ComboboxPrimitive.ItemIndicator
|
||||||
|
data-slot="combobox-item-indicator"
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
|
||||||
|
</ComboboxPrimitive.ItemIndicator>
|
||||||
|
</ComboboxPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Group
|
||||||
|
data-slot="combobox-group"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.GroupLabel
|
||||||
|
data-slot="combobox-label"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground px-2 py-1.5 text-xs pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Empty
|
||||||
|
data-slot="combobox-empty"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Separator
|
||||||
|
data-slot="combobox-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxChips({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||||
|
ComboboxPrimitive.Chips.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Chips
|
||||||
|
data-slot="combobox-chips"
|
||||||
|
className={cn(
|
||||||
|
"dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxChip({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showRemove = true,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Chip.Props & {
|
||||||
|
showRemove?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Chip
|
||||||
|
data-slot="combobox-chip"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showRemove && (
|
||||||
|
<ComboboxPrimitive.ChipRemove
|
||||||
|
render={<Button variant="ghost" size="icon-xs" />}
|
||||||
|
className="-ml-1 opacity-50 hover:opacity-100"
|
||||||
|
data-slot="combobox-chip-remove"
|
||||||
|
>
|
||||||
|
<XIcon className="pointer-events-none" />
|
||||||
|
</ComboboxPrimitive.ChipRemove>
|
||||||
|
)}
|
||||||
|
</ComboboxPrimitive.Chip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComboboxChipsInput({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComboboxPrimitive.Input.Props) {
|
||||||
|
return (
|
||||||
|
<ComboboxPrimitive.Input
|
||||||
|
data-slot="combobox-chip-input"
|
||||||
|
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useComboboxAnchor() {
|
||||||
|
return React.useRef<HTMLDivElement | null>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Combobox,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxList,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxGroup,
|
||||||
|
ComboboxLabel,
|
||||||
|
ComboboxCollection,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxSeparator,
|
||||||
|
ComboboxChips,
|
||||||
|
ComboboxChip,
|
||||||
|
ComboboxChipsInput,
|
||||||
|
ComboboxTrigger,
|
||||||
|
ComboboxValue,
|
||||||
|
useComboboxAnchor,
|
||||||
|
}
|
||||||
135
src/components/ui/drawer.tsx
Normal file
135
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"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 DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
170
src/components/ui/input-group.tsx
Normal file
170
src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||||
|
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||||
|
|
||||||
|
// Variants based on alignment.
|
||||||
|
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||||
|
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||||
|
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||||
|
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||||
|
|
||||||
|
// Focus state.
|
||||||
|
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||||
|
|
||||||
|
// Error state.
|
||||||
|
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||||
|
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"text-sm shadow-none flex gap-2 items-center",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||||
|
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
@@ -7,8 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"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",
|
||||||
"focus-visible:outline-hidden",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
1
src/modules/index.ts
Normal file
1
src/modules/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { query } from './query'
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
'use client';
|
||||||
import { MenuOutlined, SwapOutlined } from '@ant-design/icons';
|
import { MenuOutlined, SwapOutlined } from '@ant-design/icons';
|
||||||
import { LayoutMenu, useQuickMenu } from './Menu';
|
import { LayoutMenu, useQuickMenu } from './Menu';
|
||||||
import { useLayoutStore, usePlatformStore } from './store';
|
import { useLayoutStore, usePlatformStore } from './store';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { LayoutUser } from './LayoutUser';
|
import { LayoutUser } from './LayoutUser';
|
||||||
import PandaPNG from '@/assets/panda.jpg';
|
import PandaPNG from '@/assets/panda.jpg';
|
||||||
import QRCodePNG from '@/assets/qrcode-8x8.jpg';
|
import QRCodePNG from '@/assets/qrcode-8x8.jpg';
|
||||||
@@ -53,6 +55,7 @@ export const LayoutMain = (props: LayoutMainProps) => {
|
|||||||
);
|
);
|
||||||
const { isMac, mount, isElectron } = platformStore;
|
const { isMac, mount, isElectron } = platformStore;
|
||||||
const quickMenu = useQuickMenu();
|
const quickMenu = useQuickMenu();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
platformStore.init();
|
platformStore.init();
|
||||||
@@ -74,8 +77,7 @@ export const LayoutMain = (props: LayoutMainProps) => {
|
|||||||
<div className='text-xl font-bold '>{props.title}</div>
|
<div className='text-xl font-bold '>{props.title}</div>
|
||||||
<div className='flex items-center gap-2 text-sm '>
|
<div className='flex items-center gap-2 text-sm '>
|
||||||
{quickMenu.map((item, index) => {
|
{quickMenu.map((item, index) => {
|
||||||
if (typeof window === 'undefined') return null;
|
const isActive = pathname === item.link;
|
||||||
const isActive = location?.pathname === item.link;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -47,9 +47,10 @@ export const usePlatformStore = create<PlatfromStore>((set) => {
|
|||||||
type Me = {
|
type Me = {
|
||||||
id?: string;
|
id?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
nickname?: string | null;
|
||||||
needChangePassword?: boolean;
|
needChangePassword?: boolean;
|
||||||
role?: string;
|
role?: string;
|
||||||
description?: string;
|
description?: string | null;
|
||||||
type?: 'user' | 'org';
|
type?: 'user' | 'org';
|
||||||
orgs?: string[];
|
orgs?: string[];
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user