This commit is contained in:
2026-01-23 02:35:52 +08:00
parent 9849f93b1e
commit 2db3868fcf
39 changed files with 3381 additions and 164 deletions

View File

@@ -0,0 +1,47 @@
'use client';
import { useLayoutStore } from '@/modules/layout/store';
import { useShallow } from 'zustand/shallow';
import { toast } from 'sonner';
import { Folder } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { openLink } from '@/modules/basename';
type Props = {
pathname?: string;
};
export const AIEditorLink = (props: Props) => {
const layoutUser = useLayoutStore(
useShallow((state) => ({
user: state.me?.username || '',
})),
);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
if (!layoutUser.user) {
toast.error('请先登录');
}
if (!window) {
return;
}
let folder = `${layoutUser.user}/resources/${props.pathname}`;
if (folder.endsWith('/')) {
folder = folder.slice(0, -1);
}
let baseUri = location.origin;
const openUrl = `${baseUri}/root/ai-pages/ai-editor/?folder=${folder}/`;
openLink(openUrl, '_blank');
}}>
<Folder className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
);
};

378
src/app/apps/app/page.tsx Normal file
View File

@@ -0,0 +1,378 @@
'use client';
import { useAppVersionStore } from '../store';
import { useShallow } from 'zustand/react/shallow';
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { Plus, ChevronLeft, Upload, File, Trash2, ExternalLink } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { isObjectNull } from '@/modules/is-null';
import { FileUpload } from '../modules/FileUpload';
import clsx from 'clsx';
import { toast as message } from 'sonner';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Controller, useForm } from 'react-hook-form';
import { pick } from 'es-toolkit';
import { useAppDeleteModalStore, AppDeleteModal } from '../modules/AppDeleteModal';
import { AIEditorLink } from './AIEditorLink';
import { openLink } from '@/modules/basename';
import { LayoutMain } from '@/modules/layout';
const FormModal = () => {
const { control, handleSubmit, reset } = useForm();
const containerStore = useAppVersionStore(
useShallow((state) => {
return {
showEdit: state.showEdit,
setShowEdit: state.setShowEdit,
formData: state.formData,
updateData: state.updateData,
};
}),
);
useEffect(() => {
const open = containerStore.showEdit;
if (open) {
const isNull = isObjectNull(containerStore.formData);
if (isNull) {
reset({});
} else {
reset(containerStore.formData);
}
}
}, [containerStore.showEdit]);
const onFinish = async (values: any) => {
const pickValues = pick(values, ['id', 'key', 'version']);
containerStore.updateData(pickValues);
};
const onClose = () => {
containerStore.setShowEdit(false);
reset();
};
const isEdit = containerStore.formData.id;
return (
<Dialog open={containerStore.showEdit} onOpenChange={(open) => containerStore.setShowEdit(open)}>
<DialogContent className='w-[800px]'>
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit' : 'Add'}</DialogTitle>
</DialogHeader>
<form className='flex flex-col gap-6 py-4' onSubmit={handleSubmit(onFinish)}>
<div className='grid gap-2'>
<Label htmlFor='key'>key</Label>
<Controller
name='key'
control={control}
defaultValue=''
render={({ field }) => <Input id='key' {...field} disabled />}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='version'>version</Label>
<Controller
name='version'
control={control}
defaultValue=''
render={({ field }) => <Input id='version' {...field} />}
/>
</div>
<div className='flex gap-2'>
<Button type='submit'>Submit</Button>
<Button variant='outline' onClick={onClose}>
Cancel
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
const getAppKey = () => {
const [appKey, setAppKey] = useState('');
useLayoutEffect(() => {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
const appKey = url.searchParams.get('appKey');
setAppKey(appKey || '');
}, []);
return appKey || '';
}
export const AppVersionList = () => {
const appKey = getAppKey();
const versionStore = useAppVersionStore(
useShallow((state) => {
return {
list: state.list,
getList: state.getList,
key: state.key,
setKey: state.setKey,
setShowEdit: state.setShowEdit,
formData: state.formData,
setFormData: state.setFormData,
deleteData: state.deleteData,
publishVersion: state.publishVersion,
app: state.app,
};
}),
);
const appDeleteModalStore = useAppDeleteModalStore(
useShallow((state) => {
return {
onClickDelete: state.onClickDelete,
};
}),
);
const [isUpload, setIsUpload] = useState(false);
useEffect(() => {
// fetch app version list
if (appKey) {
versionStore.setKey(appKey);
versionStore.getList();
}
}, [appKey]);
const appVersion = useMemo(() => {
return versionStore.app?.version || '';
}, [versionStore.app?.version]);
if (!appKey) {
return <div>App Key is required</div>;
}
return (
<div className='w-full h-full flex bg-slate-100'>
<div className='p-2 bg-white'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
versionStore.setFormData({ key: appKey });
versionStore.setShowEdit(true);
}}>
<Plus className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
<div className='grow h-full relative'>
<div className='absolute top-2 left-4'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
// navigate('/app/edit/list');
history.back();
}}>
<ChevronLeft className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
<div className='w-full h-full p-4 pt-12'>
<div className='w-full h-full rounded-lg bg-white'>
<div className='flex gap-2 flex-wrap p-4'>
{versionStore.list.map((item, index) => {
const isPublish = item.version === appVersion;
const color = isPublish ? 'bg-green-500' : '';
const isRunning = item.status === 'running';
return (
<div className='w-[300px] bg-white rounded-lg border border-slate-200 shadow-sm p-4' key={index}>
<div className={'flex items-center justify-between'}>
<span className='font-medium'>{item.version}</span>
<Tooltip>
<TooltipTrigger asChild>
<div className={clsx('rounded-full w-4 h-4', color)}></div>
</TooltipTrigger>
<TooltipContent>{isPublish ? 'published' : ''}</TooltipContent>
</Tooltip>
</div>
<div className='mt-4 flex gap-1'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={(e) => {
appDeleteModalStore.onClickDelete('app-version', item);
e.stopPropagation();
}}>
<Trash2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
versionStore.publishVersion({ id: item.id });
}}>
<Upload className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>使</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
if (isRunning) {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const link = new URL(`/test/${item.id}`, origin);
openLink(link.toString(), '_blank');
} else {
message.error('The app is not running');
}
}}>
<ExternalLink className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>To Test App</TooltipContent>
</Tooltip>
<AIEditorLink pathname={item.key + '/' + item.version} />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
versionStore.setFormData(item);
setIsUpload(true);
}}>
<File className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
<div className='shark h-full'>
{isUpload && (
<div className='bg-white p-2 w-[600px] h-full flex flex-col'>
<div className='header flex items-center gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
setIsUpload(false);
}}>
<ChevronLeft className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<div className='font-bold'>{versionStore.key}</div>
</div>
<AppVersionFile />
</div>
)}
</div>
<FormModal />
<AppDeleteModal />
</div>
);
};
export const AppVersionFile = () => {
const versionStore = useAppVersionStore(
useShallow((state) => {
return {
formData: state.formData,
detectVersionList: state.detectVersionList,
};
}),
);
const versionFiles = useMemo(() => {
if (!versionStore.formData?.data) return [];
const files = versionStore.formData.data.files || [];
return files as any[];
}, [versionStore.formData]);
const onDetect = useCallback(async () => {
console.log('formData', versionStore.formData);
if (!versionStore.formData.key || !versionStore.formData.version) {
message.error('请先选择应用和版本');
return;
}
const res = await versionStore.detectVersionList({
appKey: versionStore.formData.key,
version: versionStore.formData.version,
});
console.log('res', res);
if (res.code === 200) {
message.success('检测实际文件成功');
} else {
message.error(res.message || 'Detect failed');
}
}, [versionStore.formData]);
return (
<>
<div>version: {versionStore.formData.version}</div>
<div className='border border-gray-200 rounded-md my-2 grow overflow-hidden'>
<div className='flex gap-2 items-center border-b border-b-gray-200 py-2 px-2'>
Files
<FileUpload />
<Tooltip>
<TooltipTrigger asChild>
<Button variant='outline' size='sm' onClick={onDetect}>
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
<div
className='mt-2 '
style={{
height: 'calc(100% - 40px)',
}}>
<div className='h-full overflow-auto mb-4 pb-8 scrollbar'>
{versionFiles.map((file, index) => {
const prefix = versionStore.formData.key + '/' + versionStore.formData.version + '/';
const _path = file.path || '';
const path = _path.replace(prefix, '');
return (
<div className='flex gap-2 px-4 py-2 border-b border-b-gray-200' key={index}>
{/* <div className='w-[100px] truncate'>{file.name}</div> */}
<div>
<File className='h-4 w-4' />
</div>
<div>{path}</div>
</div>
);
})}
</div>
</div>
</div>
</>
);
};
export default () => {
return <LayoutMain>
<AppVersionList />
</LayoutMain>
};

