Compare commits
6 Commits
cc466f7bd4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b1f58614e | |||
| 80fb01526c | |||
| d3f0393332 | |||
| 09f5f06baa | |||
| e42fce5bd1 | |||
| 85f742ad2b |
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 "./dist/dev/types/routes.d.ts";
|
||||
import "./.next/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", "@kevisual/remote-app", "@kevisual/router"],
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
|
||||
16
package.json
16
package.json
@@ -11,10 +11,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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/query": "^0.0.38",
|
||||
"@kevisual/router": "^0.0.63",
|
||||
"@kevisual/query": "^0.0.39",
|
||||
"@kevisual/router": "^0.0.70",
|
||||
"@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.2",
|
||||
"antd": "^6.2.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -39,7 +40,8 @@
|
||||
"idb-keyval": "^6.2.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"marked": "^17.0.1",
|
||||
"next": "16.1.5",
|
||||
"next": "16.1.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -48,11 +50,13 @@
|
||||
"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/remote-app": "^0.0.4",
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@kevisual/use-config": "^1.0.30",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^25",
|
||||
"@types/react": "^19",
|
||||
|
||||
1541
pnpm-lock.yaml
generated
1541
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
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 }),
|
||||
}));
|
||||
|
||||
63
src/app/remote/page.tsx
Normal file
63
src/app/remote/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
import { LayoutMain } from "@/modules/layout";
|
||||
import { RemoteApp } from "@kevisual/remote-app";
|
||||
import { useEffect } from "react";
|
||||
import { QueryRouterServer } from "@kevisual/router/browser";
|
||||
export default function Home() {
|
||||
useEffect(() => {
|
||||
init();
|
||||
}, []);
|
||||
const init = async () => {
|
||||
// const url = new URL('https://kevisual.cn/ws/proxy');
|
||||
const isKevisualEnv = window.location.hostname.endsWith('kevisual.cn');
|
||||
const kevisualWs = 'https://kevisual.cn/ws/proxy';
|
||||
const url = new URL(isKevisualEnv ? kevisualWs : 'https://kevisual.xiongxiao.me/ws/proxy');
|
||||
const token = localStorage.getItem('token') || '';
|
||||
const id = 'remote';
|
||||
const app = new QueryRouterServer();
|
||||
app.route({
|
||||
path: 'web-test',
|
||||
key: 'web-test',
|
||||
description: 'Web Router Studio',
|
||||
}).define(async (ctx) => {
|
||||
console.log('Received request at /web-test', ctx.query, ctx.state, ctx);
|
||||
ctx.body = 'Hello from remote route!';
|
||||
}).addTo(app);
|
||||
app.createRouteList()
|
||||
const remoteApp = new RemoteApp({
|
||||
url: url.toString(),
|
||||
token,
|
||||
id,
|
||||
app: app as any,
|
||||
});
|
||||
const connect = await remoteApp.isConnect();
|
||||
if (connect) {
|
||||
console.log('Connected to proxy server');
|
||||
remoteApp.listenProxy();
|
||||
remoteApp.on('message', (event) => {
|
||||
const _msg = event.toString();
|
||||
console.log('Received message from remote app:', _msg);
|
||||
const remote = document.querySelector('#remote')
|
||||
if (remote) {
|
||||
remote.innerHTML += `\n${_msg}`;
|
||||
}
|
||||
});
|
||||
remoteApp.on('open', () => {
|
||||
console.log('Connection to remote app opened');
|
||||
remoteApp.listenProxy()
|
||||
});
|
||||
remoteApp.on('close', () => {
|
||||
console.log('Connection to remote app closed');
|
||||
});
|
||||
} else {
|
||||
console.log('Not connected to proxy server');
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<LayoutMain>
|
||||
<div id="remote"></div>
|
||||
</LayoutMain>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 }
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -22,9 +22,11 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
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",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
@@ -46,7 +48,7 @@ function Button({
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
||||
310
src/components/ui/combobox.tsx
Normal file
310
src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group"
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
data-slot="combobox-trigger-icon"
|
||||
className="text-muted-foreground pointer-events-none size-4"
|
||||
/>
|
||||
</ComboboxPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean
|
||||
showClear?: boolean
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
asChild
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxTrigger />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:border-input/30 group/combobox-content relative max-h-96 w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-md shadow-md ring-1 duration-100 data-[chips=true]:min-w-(--anchor-width) *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"max-h-[min(calc(--spacing(96)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto p-1 data-empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"data-highlighted:bg-accent data-highlighted:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
data-slot="combobox-item-indicator"
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-xs pointer-coarse:px-3 pointer-coarse:py-2 pointer-coarse:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"text-muted-foreground hidden w-full justify-center py-2 text-center text-sm group-data-empty/combobox-content:flex",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-aria-invalid:border-destructive dark:has-aria-invalid:border-destructive/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-transparent bg-clip-padding px-2.5 py-1.5 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-[3px] has-aria-invalid:ring-[3px] has-data-[slot=combobox-chip]:px-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"bg-muted text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-sm px-1.5 text-xs font-medium whitespace-nowrap has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null)
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
}
|
||||
170
src/components/ui/input-group.tsx
Normal file
170
src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -7,8 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
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",
|
||||
"focus-visible:outline-hidden",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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