feat: add resources

This commit is contained in:
2025-03-18 13:10:40 +08:00
parent cc76842582
commit 25def8c245
31 changed files with 1432 additions and 119 deletions

View File

@@ -1,16 +1,60 @@
import { useResourceStore } from '@/pages/store/resource';
import { useResourceFileStore } from '@/pages/store/resource-file';
import { Drawer } from '@mui/material';
import { Box, Divider, Drawer, Tab, Tabs } from '@mui/material';
import { useMemo, useState } from 'react';
import { QuickValues, QuickTabs } from './QuickTabs';
export const FileDrawer = () => {
const { prefix } = useResourceStore();
const { resource, openDrawer, setOpenDrawer } = useResourceFileStore();
const [tab, setTab] = useState<string>(QuickValues[0]);
const quickCom = useMemo(() => {
return QuickTabs.find((item) => item.value === tab)?.component;
}, [tab]);
return (
<Drawer open={openDrawer} onClose={() => setOpenDrawer(false)} anchor='right' {...(!openDrawer && { inert: true })}>
<div className='p-4 w-[600px]'>
<h2 className='text-2xl font-bold'>{resource?.name ? resource.name.replace(prefix, '') : resource?.prefix?.replace(prefix, '')}</h2>
<pre className='flex flex-col gap-2'>{JSON.stringify(resource, null, 2)}</pre>
</div>
</Drawer>
<>
<Drawer
// aria-label={'文件详情'}
open={openDrawer}
onClose={() => {
// document.getElementById('focus-safe-element')?.focus();
const activeElement = document.activeElement as HTMLElement;
if (activeElement) {
activeElement.blur();
}
setOpenDrawer(false);
}}
ModalProps={{
keepMounted: true,
}}
anchor='right'
style={{
zIndex: 1000,
}}>
<div className='p-4 w-[400px] max-w-[90%] overflow-hidden h-full sm:w-[600px]'>
<div style={{ height: '140px' }}>
<h2 className='text-2xl font-bold truncate py-2 pb-6 '>
{resource?.name ? resource.name.replace(prefix, '') : resource?.prefix?.replace(prefix, '')}
</h2>
<Divider />
<Box sx={{ borderBottom: 1, mt: 2, borderColor: 'divider' }}>
<Tabs value={tab} onChange={(_, value) => setTab(value)}>
{QuickTabs.map((item) => (
<Tab key={item.value} label={item.label} value={item.value} icon={item.icon} iconPosition='start' />
))}
</Tabs>
</Box>
</div>
<Box className='' sx={{ p: 2, height: 'calc(100% - 140px)', overflow: 'hidden' }}>
<div className='scrollbar' style={{ height: '100%', overflow: 'auto' }}>
{quickCom && quickCom()}
</div>
</Box>
</div>
</Drawer>
<button id='focus-safe-element' style={{ display: 'none' }}>
Focus Safe Element
</button>
</>
);
};

View File

@@ -0,0 +1,27 @@
import { MetaForm } from './modules/MetaForm';
import { ContentForm } from './modules/ContextForm';
import { Cpu, File, FileText, Rabbit } from 'lucide-react';
import { Quick } from './quick';
export const QuickTabs = [
{
label: 'Quick',
value: 'quick',
icon: <Rabbit />,
index: 99,
component: () => <Quick />,
},
{
label: '元数据',
value: 'meta',
icon: <Cpu />,
component: () => <MetaForm />,
},
{
label: '内容',
value: 'content',
icon: <FileText />,
component: () => <ContentForm />,
},
].sort((a, b) => (b?.index || 0) - (a?.index || 0));
export const QuickValues = QuickTabs.map((item) => item.value);

View File

@@ -0,0 +1,29 @@
import { useResourceFileStore } from '@/pages/store/resource-file';
import { Box, Typography } from '@mui/material';
import prettyBytes from 'pretty-bytes';
import dayjs from 'dayjs';
type ContentShowType = {
size: number;
lastModified: string;
etag: string;
name?: string;
};
export const ContentForm = () => {
const { resource } = useResourceFileStore();
const contentShow = resource as ContentShowType;
return (
<Box className='p-4 border rounded-md mt-2'>
{/* <Typography variant='h6'>{contentShow?.name || 'No Name Available'}</Typography> */}
<Typography variant='body1'>
<strong>Size:</strong> {contentShow?.size ? prettyBytes(contentShow?.size) : 'N/A'}
</Typography>
<Typography variant='body1'>
<strong>Last Modified:</strong> {contentShow?.lastModified ? dayjs(contentShow?.lastModified).format('YYYY-MM-DD HH:mm:ss') : 'N/A'}
</Typography>
<Typography variant='body1'>
<strong>ETag:</strong> {contentShow?.etag || 'N/A'}
</Typography>
</Box>
);
};