11
src/app/apps/constants.ts Normal file
View File

@@ -0,0 +1,11 @@
export const iText = {
share: {
title: '共享设置',
tips: `共享设置
1. 设置公共可以直接访问
2. 设置受保护需要登录后访问
3. 设置私有只有自己可以访问。\n
受保护可以设置密码,设置访问的用户名。切换共享状态后,需要重新设置密码和用户名。`,
},
};

View File

@@ -0,0 +1,7 @@
'use client';
import { LayoutMain } from '@/modules/layout';
export const Main = () => {
return <LayoutMain title='User Apps' />;
};

View File

@@ -0,0 +1,86 @@
'use client';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
import { create } from 'zustand';
import { useAppVersionStore, useUserAppStore } from '../store';
import { useShallow } from 'zustand/shallow';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
type AppDeleteModalStore = {
open: boolean;
setOpen: (open: boolean) => void;
app: any;
setApp: (app: any) => void;
type: 'user-app' | 'app-version';
setType: (type: 'user-app' | 'app-version') => void;
onClickDelete: (type: 'user-app' | 'app-version', data: any) => void;
};
export const useAppDeleteModalStore = create<AppDeleteModalStore>((set) => ({
open: false,
setOpen: (open) => set({ open }),
app: null,
setApp: (app) => set({ app }),
type: 'user-app',
setType: (type) => set({ type }),
onClickDelete: (type, data) => {
set({ open: true, type, app: data });
},
}));
export const AppDeleteModal = () => {
const { open, setOpen, app, type } = useAppDeleteModalStore();
const userAppStore = useUserAppStore(
useShallow((state) => {
return {
deleteData: state.deleteData,
};
}),
);
const appVersionStore = useAppVersionStore(
useShallow((state) => {
return {
deleteData: state.deleteData,
};
}),
);
const onClose = () => {
setOpen(false);
};
const onDelete = (deleteFile = false) => {
if (type === 'user-app') {
userAppStore.deleteData(app.id, deleteFile);
} else {
appVersionStore.deleteData(app.id, deleteFile);
}
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Tips</DialogTitle>
</DialogHeader>
<div className='w-[400px]'>
<p className='text-sm text-gray-500'>Delete App Introduce</p>
</div>
<DialogFooter>
<Button variant='default' onClick={() => onDelete()}>
Delete
</Button>
<Button variant='outline' onClick={onClose}>
Cancel
</Button>
<Button
variant='destructive'
onClick={() => {
onDelete(true);
}}>
Delete and remove file
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import { Calendar as CalendarIcon } from "lucide-react"
import dayjs, { type Dayjs } from "dayjs"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
interface DatePickerProps {
className?: string
value?: string | Dayjs
onChange?: (date: Dayjs) => void
}
export function DatePicker({ className, value, onChange }: DatePickerProps) {
const [date, setDate] = React.useState<Date | undefined>(
value ? new Date(typeof value === 'string' ? value : value.toISOString()) : undefined
)
React.useEffect(() => {
if (value) {
const dateValue = typeof value === 'string' ? value : value.toISOString()
setDate(new Date(dateValue))
}
}, [value])
const handleSelect = (selectedDate: Date | undefined) => {
setDate(selectedDate)
if (selectedDate && onChange) {
onChange(dayjs(selectedDate))
}
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal",
!date && "text-muted-foreground",
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? dayjs(date).format("YYYY-MM-DD") : <span></span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleSelect}
/>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,111 @@
'use client';
import { Button } from '@/components/ui/button';
import { useCallback, useRef } from 'react';
import { useAppVersionStore } from '../store';
import { useShallow } from 'zustand/react/shallow';
import { toast as message } from 'sonner';
export type FileType = {
name: string;
size: number;
lastModified: number;
webkitRelativePath: string; // 包含name
};
export const FileUpload = () => {
const ref = useRef<HTMLInputElement | null>(null);
const appVersionStore = useAppVersionStore(
useShallow((state) => {
return {
formData: state.formData,
setFormData: state.setFormData,
updateByFromData: state.updateByFromData,
};
}),
);
const onChange = useCallback(
async (e: any) => {
console.log(e.target.files);
// webkitRelativePath
let files = Array.from(e.target.files) as any[];
console.log(files);
if (files.length === 0) {
message.error('请选择文件');
return;
}
// 过滤 文件 .DS_Store
files = files.filter((file) => {
if (file.webkitRelativePath.startsWith('__MACOSX')) {
return false;
}
// 过滤node_modules
if (file.webkitRelativePath.includes('node_modules')) {
return false;
}
// 过滤以.开头的文件
return !file.name.startsWith('.');
});
if (files.length === 0) {
console.log('no files');
return;
}
const root = files[0].webkitRelativePath.split('/')[0];
const formData = new FormData();
files.forEach((file) => {
// relativePath 去除第一级
const webkitRelativePath = file.webkitRelativePath.replace(root + '/', '');
formData.append('file', file, webkitRelativePath); // 保留文件夹路径
});
const key = appVersionStore.formData.key;
const version = appVersionStore.formData.version;
formData.append('appKey', key);
formData.append('version', version);
const res = await fetch('/api/app/upload', {
method: 'POST',
body: formData, //
headers: {
Authorization: 'Bearer ' + (typeof window !== 'undefined' ? localStorage.getItem('token') : ''),
},
}).then((res) => res.json());
if (res?.code === 200) {
appVersionStore.setFormData(res.data);
appVersionStore.updateByFromData();
} else {
message.error(res.message || 'Request failed');
}
// 清理之前上传的文件
e.target.value = '';
},
[appVersionStore.formData],
);
return (
<div>
<input
className='hidden'
ref={ref}
type='file'
// @ts-ignore
webkitdirectory='true'
multiple
onChange={onChange}
/>
<Button
variant='outline'
size='sm'
onClick={() => {
const key = appVersionStore.formData.key;
const version = appVersionStore.formData.version;
if (!key || !version) {
message.error('请先选择应用和版本');
return;
}
ref.current!.click();
}}>
</Button>
</div>
);
};

View File

@@ -0,0 +1,149 @@
'use client';
import { useEffect, useState } from 'react';
import { KeyParse, getTips } from './key-parse';
import { DatePicker } from './DatePicker';
import { TagsInput } from './TagsInput';
import { HelpCircle } from 'lucide-react';
import clsx from 'clsx';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
export const KeyShareSelect = ({ value, onChange }: { value: string; onChange?: (value: string) => void }) => {
return (
<Select value={value || ''} onValueChange={(val) => onChange?.(val)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择共享类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value='public'></SelectItem>
<SelectItem value='protected'></SelectItem>
<SelectItem value='private'></SelectItem>
</SelectContent>
</Select>
);
};
export const KeyTextField = ({ name, value, onChange }: { name: string; value: string; onChange?: (value: string) => void }) => {
return (
<Input
name={name}
value={value}
onChange={(e) => onChange?.(e.target.value)}
className="w-full"
/>
);
};
type PermissionManagerProps = {
value: Record<string, any>;
onChange: (value: Record<string, any>) => void;
className?: string;
};
export const PermissionManager = ({ value, onChange, className }: PermissionManagerProps) => {
const [formData, setFormData] = useState<any>(value);
const [keys, setKeys] = useState<any>([]);
useEffect(() => {
const hasShare = value?.share && value?.share === 'protected';
setFormData(KeyParse.parse(value || {}));
if (hasShare) {
setKeys(['password', 'usernames', 'expiration-time']);
} else {
setKeys([]);
}
}, [value]);
const onChangeValue = (key: string, newValue: any) => {
let newFormData = { ...formData, [key]: newValue };
if (key === 'share') {
if (newValue === 'protected') {
newFormData = { ...newFormData, password: '', usernames: [], 'expiration-time': null };
onChange(KeyParse.stringify(newFormData));
setKeys(['password', 'usernames', 'expiration-time']);
} else {
delete newFormData.password;
delete newFormData.usernames;
delete newFormData['expiration-time'];
onChange(KeyParse.stringify(newFormData));
setKeys([]);
}
} else {
onChange(KeyParse.stringify(newFormData));
}
};
const tips = 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>
<KeyShareSelect value={formData?.share} onChange={(value) => onChangeValue('share', value)} />
</div>
{keys.map((item: any) => {
const tips = 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>
{item === 'expiration-time' && (
<DatePicker value={formData[item] || ''} onChange={(date) => onChangeValue(item, date)} />
)}
{item === 'usernames' && (
<TagsInput value={formData[item] || []} onChange={(value: string[]) => onChangeValue(item, value)} />
)}
{item !== 'expiration-time' && item !== 'usernames' && (
<KeyTextField name={item} value={formData[item] || ''} onChange={(value) => onChangeValue(item, value)} />
)}
</div>
);
})}
</form>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,62 @@
"use client"
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 = {
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
className?: string;
};
export function TagsInput({ value, onChange, placeholder = "输入用户名,按回车添加", className }: TagsInputProps) {
const [inputValue, setInputValue] = React.useState("")
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault()
const newValue = inputValue.trim()
if (newValue && !value.includes(newValue)) {
onChange([...value, newValue])
}
setInputValue("")
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
onChange(value.slice(0, -1))
}
}
const removeTag = (tagToRemove: string) => {
onChange(value.filter((tag) => tag !== tagToRemove))
}
return (
<div className={cn("flex flex-wrap gap-2 w-full", className)}>
{value.map((tag, index) => (
<div
key={`${tag}-${index}`}
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>
<button
type="button"
onClick={() => removeTag(tag)}
className="flex items-center justify-center w-4 h-4 rounded hover:bg-muted transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ""}
className="flex-1 min-w-[120px] h-8"
/>
</div>
)
}

View File

@@ -0,0 +1,109 @@
'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',
tips: `共享设置
1. 设置公共可以直接访问
2. 设置受保护需要登录后访问
3. 设置私有只有自己可以访问。\n
受保护可以设置密码,设置访问的用户名。切换共享状态后,需要重新设置密码和用户名。 不设置,默认是只能自己访问。`,
enTips: `1. Set public to directly access
2. Set protected to access after login
3. Set private to access only yourself.
Protected can set a password and set the username for access. After switching the shared state, you need to reset the password and username. If not set, it defaults to only being accessible to yourself.`,
},
{
key: 'content-type',
tips: `内容类型,设置文件的内容类型。默认不要修改。`,
enTips: `Content type, set the content type of the file. Default do not modify.`,
},
{
key: 'app-source',
tips: `应用来源,上传方式。默认不要修改。`,
enTips: `App source, upload method. Default do not modify.`,
},
{
key: 'cache-control',
tips: `缓存控制,设置文件的缓存控制。默认不要修改。`,
enTips: `Cache control, set the cache control of the file. Default do not modify.`,
},
{
key: 'password',
tips: `密码,设置文件的密码。不设置默认是所有人都可以访问。`,
enTips: `Password, set the password of the file. If not set, it defaults to everyone can access.`,
},
{
key: 'usernames',
tips: `用户名,设置文件的用户名。不设置默认是所有人都可以访问。`,
enTips: `Username, set the username of the file. If not set, it defaults to everyone can access.`,
parse: (value: string) => {
if (!value) {
return [];
}
return value.split(',');
},
stringify: (value: string[]) => {
if (!value) {
return '';
}
return value.join(',');
},
},
{
key: 'expiration-time',
tips: `过期时间,设置文件的过期时间。不设置默认是永久。`,
enTips: `Expiration time, set the expiration time of the file. If not set, it defaults to permanent.`,
parse: (value: Date) => {
if (!value) {
return null;
}
return dayjs(value);
},
stringify: (value?: dayjs.Dayjs) => {
if (!value) {
return '';
}
return value.toISOString();
},
},
];
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;
}
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;
}
}

