Compare commits
6 Commits
44aef38631
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 09f5f06baa | |||
| e42fce5bd1 | |||
| 85f742ad2b | |||
| cc466f7bd4 | |||
| a4e17023d0 | |||
| 0de344c7ad |
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./dist/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -10,7 +10,7 @@ const nextConfig: NextConfig = {
|
||||
distDir: 'dist',
|
||||
basePath: basePath,
|
||||
trailingSlash: true,
|
||||
transpilePackages: ['@kevisual/api'],
|
||||
transpilePackages: ['@kevisual/api', "@kevisual/use-config"],
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
||||
21
package.json
21
package.json
@@ -11,10 +11,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@kevisual/api": "^0.0.26",
|
||||
"@base-ui/react": "^1.1.0",
|
||||
"@kevisual/api": "^0.0.41",
|
||||
"@kevisual/cache": "^0.0.5",
|
||||
"@kevisual/query": "^0.0.38",
|
||||
"@kevisual/router": "^0.0.60",
|
||||
"@kevisual/query": "^0.0.39",
|
||||
"@kevisual/router": "^0.0.66",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -27,7 +28,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"antd": "^6.2.1",
|
||||
"antd": "^6.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -37,21 +38,23 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"es-toolkit": "^1.44.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"marked": "^17.0.1",
|
||||
"next": "16.1.4",
|
||||
"react": "19.2.3",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"valtio": "^2.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zustand": "^5.0.10"
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/context": "^0.0.4",
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@kevisual/use-config": "^1.0.30",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^25",
|
||||
"@types/react": "^19",
|
||||
|
||||
1956
pnpm-lock.yaml
generated
1956
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);
|
||||
}
|
||||
let baseUri = location.origin;
|
||||
const openUrl = `${baseUri}/root/ai-pages/ai-editor/?folder=${folder}/`;
|
||||
const openUrl = `${baseUri}/root/codepod/?folder=${folder}/`;
|
||||
openLink(openUrl, '_blank');
|
||||
}}>
|
||||
<Folder className='h-4 w-4' />
|
||||
|
||||
@@ -62,7 +62,7 @@ export const AppDeleteModal = () => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tips</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='w-[400px]'>
|
||||
<div className=''>
|
||||
<p className='text-sm text-gray-500'>Delete App Introduce</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -16,19 +16,14 @@ interface DatePickerProps {
|
||||
}
|
||||
|
||||
export function DatePicker({ className, value, onChange }: DatePickerProps) {
|
||||
const [date, setDate] = React.useState<Date | undefined>(
|
||||
value ? new Date(typeof value === 'string' ? value : value.toISOString()) : undefined
|
||||
)
|
||||
const toDate = (val: string | Dayjs | undefined): Date | undefined => {
|
||||
if (!val) return undefined
|
||||
return new Date(typeof val === 'string' ? val : val.toISOString())
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
const dateValue = typeof value === 'string' ? value : value.toISOString()
|
||||
setDate(new Date(dateValue))
|
||||
}
|
||||
}, [value])
|
||||
const date = toDate(value)
|
||||
|
||||
const handleSelect = (selectedDate: Date | undefined) => {
|
||||
setDate(selectedDate)
|
||||
if (selectedDate && onChange) {
|
||||
onChange(dayjs(selectedDate))
|
||||
}
|
||||
@@ -22,6 +22,24 @@ import {
|
||||
TooltipTrigger,
|
||||
} 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 }) => {
|
||||
return (
|
||||
<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 (
|
||||
<TooltipProvider>
|
||||
<form className={clsx('flex flex-col w-full gap-4', className)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<LabelWithTooltip label="共享" tips={shareTips} />
|
||||
<KeyShareSelect value={formData?.share} onChange={(value) => onChangeValue('share', value)} />
|
||||
</div>
|
||||
|
||||
{keys.map((item: any) => {
|
||||
const tips = getTips(item);
|
||||
const itemTips = getTips(item);
|
||||
|
||||
return (
|
||||
<div key={item} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<LabelWithTooltip label={item} tips={itemTips} />
|
||||
{item === 'expiration-time' && (
|
||||
<DatePicker value={formData[item] || ''} onChange={(date) => onChangeValue(item, date)} />
|
||||
)}
|
||||
@@ -3,7 +3,6 @@
|
||||
import * as React from "react"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
type TagsInputProps = {
|
||||
@@ -17,13 +16,16 @@ export function TagsInput({ value, onChange, placeholder = "输入用户名,
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
|
||||
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()
|
||||
const newValue = inputValue.trim()
|
||||
if (newValue && !value.includes(newValue)) {
|
||||
onChange([...value, newValue])
|
||||
if (!value.includes(trimmed)) {
|
||||
onChange([...value, trimmed])
|
||||
setInputValue("")
|
||||
} else {
|
||||
setInputValue("")
|
||||
}
|
||||
setInputValue("")
|
||||
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
|
||||
onChange(value.slice(0, -1))
|
||||
}
|
||||
@@ -35,9 +37,9 @@ export function TagsInput({ value, onChange, placeholder = "输入用户名,
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-2 w-full", className)}>
|
||||
{value.map((tag, index) => (
|
||||
{value.map((tag) => (
|
||||
<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"
|
||||
>
|
||||
<span>{tag}</span>
|
||||
@@ -45,6 +47,7 @@ export function TagsInput({ value, onChange, placeholder = "输入用户名,
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
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" />
|
||||
</button>
|
||||
@@ -1,16 +1,7 @@
|
||||
'use client';
|
||||
|
||||
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 = [
|
||||
{
|
||||
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 {
|
||||
static parse(metadata: Record<string, any>) {
|
||||
const keys = Object.keys(metadata);
|
||||
const newMetadata = {};
|
||||
keys.forEach((key) => {
|
||||
const tip = keysTips.find((item) => item.key === key);
|
||||
if (tip && tip.parse) {
|
||||
newMetadata[key] = tip.parse(metadata[key]);
|
||||
} else {
|
||||
newMetadata[key] = metadata[key];
|
||||
}
|
||||
});
|
||||
return newMetadata;
|
||||
return Object.entries(metadata).reduce((acc, [key, value]) => {
|
||||
const tip = tipsMap.get(key);
|
||||
acc[key] = tip?.parse ? tip.parse(value) : value;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
}
|
||||
|
||||
static stringify(metadata: Record<string, any>) {
|
||||
const keys = Object.keys(metadata);
|
||||
const newMetadata = {};
|
||||
keys.forEach((key) => {
|
||||
const tip = keysTips.find((item) => item.key === key);
|
||||
if (tip && tip.stringify) {
|
||||
newMetadata[key] = tip.stringify(metadata[key]);
|
||||
} else {
|
||||
newMetadata[key] = metadata[key];
|
||||
}
|
||||
});
|
||||
return newMetadata;
|
||||
return Object.entries(metadata).reduce((acc, [key, value]) => {
|
||||
const tip = tipsMap.get(key);
|
||||
acc[key] = tip?.stringify ? tip.stringify(value) : value;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import clsx from 'clsx';
|
||||
// import { IconButton } from '@kevisual/components/button/index.tsx';
|
||||
// import { Select } from '@kevisual/components/select/index.tsx';
|
||||
import { iText } from './constants';
|
||||
import { PermissionManager } from './modules/PermissionManager';
|
||||
import { PermissionManager } from './modules/permission/PermissionManager';
|
||||
import { toast as message } from 'sonner';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -197,7 +197,7 @@ const ShareModal = () => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>分享</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className='flex flex-col gap-2 w-[400px] '>
|
||||
<div className='flex flex-col gap-2 '>
|
||||
<PermissionManager
|
||||
value={permission}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { query } from '@/modules/query';
|
||||
import { toast } from 'sonner';
|
||||
import { QueryConfig } from '@kevisual/api/config';
|
||||
import { QueryConfig, Config } from '@kevisual/api/config';
|
||||
|
||||
export const queryConfig = new QueryConfig({ query: query as any });
|
||||
|
||||
@@ -16,6 +16,10 @@ interface ConfigStore {
|
||||
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) => ({
|
||||
@@ -75,4 +79,26 @@ export const useConfigStore = create<ConfigStore>((set, get) => ({
|
||||
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>
|
||||
<DialogTitle>添加域名</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-4 w-[500px]">
|
||||
<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>
|
||||
|
||||
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');
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
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 }),
|
||||
};
|
||||
});
|
||||
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 }
|
||||
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,
|
||||
}
|
||||
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,
|
||||
}
|
||||
@@ -47,9 +47,10 @@ export const usePlatformStore = create<PlatfromStore>((set) => {
|
||||
type Me = {
|
||||
id?: string;
|
||||
username?: string;
|
||||
nickname?: string | null;
|
||||
needChangePassword?: boolean;
|
||||
role?: string;
|
||||
description?: string;
|
||||
description?: string | null;
|
||||
type?: 'user' | 'org';
|
||||
orgs?: string[];
|
||||
avatar?: string;
|
||||
|
||||
Reference in New Issue
Block a user