View File

@@ -0,0 +1,28 @@
import ReactDatePicker from 'antd/es/date-picker';
import { useTheme } from '@mui/material';
import 'antd/es/date-picker/style/index';
interface DatePickerProps {
value?: Date | null;
onChange?: (date: Date | null) => void;
}
export const DatePicker = ({ value, onChange }: DatePickerProps) => {
const theme = useTheme();
const primaryColor = theme.palette.primary.main;
return (
<div>
<ReactDatePicker
placement='topLeft'
placeholder='请选择日期'
value={value}
showNow={false}
// showTime={true}
onChange={(date) => onChange?.(date)} //
style={{
color: primaryColor,
}}
popupStyle={{ zIndex: 2000 }}
/>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import { Button, Dialog, DialogContent, DialogTitle, FormControlLabel, TextField } from '@mui/material';
import { useState } from 'react';
import { useMetaStore } from './MetaForm';
export const DialogKey = ({ onAdd }: { onAdd: (key: string) => void }) => {
const { openPropertyModal, setOpenPropertyModal } = useMetaStore();
const [key, setKey] = useState('');
return (
<Dialog open={openPropertyModal} onClose={() => setOpenPropertyModal(false)}>
<DialogTitle>key</DialogTitle>
<DialogContent>
<div className='flex flex-col items-center gap-3 px-4 pb-4'>
<FormControlLabel
label='key'
labelPlacement='top'
control={<TextField variant='outlined' size='small' name={key} value={key} onChange={(e) => setKey(e.target.value)} />}
sx={{
alignItems: 'flex-start',
'& .MuiFormControlLabel-label': {
textAlign: 'left',
width: '100%',
},
}}
/>
<Button
variant='contained'
color='primary'
style={{ color: 'white' }}
onClick={() => {
onAdd(key);
setKey('');
setOpenPropertyModal(false);
}}>
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,368 @@
import { useResourceFileStore } from '@/pages/store/resource-file';
import { FormControlLabel, Box, TextField, Button, IconButton, ButtonGroup, Tooltip, Select, MenuItem, Typography, FormGroup } from '@mui/material';
import { Info, Plus, Save, Share, Shuffle, Trash } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { toast } from 'react-toastify';
import { create } from 'zustand';
import { uniq } from 'lodash-es';
import { DatePicker } from './DatePicker';
import { SelectPicker } from './SelectPicker';
import dayjs from 'dayjs';
import { DialogKey } from './DialogKey';
export const setShareKeysOperate = (value: 'public' | 'protected' | 'private') => {
const keys = ['password', 'usernames', 'expiration-time'];
const deleteKeys = keys.map((item) => {
return {
key: item,
operate: 'delete',
};
});
if (value === 'protected') {
return deleteKeys.map((item) => {
return {
...item,
operate: 'add',
};
});
}
return deleteKeys;
};
export const keysTips = [
{
key: 'share',
tips: `共享设置
1. 设置公共可以直接访问
2. 设置受保护需要登录后访问
3. 设置私有只有自己可以访问。\n
受保护可以设置密码,设置访问的用户名。切换共享状态后,需要重新设置密码和用户名。`,
},
{
key: 'content-type',
tips: `内容类型,设置文件的内容类型。默认不要修改。`,
},
{
key: 'app-source',
tips: `应用来源,上传方式。默认不要修改。`,
},
{
key: 'cache-control',
tips: `缓存控制,设置文件的缓存控制。默认不要修改。`,
},
{
key: 'password',
tips: `密码,设置文件的密码。不设置默认是所有人都可以访问。`,
},
{
key: 'usernames',
tips: `用户名,设置文件的用户名。不设置默认是所有人都可以访问。`,
parse: (value: string) => {
if (!value) {
return [];
}
return value.split(',');
},
stringify: (value: string[]) => {
if (!value) {
return '';
}
return value.join(',');
},
},
{
key: 'expiration-time',
tips: `过期时间,设置文件的过期时间。不设置默认是永久。`,
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;
}
}
export const useMetaOperate = ({
onSave,
metaStore,
handleFormDataChange,
}: {
onSave: () => void;
metaStore: MetaStore;
handleFormDataChange: (key: string, value: string | Date | null | string[]) => void;
}) => {
const { keys, setKeys, openPropertyModal, setOpenPropertyModal } = metaStore;
const hasShare = keys.includes('share');
const hasPassword = keys.includes('password');
const addMeta = (key: string) => {
setKeys(uniq([...keys, key]));
};
const defaultBtnList = [
{
icon: <Save />,
key: 'save',
tooltip: '保存元数据, 修改后需要手动保存',
onClick: () => onSave(),
},
{
icon: <Plus />,
key: 'add',
tooltip: '添加元数据',
onClick: () => setOpenPropertyModal(true),
},
];
if (!hasShare) {
defaultBtnList.push({
icon: <Share />,
key: 'share',
tooltip: '开启共享',
onClick: () => addMeta('share'),
});
}
if (hasShare && hasPassword) {
defaultBtnList.push({
icon: <Shuffle />,
key: 'password',
tooltip: '随机生成密码',
onClick: () => {
const password = Math.random().toString(36).substring(2, 8);
handleFormDataChange('password', password);
},
});
}
return defaultBtnList;
};
type MetaStore = {
keys: string[];
setKeys: (keys: string[]) => void;
openPropertyModal: boolean;
setOpenPropertyModal: (openPropertyModal: boolean) => void;
};
export const useMetaStore = create<MetaStore>((set) => ({
keys: [],
setKeys: (keys) => set({ keys }),
openPropertyModal: false,
setOpenPropertyModal: (openPropertyModal) => set({ openPropertyModal }),
}));
export const MetaForm = () => {
const { resource, updateMeta } = useResourceFileStore();
const [formData, setFormData] = useState<any>({});
const metaStore = useMetaStore();
const { keys, setKeys } = metaStore;
useEffect(() => {
// setFormData(resource?.metaData || {});
setFormData(KeyParse.parse(resource?.metaData || {}));
}, [resource]);
useEffect(() => {
setKeys(Object.keys(resource?.metaData || {}));
}, [resource]);
if (!keys.length) {
return <div className='text-center text-gray-500'></div>;
}
const handleFormDataChange = (key: string, value: string | Date | null | string[]) => {
// setFormData({ ...formData, [key]: value });
const _formData = { ...formData };
if (key === 'share') {
const shareKeysOperate = setShareKeysOperate(value as 'public' | 'protected' | 'private');
shareKeysOperate.forEach((item) => {
if (item.operate === 'add') {
_formData[item.key] = '';
} else if (item.operate === 'delete') {
delete _formData[item.key];
}
});
_formData.share = value;
setFormData(_formData);
const newKeys = keys
.map((item) => {
const operate = shareKeysOperate.find((item2) => item2.key === item);
if (operate && operate.operate === 'delete') {
return null;
}
return item;
})
.filter((item) => item !== null);
const addKeys = shareKeysOperate.filter((item) => item.operate === 'add').map((item) => item.key);
const _newKeys = uniq([...newKeys, ...addKeys]);
setKeys(_newKeys);
console.log(_newKeys);
return;
} else {
_formData[key] = value;
}
setFormData(_formData);
};
const deleteMeta = (key: string) => {
setKeys(keys.filter((item) => item !== key));
delete formData[key];
setFormData({ ...formData });
};
const onSave = () => {
const newMetadata = KeyParse.stringify(formData);
updateMeta(newMetadata);
};
const addMetaKey = (key: string) => {
if (keys.includes(key)) {
toast.error('元数据key已存在');
return;
}
formData[key] = '';
setKeys([...keys, key]);
setFormData({ ...formData });
};
const btnList = useMetaOperate({ onSave, metaStore, handleFormDataChange });
return (
<div className='relative w-full h-full'>
<Box className='sticky top-0 z-10 pointer-events-none'>
<div className='flex justify-end mr-20'>
<div className=' pointer-events-auto'>
<ButtonGroup className='bg-white' variant='contained' sx={{ color: 'white' }}>
{btnList.map((item) => {
const icon = (
<IconButton color='secondary' onClick={item.onClick}>
{item.icon}
</IconButton>
);
if (item.tooltip) {
return (
<Tooltip key={item.key} title={item.tooltip} placement='top' arrow>
{icon}
</Tooltip>
);
}
return <>{icon}</>;
})}
</ButtonGroup>
</div>
</div>
</Box>
<FormGroup>
{keys.map((key) => {
let control: React.ReactNode | null = null;
if (key === 'share') {
control = <KeyShareSelect name={key} value={formData[key] || ''} onChange={(value) => handleFormDataChange(key, value)} />;
} else if (key === 'expiration-time') {
control = <DatePicker value={formData[key]} onChange={(date) => handleFormDataChange(key, date)} />;
} else if (key === 'usernames') {
control = <SelectPicker value={formData[key] || []} onChange={(value) => handleFormDataChange(key, value)} />;
} else {
control = <KeyTextField name={key} value={formData[key] || ''} onChange={(value) => handleFormDataChange(key, value)} />;
}
const Label = () => {
const tip = keysTips.find((item) => item.key === key);
return (
<div className='flex justify-between items-center gap-2'>
<div className='flex items-center gap-2'>
<Typography variant='caption' color='primary'>
{key}
</Typography>
{tip && (
<Tooltip title={tip?.tips} placement='top' arrow>
<Info size={12} />
</Tooltip>
)}
</div>
<IconButton color='error' onClick={() => deleteMeta(key)}>
<Trash />
</IconButton>
</div>
);
};
return (
<div key={key} className='flex flex-col gap-2'>
<FormControlLabel
key={key}
label={<Label />}
labelPlacement='top'
control={control}
sx={{
alignItems: 'flex-start',
'& .MuiFormControlLabel-label': {
textAlign: 'left',
width: '100%',
},
}}
/>
</div>
);
})}
</FormGroup>
<DialogKey onAdd={addMetaKey} />
</div>
);
};
const KeyTextField = ({ name, value, onChange }: { name: string; value: string; onChange?: (value: string) => void }) => {
return (
<TextField
variant='outlined'
size='small'
name={name}
defaultValue={value}
// value={formData[key] || ''}
onChange={(e) => onChange?.(e.target.value)}
sx={{
width: '100%',
marginBottom: '16px',
}}
/>
);
};
const KeyShareSelect = ({ name, value, onChange }: { name: string; value: string; onChange?: (value: string) => void }) => {
return (
<Select
variant='outlined'
size='small'
name={name}
value={value}
onChange={(e) => onChange?.(e.target.value)}
sx={{
width: '100%',
marginBottom: '16px',
}}>
<MenuItem value='public' title='公开'>
</MenuItem>
<MenuItem value='protected' title='受保护'>
</MenuItem>
<MenuItem value='private' title='私有'>
</MenuItem>
</Select>
);
};

View File

@@ -0,0 +1,18 @@
import { styled } from '@mui/material';
import Select from 'antd/es/select';
import 'antd/es/select/style/index';
interface SelectPickerProps {
value: string[];
onChange: (value: string[]) => void;
}
export const SelectPickerCom = ({ value, onChange }: SelectPickerProps) => {
return <Select style={{ width: '100%' }} showSearch={false} mode='tags' value={value} onChange={onChange} />;
};
export const SelectPicker = styled(SelectPickerCom)({
'& .ant-select-selector': {
color: 'var(--primary-color)',
},
});

View File

@@ -0,0 +1,11 @@
/**
* 链接生成markdown超链接, 多选项。
* 1. markdown
* 2. HTML image
* 3. URL https url
* @returns
*/
export const QuickLink = () => {
const url = 'https://ab.c.com/a/b/c.jpg';
return <div>QuickLink</div>;
};

View File

@@ -0,0 +1,131 @@
import { useResourceFileStore } from '@/pages/store/resource-file';
import { useSettingsStore } from '@/pages/store/settings';
import { Button, Tooltip, useTheme } from '@mui/material';
import { useShallow } from 'zustand/shallow';
import { Accordion, AccordionSummary, AccordionDetails } from '@mui/material';
import { ChevronDown } from 'lucide-react';
import { useMemo } from 'react';
import { getFileType } from '../../FileIcon';
import { toast } from 'react-toastify';
import clsx from 'clsx';
type AccordionItem = {
title?: string;
key?: string;
url?: string;
content?: any;
clickCopy?: boolean;
};
export const QuickPreview = () => {
const { resource } = useResourceFileStore(
useShallow((state) => ({
resource: state.resource,
})),
);
const { settings, baseUrl } = useSettingsStore(
useShallow((state) => ({
settings: state.settings,
baseUrl: state.baseUrl,
})),
);
const fileType = useMemo(() => getFileType(resource?.name), [resource]);
const accordionList = useMemo(() => {
const username = settings?.username;
if (!username) {
toast.error('请先登录');
return [];
}
const _url = new URL(`${baseUrl}/api/s1/share/${username}/${resource?.name}`);
const meta = resource?.metaData ?? {};
if (meta.password) {
_url.searchParams.set('p', meta.password);
}
const url = _url.toString();
let accordionList: AccordionItem[] = [];
const encodeUrl = encodeURIComponent(url);
const previewUrl = `${baseUrl}/app/preview?fileUrl=${encodeUrl}&fileType=${fileType}`;
accordionList.push({
title: '文件预览',
key: 'preview-file',
url: previewUrl,
content: (
<div className=''>
<div className='text-sm break-words'>{previewUrl}</div>
<Button variant='contained' color='primary' style={{ color: 'white' }} onClick={() => window.open(previewUrl, '_blank')}>
</Button>
</div>
),
});
if (fileType === 'image') {
accordionList.push({
title: '预览图片',
key: 'preview-image',
url: url,
content: <img className='w-full h-full border-2 border-gray-300 rounded-md' src={url} alt={resource?.name} />,
});
accordionList.push({
title: 'markdown链接',
key: 'markdown-link',
url: url,
clickCopy: true,
content: `![${resource?.name}](${url})`,
});
accordionList.push({
title: 'HTML图片',
key: 'html-image',
url: url,
clickCopy: true,
content: `<img style="width: 100%;height: 100%;" src="${url}" alt="${resource?.name}" />`,
});
accordionList.push({
title: 'HTML超链接',
key: 'html-link',
url: url,
clickCopy: true,
content: `<a href="${url}">${resource?.name}</a>`,
});
}
const downloadUrl = new URL(url);
downloadUrl.searchParams.set('download', 'true');
accordionList.push({
title: '下载地址',
key: 'download-url',
url: downloadUrl.toString(),
content: (
<div className=''>
<div className='text-sm break-words'>{downloadUrl.toString()}</div>
<Button variant='contained' color='primary' style={{ color: 'white' }} onClick={() => window.open(downloadUrl.toString(), '_blank')}>
</Button>
</div>
),
});
return accordionList;
}, [resource, baseUrl, settings]);
const theme = useTheme();
return (
<div className='p-4'>
{accordionList.map((item) => (
<Accordion key={item.key}>
<AccordionSummary expandIcon={<ChevronDown color={theme.palette.text.secondary} />}>{item.title}</AccordionSummary>
<AccordionDetails>
<div
className={clsx('text-sm whitespace-pre-wrap w-full overflow-ellipsis overflow-hidden', {
'cursor-copy': item.clickCopy, // cursor-copy的有吗
})}
onClick={() => {
if (item.clickCopy) {
navigator.clipboard.writeText(item.content);
toast.success('复制成功');
}
}}>
{item.content}
</div>
</AccordionDetails>
</Accordion>
))}
</div>
);
};

View File

@@ -0,0 +1,41 @@
import { useResourceFileStore } from '@/pages/store/resource-file';
import { useShallow } from 'zustand/shallow';
import { QuickPreview } from './QuickPreview';
import { useMemo } from 'react';
import { getFileType } from '../../FileIcon';
const QuickModules = [
{
key: 'link',
type: 'link',
categroy: ['image', 'video', 'audio'],
tooltips: `链接生成markdown超链接`,
component: () => <QuickPreview />,
},
{
key: 'preview',
type: 'all',
tooltips: `预览页面内容`,
component: () => <QuickPreview />,
},
];
export const Quick = () => {
const { resource } = useResourceFileStore(
useShallow((state) => ({
resource: state.resource,
})),
);
const quickModule = useMemo(() => {
const fileType = getFileType(resource?.name);
const allCanUseModule = QuickModules.filter((item) => item.type === 'all');
if (!fileType) {
return allCanUseModule;
}
return QuickModules.filter((item) => item.type === fileType);
}, [resource]);
return (
<>
<QuickPreview />
</>
);
};