494
src/app/apps/page.tsx Normal file
View File

@@ -0,0 +1,494 @@
'use client';
import { useShallow } from 'zustand/react/shallow';
import { useAppVersionStore, useUserAppStore } from './store';
import { useEffect, useMemo, useState } from 'react';
// import { useModal } from '@kevisual/components/modal/Confirm.tsx';
import { Plus, Code, Link as LinkIcon, Edit, Trash2, Share2, RefreshCcw, ExternalLink, Folder } from 'lucide-react';
import { isObjectNull } from '@/modules/is-null';
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 { toast as message } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Controller, useForm } from 'react-hook-form';
import { pick } from 'es-toolkit';
import copy from 'copy-to-clipboard';
import { useLayoutStore } from '@/modules/layout/store';
import { useAppDeleteModalStore, AppDeleteModal } from './modules/AppDeleteModal';
import { AppWindow, Folder as FolderIcon } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Switch } from '@/components/ui/switch';
import { AIEditorLink } from './app/AIEditorLink';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { LayoutMain } from '@/modules/layout';
import { openLink } from '@/modules/basename';
export const IconButton = (props: any) => {
return (
<button
className={clsx(
'inline-flex items-center justify-center rounded-md p-2 transition-colors hover:bg-slate-100 disabled:opacity-50 disabled:pointer-events-none',
props.className,
)}
{...props}>
{props.children}
</button>
);
};
const FormModal = () => {
const defaultValues = {
id: '',
title: '',
domain: '',
key: '',
description: '',
proxy: true,
status: 'running',
};
const { control, handleSubmit, reset } = useForm({
defaultValues,
});
const containerStore = useUserAppStore(
useShallow((state) => {
return {
showEdit: state.showEdit,
setShowEdit: state.setShowEdit,
userApp: state.userApp,
updateData: state.updateData,
};
}),
);
useEffect(() => {
const open = containerStore.showEdit;
if (open) {
const isNull = isObjectNull(containerStore.userApp);
if (isNull) {
reset(defaultValues);
} else {
reset(containerStore.userApp);
}
}
}, [containerStore.showEdit, containerStore.userApp]);
const onFinish = async (values: any) => {
const pickValues = pick(values, ['id', 'title', 'domain', 'key', 'description', 'proxy', 'status']);
containerStore.updateData(pickValues);
};
const onClose = () => {
containerStore.setShowEdit(false);
reset();
};
const isEdit = containerStore?.userApp?.id;
const isAdmin = useLayoutStore(useShallow((state) => state.isAdmin));
return (
<Dialog open={containerStore.showEdit} onOpenChange={(open) => containerStore.setShowEdit(open)}>
<DialogContent className='w-[1000px]'>
<DialogHeader>
<DialogTitle>{isEdit ? '编辑' : '添加'}</DialogTitle>
</DialogHeader>
<form className='flex flex-col gap-4 pt-2' onSubmit={handleSubmit(onFinish)}>
<div className='grid gap-2'>
<Label htmlFor='title'></Label>
<Controller name='title' control={control} render={({ field }) => <Input id='title' {...field} />} />
</div>
<div className='grid gap-2'>
<Label htmlFor='key'></Label>
<Controller name='key' control={control} render={({ field }) => <Input id='key' {...field} />} />
</div>
<div className='grid gap-2'>
<Label htmlFor='description'></Label>
<Controller
name='description'
control={control}
render={({ field }) => <Textarea id='description' {...field} rows={4} />}
/>
</div>
{isAdmin && (
<div className='grid gap-2'>
<Label htmlFor='proxy'></Label>
<Controller name='proxy' control={control} render={({ field }) => <Switch id='proxy' checked={field.value} onCheckedChange={field.onChange} />} />
</div>
)}
<div className='grid gap-2'>
<Label htmlFor='status'></Label>
<Controller
name='status'
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder='选择状态' />
</SelectTrigger>
<SelectContent>
<SelectItem value='running'></SelectItem>
<SelectItem value='stop'></SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className='flex gap-2'>
<Button type='submit'></Button>
<Button variant='outline' type='reset' onClick={onClose}>
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
const ShareModal = () => {
const [permission, setPermission] = useState<any>(null);
const [runtime, setRuntime] = useState<string[]>([]);
const containerStore = useUserAppStore(
useShallow((state) => {
return {
showEdit: state.showShareEdit,
setShowEdit: state.setShowShareEdit,
updateData: state.updateData,
userApp: state.userApp,
};
}),
);
useEffect(() => {
const open = containerStore.showEdit;
if (open) {
const permission = containerStore.userApp?.data?.permission || {};
const runtime = containerStore.userApp?.data?.runtime || [];
if (isObjectNull(permission)) {
setPermission({ share: 'private' });
} else {
setPermission(permission);
}
setRuntime(runtime);
}
}, [containerStore.showEdit, containerStore.userApp]);
const onFinish = async () => {
const values = {
id: containerStore.userApp.id,
data: {
permission,
runtime,
},
};
containerStore.updateData(values);
};
const onClose = () => {
containerStore.setShowEdit(false);
};
const isAdmin = useLayoutStore(useShallow((state) => state.isAdmin));
return (
<Dialog open={containerStore.showEdit} onOpenChange={(open) => containerStore.setShowEdit(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className='flex flex-col gap-2 w-[400px] '>
<PermissionManager
value={permission}
onChange={(value) => {
setPermission(value);
}}
/>
{isAdmin && (
<div className='grid gap-2'>
<Label></Label>
<Select
value={runtime[0] || ''}
onValueChange={(val: string) => {
setRuntime((prev) => (prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val]));
}}
>
<SelectTrigger>
<SelectValue placeholder='选择运行时' />
</SelectTrigger>
<SelectContent>
<SelectItem value='node'>Node.js</SelectItem>
<SelectItem value='browser'></SelectItem>
</SelectContent>
</Select>
<div className='flex gap-1 flex-wrap'>
{runtime.map((r) => (
<span key={r} className='bg-slate-200 px-2 py-1 rounded text-xs'>
{r}
</span>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button type='submit' onClick={() => { onFinish() }}></Button>
<Button variant='outline' type='reset' onClick={onClose}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const List = () => {
// const [modal, contextHolder] = useModal();
const userAppStore = useUserAppStore(
useShallow((state) => {
return {
list: state.list,
getList: state.getList,
setShowEdit: state.setShowEdit,
formData: state.formData,
setFormData: state.setFormData,
deleteData: state.deleteData,
setShowShareEdit: state.setShowShareEdit,
getUserApp: state.getUserApp,
};
}),
);
const appVersionStore = useAppVersionStore(
useShallow((state) => {
return {
publishVersion: state.publishVersion,
};
}),
);
const appDeleteModalStore = useAppDeleteModalStore(
useShallow((state) => {
return {
onClickDelete: state.onClickDelete,
};
}),
);
useEffect(() => {
userAppStore.getList();
}, []);
return (
<div className='w-full h-full flex bg-slate-100'>
<div className='p-2 h-full bg-white flex flex-col gap-2'>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
sx={{
padding: '8px',
}}
onClick={() => {
userAppStore.setFormData({});
userAppStore.setShowEdit(true);
}}>
<Plus className='h-4 w-4' />
</IconButton>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
sx={{
padding: '8px',
}}
onClick={() => {
openLink('/domain/', '_self');
}}>
<LinkIcon className='h-4 w-4' />
</IconButton>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
<div className='grow'>
<div className='w-full h-full p-4'>
<div className='w-full h-full bg-white rounded-lg p-2 scrollbar '>
<div className='flex flex-wrap gap-2'>
{userAppStore.list.map((item) => {
const isRunning = item.status === 'running';
const hasDescription = !!item.description;
// const content = marked.parse(item.description);
const content = item.description;
return (
<div className='w-[300px] bg-white rounded-lg border border-slate-200 shadow-sm p-4 relative' key={item.id}>
<div className='flex font-bold justify-between mb-3' onClick={() => { }}>
<Tooltip>
<TooltipTrigger asChild>
<div>
{item.title} <i className='text-xs text-gray-400'>{item.key}</i>
</div>
</TooltipTrigger>
<TooltipContent><pre className=''>
<span className='text-sm'>{item.title}</span>
<i className='text-xs text-white ml-4'>{item.key}</i>
</pre></TooltipContent>
</Tooltip>
<div>
<Tooltip>
<TooltipTrigger asChild>
<div className={`${isRunning ? 'bg-green-500' : 'bg-red-500'} w-4 h-4 rounded-full`}></div>
</TooltipTrigger>
<TooltipContent>{isRunning ? '网页可正常访问' : '网页被关闭'}</TooltipContent>
</Tooltip>
</div>
</div>
<div className='flex flex-col gap-2 mb-16'>
<Tooltip>
<TooltipTrigger asChild>
<div
className='text-xs cursor-copy'
onClick={() => {
copy(item.id);
message.success('复制成功');
}}>
{item.id}
</div>
</TooltipTrigger>
<TooltipContent>App ID到剪贴板</TooltipContent>
</Tooltip>
<div className='text-xs text-gray-500'>
{item.version}
</div>
<div className={clsx('text-sm border border-slate-200 rounded p-2 max-h-[140px] overflow-auto my-1 scrollbar', !hasDescription && 'hidden')}>
{/* <div dangerouslySetInnerHTML={{ __html: content }}></div> */}
<div className='text-sm whitespace-pre-wrap'>{content}</div>
</div>
</div>
<div className='mt-4 pt-3 border-t border-slate-100 flex gap-1 absolute bottom-0 left-0 right-0 px-4 pb-4 bg-white rounded-b-lg'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
userAppStore.getUserApp(item.id);
userAppStore.setFormData(item);
userAppStore.setShowEdit(true);
}}>
<Edit className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
const url = `/apps/app?appKey=${item.key}`;
openLink(url, '_self');
}}
>
<AppWindow className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
userAppStore.getUserApp(item.id);
userAppStore.setFormData(item);
userAppStore.setShowShareEdit(true);
}}>
<Share2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent className="whitespace-pre-wrap">{iText.share.tips}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
appVersionStore.publishVersion({ appKey: item.key, version: item.version }, { showToast: true });
}}>
<RefreshCcw className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
if (isRunning) {
let baseUri = typeof window !== 'undefined' ? window.location.origin : '';
if (item.domain) {
if (item.domain.startsWith('http://') || item.domain.startsWith('https://')) {
baseUri = item.domain;
} else if (item.domain.startsWith('//')) {
baseUri = new URL(item.domain).origin;
} else {
baseUri = new URL('https://' + item.domain).toString();
}
if (baseUri.endsWith('/')) {
openLink(baseUri, '_blank');
}
console.log('baseUri', baseUri);
message.success('success');
return;
}
const link = new URL(`/${item.user}/${item.key}/`, baseUri);
openLink(link.toString(), '_blank');
} else {
message.error('应用未运行');
}
}}>
<ExternalLink className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<AIEditorLink pathname={item.key} />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={(e) => {
appDeleteModalStore.onClickDelete('user-app', item);
e.stopPropagation();
}}>
<Trash2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
</div>
);
})}
</div>
</div>
</div>
</div >
{/* {contextHolder} */}
< FormModal />
<ShareModal />
<AppDeleteModal />
</div >
);
};
export default () => {
return <LayoutMain>
<List />
</LayoutMain>
};

