update
This commit is contained in:
47
src/app/apps/app/AIEditorLink.tsx
Normal file
47
src/app/apps/app/AIEditorLink.tsx
Normal 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
378
src/app/apps/app/page.tsx
Normal 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
11
src/app/apps/constants.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const iText = {
|
||||
share: {
|
||||
title: '共享设置',
|
||||
tips: `共享设置
|
||||
|
||||
1. 设置公共可以直接访问
|
||||
2. 设置受保护需要登录后访问
|
||||
3. 设置私有只有自己可以访问。\n
|
||||
受保护可以设置密码,设置访问的用户名。切换共享状态后,需要重新设置密码和用户名。`,
|
||||
},
|
||||
};
|
||||
7
src/app/apps/layouts/index.tsx
Normal file
7
src/app/apps/layouts/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { LayoutMain } from '@/modules/layout';
|
||||
|
||||
export const Main = () => {
|
||||
return <LayoutMain title='User Apps' />;
|
||||
};
|
||||
86
src/app/apps/modules/AppDeleteModal.tsx
Normal file
86
src/app/apps/modules/AppDeleteModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
61
src/app/apps/modules/DatePicker.tsx
Normal file
61
src/app/apps/modules/DatePicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
src/app/apps/modules/FileUpload.tsx
Normal file
111
src/app/apps/modules/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
149
src/app/apps/modules/PermissionManager.tsx
Normal file
149
src/app/apps/modules/PermissionManager.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
62
src/app/apps/modules/TagsInput.tsx
Normal file
62
src/app/apps/modules/TagsInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
src/app/apps/modules/key-parse.ts
Normal file
109
src/app/apps/modules/key-parse.ts
Normal 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
494
src/app/apps/page.tsx
Normal 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>
|
||||
};
|
||||
152
src/app/apps/store/app-version.ts
Normal file
152
src/app/apps/store/app-version.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
});
|
||||
2
src/app/apps/store/index.ts
Normal file
2
src/app/apps/store/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './user-app';
|
||||
export * from './app-version';
|
||||
101
src/app/apps/store/user-app.ts
Normal file
101
src/app/apps/store/user-app.ts
Normal 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');
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user