Compare commits

..

6 Commits

Author SHA1 Message Date
6b1f58614e update 2026-02-05 14:31:38 +08:00
80fb01526c chore: update @kevisual/remote-app dependency to version 0.0.4 in package.json and pnpm-lock.yaml 2026-02-05 12:57:44 +08:00
d3f0393332 feat: update button component to use Slot.Root and add new size variants; refactor textarea styles for improved accessibility; implement remote app connection logic in new page 2026-02-05 05:08:52 +08:00
09f5f06baa feat: add environment variable management page with import/export functionality
- Implemented EnvPage component for managing environment variables.
- Added functionality to load, add, remove, and update environment variables.
- Included validation for empty and duplicate keys.
- Implemented import/export features for environment variables in JSON format.
- Integrated autocompletion for environment variable keys based on predefined config.

feat: create user profile management with edit and password change modals

- Developed ProfileCard component to display user information.
- Added EditProfileModal for updating user details.
- Implemented ChangePasswordModal for password modification.
- Integrated user data fetching and state management using Zustand.

feat: establish user store for managing user state and actions

- Created user store with Zustand for managing user profile state.
- Added actions for updating user information and handling loading states.

feat: implement login store for user authentication

- Developed login store for managing login state and actions.
- Added functionality for user login and registration with error handling.

feat: create reusable UI components for input groups and comboboxes