View File

@@ -0,0 +1,152 @@
'use client';
import { isObjectNull } from '@/modules/is-null';
import { create } from 'zustand';
import { query } from '@/modules/query';
import { toast as message } from 'sonner'
type AppVersionStore = {
showEdit: boolean;
setShowEdit: (showEdit: boolean) => void;
formData: any;
setFormData: (formData: any) => void;
updateByFromData: () => void;
loading: boolean;
setLoading: (loading: boolean) => void;
key: string;
setKey: (key: string) => void;
list: any[];
getList: () => Promise<void>;
app: any;
getApp: (key: string, force?: boolean) => Promise<void>;
updateData: (data: any) => Promise<void>;
/**
* 删除应用版本
* @param id 应用版本id
* @param deleteFile 是否删除文件
* @returns
*/
deleteData: (id: string, deleteFile?: boolean) => Promise<void>;
publishVersion: (data: { id?: string; appKey?: string; version?: string }, opts?: { showToast?: boolean }) => Promise<any>;
detectVersionList: (data: { appKey: string; version: string }) => Promise<any>;
};
export const useAppVersionStore = create<AppVersionStore>((set, get) => {
return {
showEdit: false,
setShowEdit: (showEdit) => set({ showEdit }),
formData: {},
setFormData: (formData) => set({ formData }),
updateByFromData: () => {
const { formData, list } = get();
const data = list.map((item) => {
if (item.id === formData.id) {
return formData;
}
return item;
});
set({ list: data });
},
loading: false,
setLoading: (loading) => set({ loading }),
key: '',
setKey: (key) => set({ key }),
list: [],
getList: async () => {
set({ loading: true });
const key = get().key;
const res = await query.post({
path: 'app',
key: 'list',
data: {
key,
},
});
get().getApp(key, true);
set({ loading: false });
if (res.code === 200) {
set({ list: res.data });
} else {
message.error(res.message || 'Request failed');
}
},
app: {},
getApp: async (key, force) => {
const { app } = get();
if (!force && !isObjectNull(app)) {
return;
}
const res = await query.post({
path: 'user-app',
key: 'get',
data: {
key,
},
});
if (res.code === 200) {
set({ app: res.data });
} else {
message.error(res.message || '请求失败');
}
},
updateData: async (data) => {
const { getList } = get();
const res = await query.post({
path: 'app',
key: 'update',
data,
});
if (res.code === 200) {
message.success('Success');
set({ showEdit: false, formData: res.data });
getList();
} else {
message.error(res.message || 'Request failed');
}
},
deleteData: async (id, deleteFile = false) => {
const { getList } = get();
const res = await query.post({
path: 'app',
key: 'delete',
payload: {
id,
deleteFile,
},
});
if (res.code === 200) {
getList();
message.success('Success');
} else {
message.error(res.message || 'Request failed');
}
},
publishVersion: async (data, opts) => {
const showToast = opts?.showToast ?? true;
const res = await query.post({
path: 'app',
key: 'publish',
data,
});
if (res.code === 200) {
if (showToast) {
message.success('发布成功');
if (get().key) {
get().getApp(get().key, true);
}
}
} else {
if (showToast) {
message.error(res.message || '请求失败');
}
}
return res;
},
detectVersionList: async (data) => {
const res = await query.post({
path: 'app',
key: 'detectVersionList',
data,
});
return res;
},
};
});