- Developed InputGroup and related components for enhanced input handling.
- Created Combobox component for improved selection functionality.
- Added Badge component for displaying contextual information.
2026-02-02 23:30:08 +08:00
e42fce5bd1 update 2026-02-01 19:22:54 +08:00
85f742ad2b update 2026-02-01 18:45:34 +08:00
22 changed files with 2562 additions and 707 deletions

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./dist/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -10,7 +10,7 @@ const nextConfig: NextConfig = {
distDir: 'dist', distDir: 'dist',
basePath: basePath, basePath: basePath,
trailingSlash: true, trailingSlash: true,
transpilePackages: ['@kevisual/api'], transpilePackages: ['@kevisual/api', "@kevisual/use-config", "@kevisual/remote-app", "@kevisual/router"],
images: { images: {
unoptimized: true, unoptimized: true,
}, },

View File

@@ -11,10 +11,11 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"@kevisual/api": "^0.0.28", "@base-ui/react": "^1.1.0",
"@kevisual/api": "^0.0.44",
"@kevisual/cache": "^0.0.5", "@kevisual/cache": "^0.0.5",
"@kevisual/query": "^0.0.38", "@kevisual/query": "^0.0.39",
"@kevisual/router": "^0.0.63", "@kevisual/router": "^0.0.70",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -27,7 +28,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"antd": "^6.2.2", "antd": "^6.2.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -39,7 +40,8 @@
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"marked": "^17.0.1", "marked": "^17.0.1",
"next": "16.1.5", "next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "19.2.4", "react-dom": "19.2.4",
@@ -48,11 +50,13 @@
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"valtio": "^2.3.0", "valtio": "^2.3.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zustand": "^5.0.10" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@kevisual/context": "^0.0.4", "@kevisual/context": "^0.0.4",
"@kevisual/remote-app": "^0.0.4",
"@kevisual/types": "^0.0.12", "@kevisual/types": "^0.0.12",
"@kevisual/use-config": "^1.0.30",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^25", "@types/node": "^25",
"@types/react": "^19", "@types/react": "^19",

1541
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,19 +16,14 @@ interface DatePickerProps {
} }
export function DatePicker({ className, value, onChange }: DatePickerProps) { export function DatePicker({ className, value, onChange }: DatePickerProps) {
const [date, setDate] = React.useState<Date | undefined>( const toDate = (val: string | Dayjs | undefined): Date | undefined => {
value ? new Date(typeof value === 'string' ? value : value.toISOString()) : undefined if (!val) return undefined
) return new Date(typeof val === 'string' ? val : val.toISOString())
}
React.useEffect(() => { const date = toDate(value)
if (value) {
const dateValue = typeof value === 'string' ? value : value.toISOString()
setDate(new Date(dateValue))
}
}, [value])
const handleSelect = (selectedDate: Date | undefined) => { const handleSelect = (selectedDate: Date | undefined) => {
setDate(selectedDate)
if (selectedDate && onChange) { if (selectedDate && onChange) {
onChange(dayjs(selectedDate)) onChange(dayjs(selectedDate))
} }

View File

@@ -22,6 +22,24 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
const LabelWithTooltip = ({ label, tips }: { label: string; tips?: string }) => (
<div className="flex items-center gap-2">
<label className="text-sm font-medium">{label}</label>
{tips && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-4 w-4 p-0">
<HelpCircle size={16} />
</Button>
</TooltipTrigger>
<TooltipContent className="whitespace-pre-wrap">
<p>{tips}</p>
</TooltipContent>
</Tooltip>
)}
</div>
);
export const KeyShareSelect = ({ value, onChange }: { value: string; onChange?: (value: string) => void }) => { export const KeyShareSelect = ({ value, onChange }: { value: string; onChange?: (value: string) => void }) => {
return ( return (
<Select value={value || ''} onValueChange={(val) => onChange?.(val)}> <Select value={value || ''} onValueChange={(val) => onChange?.(val)}>
@@ -87,50 +105,22 @@ export const PermissionManager = ({ value, onChange, className }: PermissionMana
} }
}; };
const tips = getTips('share'); const shareTips = getTips('share');
return ( return (
<TooltipProvider> <TooltipProvider>
<form className={clsx('flex flex-col w-full gap-4', className)}> <form className={clsx('flex flex-col w-full gap-4', className)}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center gap-2"> <LabelWithTooltip label="共享" tips={shareTips} />
<label className="text-sm font-medium"></label>
{tips && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-4 w-4 p-0">
<HelpCircle size={16} />
</Button>
</TooltipTrigger>
<TooltipContent className="whitespace-pre-wrap">
<p>{tips}</p>
</TooltipContent>
</Tooltip>
)}
</div>
<KeyShareSelect value={formData?.share} onChange={(value) => onChangeValue('share', value)} /> <KeyShareSelect value={formData?.share} onChange={(value) => onChangeValue('share', value)} />
</div> </div>
{keys.map((item: any) => { {keys.map((item: any) => {
const tips = getTips(item); const itemTips = getTips(item);
return ( return (
<div key={item} className="flex flex-col gap-2"> <div key={item} className="flex flex-col gap-2">
<div className="flex items-center gap-2"> <LabelWithTooltip label={item} tips={itemTips} />
<label className="text-sm font-medium">{item}</label>
{tips && (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-4 w-4 p-0">
<HelpCircle size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tips}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{item === 'expiration-time' && ( {item === 'expiration-time' && (
<DatePicker value={formData[item] || ''} onChange={(date) => onChangeValue(item, date)} /> <DatePicker value={formData[item] || ''} onChange={(date) => onChangeValue(item, date)} />
)} )}

View File

@@ -3,7 +3,6 @@
import * as React from "react" import * as React from "react"
import { X } from "lucide-react" import { X } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
type TagsInputProps = { type TagsInputProps = {
@@ -17,13 +16,16 @@ export function TagsInput({ value, onChange, placeholder = "输入用户名,
const [inputValue, setInputValue] = React.useState("") const [inputValue, setInputValue] = React.useState("")
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") { const trimmed = inputValue.trim()
if ((e.key === "Enter" || e.key === ",") && trimmed) {
e.preventDefault() e.preventDefault()
const newValue = inputValue.trim() if (!value.includes(trimmed)) {
if (newValue && !value.includes(newValue)) { onChange([...value, trimmed])
onChange([...value, newValue]) setInputValue("")
} else {
setInputValue("")
} }
setInputValue("")
} else if (e.key === "Backspace" && !inputValue && value.length > 0) { } else if (e.key === "Backspace" && !inputValue && value.length > 0) {
onChange(value.slice(0, -1)) onChange(value.slice(0, -1))
} }
@@ -35,9 +37,9 @@ export function TagsInput({ value, onChange, placeholder = "输入用户名,
return ( return (
<div className={cn("flex flex-wrap gap-2 w-full", className)}> <div className={cn("flex flex-wrap gap-2 w-full", className)}>
{value.map((tag, index) => ( {value.map((tag) => (
<div <div
key={`${tag}-${index}`} key={tag}
className="flex items-center gap-1 px-3 py-1 text-sm bg-secondary text-secondary-foreground rounded-md border border-border" className="flex items-center gap-1 px-3 py-1 text-sm bg-secondary text-secondary-foreground rounded-md border border-border"
> >
<span>{tag}</span> <span>{tag}</span>
@@ -45,6 +47,7 @@ export function TagsInput({ value, onChange, placeholder = "输入用户名,
type="button" type="button"
onClick={() => removeTag(tag)} onClick={() => removeTag(tag)}
className="flex items-center justify-center w-4 h-4 rounded hover:bg-muted transition-colors" className="flex items-center justify-center w-4 h-4 rounded hover:bg-muted transition-colors"
aria-label={`移除 ${tag}`}
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</button> </button>

View File

@@ -1,16 +1,7 @@
'use client'; 'use client';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
export const getTips = (key: string, lang?: string) => {
const tip = keysTips.find((item) => item.key === key);
if (tip) {
if (lang === 'en') {
return tip.enTips;
}
return tip.tips;
}
return '';
};
export const keysTips = [ export const keysTips = [
{ {
key: 'share', key: 'share',
@@ -79,31 +70,32 @@ export const keysTips = [
}, },
}, },
]; ];
// 创建缓存Map以提升查找性能
const tipsMap = new Map(keysTips.map(tip => [tip.key, tip]));
export const getTips = (key: string, lang?: string) => {
const tip = tipsMap.get(key);
if (tip) {
return lang === 'en' ? tip.enTips : tip.tips;
}
return '';
};
export class KeyParse { export class KeyParse {
static parse(metadata: Record<string, any>) { static parse(metadata: Record<string, any>) {
const keys = Object.keys(metadata); return Object.entries(metadata).reduce((acc, [key, value]) => {
const newMetadata = {}; const tip = tipsMap.get(key);
keys.forEach((key) => { acc[key] = tip?.parse ? tip.parse(value) : value;
const tip = keysTips.find((item) => item.key === key); return acc;
if (tip && tip.parse) { }, {} as Record<string, any>);
newMetadata[key] = tip.parse(metadata[key]);
} else {
newMetadata[key] = metadata[key];
}
});
return newMetadata;
} }
static stringify(metadata: Record<string, any>) { static stringify(metadata: Record<string, any>) {
const keys = Object.keys(metadata); return Object.entries(metadata).reduce((acc, [key, value]) => {
const newMetadata = {}; const tip = tipsMap.get(key);
keys.forEach((key) => { acc[key] = tip?.stringify ? tip.stringify(value) : value;
const tip = keysTips.find((item) => item.key === key); return acc;
if (tip && tip.stringify) { }, {} as Record<string, any>);
newMetadata[key] = tip.stringify(metadata[key]);
} else {
newMetadata[key] = metadata[key];
}
});
return newMetadata;
} }
} }

View File

@@ -11,7 +11,7 @@ import clsx from 'clsx';
// import { IconButton } from '@kevisual/components/button/index.tsx'; // import { IconButton } from '@kevisual/components/button/index.tsx';
// import { Select } from '@kevisual/components/select/index.tsx'; // import { Select } from '@kevisual/components/select/index.tsx';
import { iText } from './constants'; import { iText } from './constants';
import { PermissionManager } from './modules/PermissionManager'; import { PermissionManager } from './modules/permission/PermissionManager';
import { toast as message } from 'sonner'; import { toast as message } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';

View 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
View 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>
);
}

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { query } from '@/modules/query'; import { query } from '@/modules/query';
import { toast } from 'sonner'; 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 }); export const queryConfig = new QueryConfig({ query: query as any });
@@ -16,6 +16,10 @@ interface ConfigStore {
deleteConfig: (id: string) => Promise<void>; deleteConfig: (id: string) => Promise<void>;
detectConfig: () => Promise<void>; detectConfig: () => Promise<void>;
onOpenKey: (key: string) => 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) => ({ export const useConfigStore = create<ConfigStore>((set, get) => ({
@@ -75,4 +79,26 @@ export const useConfigStore = create<ConfigStore>((set, get) => ({
toast.error('获取配置失败'); 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 }),
})); }));

63
src/app/remote/page.tsx Normal file
View File

@@ -0,0 +1,63 @@
'use client';
import { LayoutMain } from "@/modules/layout";
import { RemoteApp } from "@kevisual/remote-app";
import { useEffect } from "react";
import { QueryRouterServer } from "@kevisual/router/browser";
export default function Home() {
useEffect(() => {
init();
}, []);
const init = async () => {
// const url = new URL('https://kevisual.cn/ws/proxy');
const isKevisualEnv = window.location.hostname.endsWith('kevisual.cn');
const kevisualWs = 'https://kevisual.cn/ws/proxy';
const url = new URL(isKevisualEnv ? kevisualWs : 'https://kevisual.xiongxiao.me/ws/proxy');
const token = localStorage.getItem('token') || '';
const id = 'remote';
const app = new QueryRouterServer();
app.route({
path: 'web-test',
key: 'web-test',
description: 'Web Router Studio',
}).define(async (ctx) => {
console.log('Received request at /web-test', ctx.query, ctx.state, ctx);
ctx.body = 'Hello from remote route!';
}).addTo(app);
app.createRouteList()
const remoteApp = new RemoteApp({
url: url.toString(),
token,
id,
app: app as any,
});
const connect = await remoteApp.isConnect();
if (connect) {
console.log('Connected to proxy server');
remoteApp.listenProxy();
remoteApp.on('message', (event) => {
const _msg = event.toString();
console.log('Received message from remote app:', _msg);
const remote = document.querySelector('#remote')
if (remote) {
remote.innerHTML += `\n${_msg}`;
}
});
remoteApp.on('open', () => {
console.log('Connection to remote app opened');
remoteApp.listenProxy()
});
remoteApp.on('close', () => {
console.log('Connection to remote app closed');
});
} else {
console.log('Not connected to proxy server');
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<LayoutMain>
<div id="remote"></div>
</LayoutMain>
</div>
);
}

292
src/app/user/page.tsx Normal file
View 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>;
}

View 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 }),
};
});

View 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 }),
};
});

View 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 }

View File

@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -22,9 +22,11 @@ const buttonVariants = cva(
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9", icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8", "icon-sm": "size-8",
"icon-lg": "size-10", "icon-lg": "size-10",
}, },
@@ -46,7 +48,7 @@ function Button({
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot.Root : "button"
return ( return (
<Comp <Comp

View 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,
}

View 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,
}

View File

@@ -7,8 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/20 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:outline-hidden",
className className
)} )}
{...props} {...props}

View File

@@ -47,9 +47,10 @@ export const usePlatformStore = create<PlatfromStore>((set) => {
type Me = { type Me = {
id?: string; id?: string;
username?: string; username?: string;
nickname?: string | null;
needChangePassword?: boolean; needChangePassword?: boolean;
role?: string; role?: string;
description?: string; description?: string | null;
type?: 'user' | 'org'; type?: 'user' | 'org';
orgs?: string[]; orgs?: string[];
avatar?: string; avatar?: string;