View File

@@ -0,0 +1,2 @@
export * from './user-app';
export * from './app-version';

View File

@@ -0,0 +1,101 @@
'use client';
import { create } from 'zustand';
import { query } from '@/modules/query';
import { toast as message } from 'sonner'
type UserAppStore = {
showEdit: boolean;
setShowEdit: (showEdit: boolean) => void;
formData: any;
setFormData: (formData: any) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
list: any[];
getList: () => Promise<void>;
updateData: (data: any) => Promise<void>;
/**
* 删除用户应用
* @param id 用户应用id
* @param deleteFile 是否删除文件
* @returns
*/
deleteData: (id: string, deleteFile?: boolean) => Promise<void>;
showShareEdit: boolean;
setShowShareEdit: (showShareEdit: boolean) => void;
userApp: any;
setUserApp: (userApp: any) => void;
getUserApp: (id: string) => Promise<void>;
};
export const useUserAppStore = create<UserAppStore>((set, get) => {
return {
showEdit: false,
setShowEdit: (showEdit) => set({ showEdit }),
formData: {},
setFormData: (formData) => set({ formData }),
loading: false,
setLoading: (loading) => set({ loading }),
list: [],
getList: async () => {
set({ loading: true });
const res = await query.post({
path: 'user-app',
key: 'list',
});
set({ loading: false });
if (res.code === 200) {
set({ list: res.data });
} else {
message.error(res.message || 'Request failed');
}
},
updateData: async (data) => {
const { getList } = get();
const res = await query.post({
path: 'user-app',
key: 'update',
data,
});
if (res.code === 200) {
message.success('Success');
set({ showEdit: false, showShareEdit: false, formData: res.data });
getList();
} else {
message.error(res.message || 'Request failed');
}
},
deleteData: async (id, deleteFile = false) => {
const { getList } = get();
const res = await query.post({
path: 'user-app',
key: 'delete',
payload: {
id,
deleteFile,
},
});
if (res.code === 200) {
getList();
message.success('Success');
} else {
message.error(res.message || 'Request failed');
}
},
showShareEdit: false,
setShowShareEdit: (showShareEdit) => set({ showShareEdit }),
userApp: {},
setUserApp: (userApp) => set({ userApp }),
getUserApp: async (id) => {
set({ userApp: null });
const res = await query.post({
path: 'user-app',
key: 'get',
payload: { id }
});
if (res.code === 200) {
set({ userApp: res.data });
} else {
message.error(res.message || 'Request failed');
}
},
};
});