Add webdrive upload and preview tweaks
This commit is contained in:
155
front/src/api.ts
155
front/src/api.ts
@@ -176,6 +176,14 @@ const notifyInvalidIdentity = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const require_identity_token = (): string => {
|
||||
const token = localStorage.getItem('identity_token');
|
||||
if (!token) {
|
||||
throw new Error('IDENTITY_REQUIRED');
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
export const getHotTopics = async (): Promise<HotTopicItem[]> => {
|
||||
try {
|
||||
const response = await fetch('/api/hot_topics');
|
||||
@@ -225,6 +233,153 @@ export const getTagSuggest = async (prefix: string, limit: number = 5): Promise<
|
||||
}
|
||||
};
|
||||
|
||||
export interface MyPicItem {
|
||||
name: string;
|
||||
path: string;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
export const getMyPicPages = async (numPerPage: number): Promise<number> => {
|
||||
try {
|
||||
const identity = require_identity_token();
|
||||
const response = await fetch(`/api/my/pics_pages?num_per_pages=${numPerPage}&identity_token=${encodeURIComponent(identity)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.code === 2004) {
|
||||
notifyInvalidIdentity();
|
||||
throw new Error('Identity token invalid');
|
||||
}
|
||||
if (json.code === 2009) {
|
||||
throw new Error('IDENTITY_REQUIRED');
|
||||
}
|
||||
if (json.code === 1000 && json.data) {
|
||||
return Number(json.data.total_pages) || 0;
|
||||
}
|
||||
throw new Error(json.data || 'Failed to fetch pages');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch my pic pages:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getMyPics = async (page: number, numPerPage: number): Promise<MyPicItem[]> => {
|
||||
try {
|
||||
const identity = require_identity_token();
|
||||
const response = await fetch(`/api/my/all_pics?page=${page}&num_per_page=${numPerPage}&identity_token=${encodeURIComponent(identity)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.code === 2004) {
|
||||
notifyInvalidIdentity();
|
||||
throw new Error('Identity token invalid');
|
||||
}
|
||||
if (json.code === 2009) {
|
||||
throw new Error('IDENTITY_REQUIRED');
|
||||
}
|
||||
if (json.code === 1000 && json.data && Array.isArray(json.data.list)) {
|
||||
return json.data.list as MyPicItem[];
|
||||
}
|
||||
throw new Error(json.data || 'Failed to fetch pics');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch my pics:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const modifyMyPic = async (path: string, file: File): Promise<void> => {
|
||||
try {
|
||||
const identity = require_identity_token();
|
||||
const formData = new FormData();
|
||||
formData.append('identity_token', identity);
|
||||
formData.append('path', path);
|
||||
formData.append('file', file);
|
||||
const response = await fetch('/api/my/modify_pic', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.code === 2004) {
|
||||
notifyInvalidIdentity();
|
||||
throw new Error('Identity token invalid');
|
||||
}
|
||||
if (json.code === 2009) {
|
||||
throw new Error('IDENTITY_REQUIRED');
|
||||
}
|
||||
if (json.code !== 1000) {
|
||||
throw new Error(json.data || 'Modify failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to modify pic:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const changeMyPicName = async (path: string, name: string): Promise<void> => {
|
||||
try {
|
||||
const identity = require_identity_token();
|
||||
const response = await fetch('/api/my/change_pic_name', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ identity_token: identity, path, name }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.code === 2004) {
|
||||
notifyInvalidIdentity();
|
||||
throw new Error('Identity token invalid');
|
||||
}
|
||||
if (json.code === 2009) {
|
||||
throw new Error('IDENTITY_REQUIRED');
|
||||
}
|
||||
if (json.code !== 1000) {
|
||||
throw new Error(json.data || 'Rename failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to rename pic:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteMyPic = async (path: string): Promise<void> => {
|
||||
try {
|
||||
const identity = require_identity_token();
|
||||
const response = await fetch('/api/my/del_pic', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ identity_token: identity, path }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.code === 2004) {
|
||||
notifyInvalidIdentity();
|
||||
throw new Error('Identity token invalid');
|
||||
}
|
||||
if (json.code === 2009) {
|
||||
throw new Error('IDENTITY_REQUIRED');
|
||||
}
|
||||
if (json.code !== 1000) {
|
||||
throw new Error(json.data || 'Delete failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete pic:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePostApiCode = (json: any) => {
|
||||
if (json && json.code === 2004) {
|
||||
notifyInvalidIdentity();
|
||||
|
||||
@@ -33,7 +33,8 @@ import {
|
||||
} from '@fluentui/react-icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLayout } from '../context/LayoutContext';
|
||||
import { saveDraft, getDraft, createPost, uploadImage, getTagSuggest } from '../api';
|
||||
import { saveDraft, getDraft, createPost, uploadImage, getTagSuggest, type MyPicItem } from '../api';
|
||||
import WebDrive from './WebDrive';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
container: {
|
||||
@@ -127,6 +128,10 @@ const useStyles = makeStyles({
|
||||
imageActions: {
|
||||
display: 'flex',
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
webDriveSurface: {
|
||||
width: '920px',
|
||||
maxWidth: '95vw',
|
||||
}
|
||||
});
|
||||
|
||||
@@ -175,6 +180,7 @@ const CreatePost: React.FC = () => {
|
||||
const [tagInputValue, setTagInputValue] = useState("");
|
||||
const [showImageDialog, setShowImageDialog] = useState(false);
|
||||
const [showUrlDialog, setShowUrlDialog] = useState(false);
|
||||
const [showWebDrive, setShowWebDrive] = useState(false);
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [showSuggest, setShowSuggest] = useState(false);
|
||||
@@ -266,6 +272,30 @@ const CreatePost: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const insertMarkdown = (markdown: string) => {
|
||||
const api = editorApiRef.current;
|
||||
if (api && typeof api.replaceSelection === 'function') {
|
||||
api.replaceSelection(markdown);
|
||||
} else {
|
||||
setValue(prev => `${prev || ''}\n${markdown}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getWebDriveName = (item: MyPicItem) => {
|
||||
if (item.name) return item.name;
|
||||
const path = item.path || '';
|
||||
const ext = path.split('.').pop() || 'png';
|
||||
return `pic.${ext}`;
|
||||
};
|
||||
|
||||
const handleWebDriveSelect = (items: MyPicItem[]) => {
|
||||
if (!items || items.length === 0) return;
|
||||
const markdown = items
|
||||
.map((item) => ``)
|
||||
.join('\n');
|
||||
insertMarkdown(markdown);
|
||||
};
|
||||
|
||||
const handleLocalUpload = () => {
|
||||
setShowImageDialog(false);
|
||||
fileInputRef.current?.click();
|
||||
@@ -293,7 +323,15 @@ const CreatePost: React.FC = () => {
|
||||
const formats = settings?.fileFormats;
|
||||
if (formats && formats.length > 0) {
|
||||
const mime = (file.type || '').toLowerCase();
|
||||
if (!mime || !formats.includes(mime)) {
|
||||
const allowed = !!mime && formats.some((rule) => {
|
||||
const normalized = String(rule || '').toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if (normalized === 'image/*') {
|
||||
return mime.startsWith('image/');
|
||||
}
|
||||
return normalized === mime;
|
||||
});
|
||||
if (!allowed) {
|
||||
dispatchToast(
|
||||
<Toast>
|
||||
<ToastTitle>上传的文件类型不支持</ToastTitle>
|
||||
@@ -811,7 +849,7 @@ const CreatePost: React.FC = () => {
|
||||
<DialogContent>请选择图片来源</DialogContent>
|
||||
<DialogActions>
|
||||
<div className={styles.imageActions}>
|
||||
<Button appearance="secondary" onClick={() => {}}>网盘上传</Button>
|
||||
<Button appearance="secondary" onClick={() => { setShowImageDialog(false); setShowWebDrive(true); }}>网盘上传</Button>
|
||||
<Button appearance="secondary" onClick={() => { setShowImageDialog(false); setShowUrlDialog(true); }}>输入URL</Button>
|
||||
<Button appearance="primary" onClick={handleLocalUpload}>本地上传</Button>
|
||||
</div>
|
||||
@@ -820,6 +858,24 @@ const CreatePost: React.FC = () => {
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showWebDrive} onOpenChange={(_, data) => setShowWebDrive(!!data.open)}>
|
||||
<DialogSurface className={styles.webDriveSurface}>
|
||||
<DialogBody>
|
||||
<DialogTitle>选择文件</DialogTitle>
|
||||
<DialogContent>
|
||||
<WebDrive
|
||||
mode={0}
|
||||
onClose={() => setShowWebDrive(false)}
|
||||
onSelect={(items) => {
|
||||
handleWebDriveSelect(items);
|
||||
setShowWebDrive(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showUrlDialog} onOpenChange={(_, data) => setShowUrlDialog(!!data.open)}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { makeStyles, tokens, Tab, TabList, Text } from '@fluentui/react-components';
|
||||
import WebDrive from './WebDrive';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
container: {
|
||||
@@ -32,6 +33,18 @@ const Panel: React.FC = () => {
|
||||
settings: '设置',
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (tab === 'drive') {
|
||||
return <WebDrive mode={1} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Text weight="semibold">{titleMap[tab]}</Text>
|
||||
<Text className={styles.placeholder}>这里是{titleMap[tab]}的占位内容</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<TabList
|
||||
@@ -45,12 +58,9 @@ const Panel: React.FC = () => {
|
||||
<Tab value="settings">设置</Tab>
|
||||
</TabList>
|
||||
|
||||
<div className={styles.section}>
|
||||
<Text weight="semibold">{titleMap[tab]}</Text>
|
||||
<Text className={styles.placeholder}>这里是 {titleMap[tab]} 的占位内容</Text>
|
||||
</div>
|
||||
<div className={styles.section}>{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Panel;
|
||||
export default Panel;
|
||||
493
front/src/components/WebDrive.tsx
Normal file
493
front/src/components/WebDrive.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
makeStyles,
|
||||
tokens,
|
||||
Button,
|
||||
Text,
|
||||
Spinner,
|
||||
Dialog,
|
||||
DialogSurface,
|
||||
DialogBody,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Input,
|
||||
Select,
|
||||
useToastController,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
} from '@fluentui/react-components';
|
||||
import {
|
||||
Grid24Regular,
|
||||
List24Regular,
|
||||
Edit24Regular,
|
||||
Rename24Regular,
|
||||
Delete24Regular,
|
||||
Image24Regular,
|
||||
ChevronLeft24Regular,
|
||||
ChevronRight24Regular,
|
||||
CloudArrowUp24Regular,
|
||||
} from '@fluentui/react-icons';
|
||||
import { getMyPicPages, getMyPics, type MyPicItem, modifyMyPic, changeMyPicName, deleteMyPic, uploadImage } from '../api';
|
||||
import { useLayout } from '../context/LayoutContext';
|
||||
import ImageViewer from './ImageViewer';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
container: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: tokens.spacingVerticalM,
|
||||
},
|
||||
topBar: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
topActions: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
viewToggle: {
|
||||
display: 'flex',
|
||||
gap: tokens.spacingHorizontalS,
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
gap: tokens.spacingHorizontalM,
|
||||
},
|
||||
gridCard: {
|
||||
border: `1px solid ${tokens.colorNeutralStroke1}`,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
padding: tokens.spacingVerticalS,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: tokens.spacingVerticalS,
|
||||
},
|
||||
gridImage: {
|
||||
width: '100%',
|
||||
height: '160px',
|
||||
objectFit: 'cover',
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
backgroundColor: tokens.colorNeutralBackground2,
|
||||
},
|
||||
previewable: {
|
||||
cursor: 'zoom-in',
|
||||
},
|
||||
gridFooter: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
nameText: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
maxWidth: '100%',
|
||||
},
|
||||
gridActions: {
|
||||
display: 'flex',
|
||||
gap: tokens.spacingHorizontalXS,
|
||||
},
|
||||
selected: {
|
||||
outline: `2px solid ${tokens.colorBrandForeground1}`,
|
||||
},
|
||||
list: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: tokens.spacingVerticalXS,
|
||||
},
|
||||
listRow: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '32px 1fr 180px 120px',
|
||||
alignItems: 'center',
|
||||
gap: tokens.spacingHorizontalS,
|
||||
padding: tokens.spacingVerticalXS,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
},
|
||||
listActions: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: tokens.spacingHorizontalXS,
|
||||
},
|
||||
pager: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: tokens.spacingHorizontalXS,
|
||||
},
|
||||
pagerButton: {
|
||||
minWidth: 'auto',
|
||||
},
|
||||
pagerInputWrap: {
|
||||
position: 'relative',
|
||||
},
|
||||
pagerInput: {
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: 0,
|
||||
marginBottom: tokens.spacingVerticalXS,
|
||||
padding: tokens.spacingVerticalXS,
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
boxShadow: tokens.shadow16,
|
||||
},
|
||||
footerBar: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
selectBar: {
|
||||
display: 'flex',
|
||||
gap: tokens.spacingHorizontalS,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
});
|
||||
|
||||
export type WebDriveMode = 0 | 1;
|
||||
|
||||
interface WebDriveProps {
|
||||
mode: WebDriveMode;
|
||||
onClose?: () => void;
|
||||
onSelect?: (items: MyPicItem[]) => void;
|
||||
}
|
||||
|
||||
const formatNameFallback = (item: MyPicItem) => {
|
||||
if (item.name) return item.name;
|
||||
const path = item.path || '';
|
||||
const ext = path.split('.').pop() || 'png';
|
||||
return `pic.${ext}`;
|
||||
};
|
||||
|
||||
const WebDrive: React.FC<WebDriveProps> = ({ mode, onClose, onSelect }) => {
|
||||
const styles = useStyles();
|
||||
const { toasterId } = useLayout();
|
||||
const { dispatchToast } = useToastController(toasterId);
|
||||
const [view, setView] = React.useState<'grid' | 'list'>('grid');
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [totalPages, setTotalPages] = React.useState(0);
|
||||
const [perPage, setPerPage] = React.useState(5);
|
||||
const [items, setItems] = React.useState<MyPicItem[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [selected, setSelected] = React.useState<Record<string, boolean>>({});
|
||||
const [showJump, setShowJump] = React.useState(false);
|
||||
const [jumpValue, setJumpValue] = React.useState('');
|
||||
const [renameOpen, setRenameOpen] = React.useState(false);
|
||||
const [renameValue, setRenameValue] = React.useState('');
|
||||
const [activePath, setActivePath] = React.useState('');
|
||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||
const [preview, setPreview] = React.useState<{ open: boolean; src?: string; alt?: string }>({ open: false });
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const uploadInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchData = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const pages = await getMyPicPages(perPage);
|
||||
setTotalPages(pages);
|
||||
const list = await getMyPics(page, perPage);
|
||||
setItems(list);
|
||||
} catch (err: any) {
|
||||
const msg = String(err?.message || '');
|
||||
if (msg.includes('IDENTITY_REQUIRED')) {
|
||||
dispatchToast(<Toast><ToastTitle>需要身份标识</ToastTitle></Toast>, { intent: 'error' });
|
||||
} else {
|
||||
dispatchToast(<Toast><ToastTitle>获取网盘失败</ToastTitle></Toast>, { intent: 'error' });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, perPage, dispatchToast]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [perPage]);
|
||||
|
||||
const toggleSelect = (path: string) => {
|
||||
setSelected(prev => ({ ...prev, [path]: !prev[path] }));
|
||||
};
|
||||
|
||||
const openModify = (path: string) => {
|
||||
setActivePath(path);
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !activePath) return;
|
||||
try {
|
||||
await modifyMyPic(activePath, file);
|
||||
dispatchToast(<Toast><ToastTitle>修改成功</ToastTitle></Toast>, { intent: 'success' });
|
||||
fetchData();
|
||||
} catch {
|
||||
dispatchToast(<Toast><ToastTitle>修改失败</ToastTitle></Toast>, { intent: 'error' });
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await uploadImage(file);
|
||||
dispatchToast(<Toast><ToastTitle>上传成功</ToastTitle></Toast>, { intent: 'success' });
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
const msg = String(err?.message || '');
|
||||
if (msg.includes('UPLOAD_TOO_LARGE')) {
|
||||
dispatchToast(<Toast><ToastTitle>上传的图片超出限制大小</ToastTitle></Toast>, { intent: 'error' });
|
||||
} else if (msg.includes('CORRUPTED_IMAGE')) {
|
||||
dispatchToast(<Toast><ToastTitle>上传的文件损坏</ToastTitle></Toast>, { intent: 'error' });
|
||||
} else if (msg.includes('UNSUPPORTED_FORMAT')) {
|
||||
dispatchToast(<Toast><ToastTitle>上传的文件类型不支持</ToastTitle></Toast>, { intent: 'error' });
|
||||
} else {
|
||||
dispatchToast(<Toast><ToastTitle>上传失败</ToastTitle></Toast>, { intent: 'error' });
|
||||
}
|
||||
} finally {
|
||||
if (uploadInputRef.current) uploadInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
try {
|
||||
await changeMyPicName(activePath, renameValue);
|
||||
dispatchToast(<Toast><ToastTitle>重命名成功</ToastTitle></Toast>, { intent: 'success' });
|
||||
setRenameOpen(false);
|
||||
fetchData();
|
||||
} catch {
|
||||
dispatchToast(<Toast><ToastTitle>重命名失败</ToastTitle></Toast>, { intent: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteMyPic(activePath);
|
||||
dispatchToast(<Toast><ToastTitle>删除成功</ToastTitle></Toast>, { intent: 'success' });
|
||||
setDeleteOpen(false);
|
||||
fetchData();
|
||||
} catch {
|
||||
dispatchToast(<Toast><ToastTitle>删除失败</ToastTitle></Toast>, { intent: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const pages = React.useMemo(() => {
|
||||
if (totalPages <= 5) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
return [1, 2, 'ellipsis', totalPages];
|
||||
}, [totalPages]);
|
||||
|
||||
const selectedCount = React.useMemo(
|
||||
() => Object.values(selected).filter(Boolean).length,
|
||||
[selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<input ref={uploadInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleUploadChange} />
|
||||
<div className={styles.topBar}>
|
||||
<div className={styles.viewToggle}>
|
||||
<Button
|
||||
appearance={view === 'grid' ? 'primary' : 'subtle'}
|
||||
icon={<Grid24Regular />}
|
||||
onClick={() => setView('grid')}
|
||||
/>
|
||||
<Button
|
||||
appearance={view === 'list' ? 'primary' : 'subtle'}
|
||||
icon={<List24Regular />}
|
||||
onClick={() => setView('list')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.topActions}>
|
||||
{mode === 1 && (
|
||||
<Button
|
||||
appearance="subtle"
|
||||
icon={<CloudArrowUp24Regular />}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
title="上传图片"
|
||||
aria-label="上传图片"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{mode === 0 && (
|
||||
<div className={styles.selectBar}>
|
||||
<Button appearance="secondary" onClick={onClose}>取消</Button>
|
||||
<Button
|
||||
appearance="primary"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={() => {
|
||||
const picked = items.filter(i => selected[i.path]);
|
||||
onSelect?.(picked);
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Spinner size="small" />
|
||||
) : totalPages === 0 ? (
|
||||
<Text>暂时没有文件哦~</Text>
|
||||
) : view === 'grid' ? (
|
||||
<div className={styles.grid}>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.path}
|
||||
className={`${styles.gridCard} ${mode === 0 && selected[item.path] ? styles.selected : ''}`}
|
||||
onClick={() => mode === 0 && toggleSelect(item.path)}
|
||||
>
|
||||
<img
|
||||
className={`${styles.gridImage} ${mode === 1 ? styles.previewable : ''}`}
|
||||
src={item.path}
|
||||
alt={item.name || 'pic'}
|
||||
onClick={(e) => {
|
||||
if (mode !== 1) return;
|
||||
e.stopPropagation();
|
||||
setPreview({ open: true, src: item.path, alt: item.name || 'pic' });
|
||||
}}
|
||||
/>
|
||||
<div className={styles.gridFooter}>
|
||||
<Text className={styles.nameText} title={formatNameFallback(item)}>{formatNameFallback(item)}</Text>
|
||||
{mode === 1 && (
|
||||
<div className={styles.gridActions}>
|
||||
<Button appearance="subtle" icon={<Edit24Regular />} title="替换文件" aria-label="替换文件" onClick={(e) => { e.stopPropagation(); openModify(item.path); }} />
|
||||
<Button appearance="subtle" icon={<Rename24Regular />} title="重命名" aria-label="重命名" onClick={(e) => { e.stopPropagation(); setActivePath(item.path); setRenameValue(item.name || ''); setRenameOpen(true); }} />
|
||||
<Button appearance="subtle" icon={<Delete24Regular />} title="删除" aria-label="删除" onClick={(e) => { e.stopPropagation(); setActivePath(item.path); setDeleteOpen(true); }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.path}
|
||||
className={`${styles.listRow} ${mode === 0 && selected[item.path] ? styles.selected : ''}`}
|
||||
onClick={() => mode === 0 && toggleSelect(item.path)}
|
||||
>
|
||||
<Image24Regular />
|
||||
<Text
|
||||
className={`${styles.nameText} ${mode === 1 ? styles.previewable : ''}`}
|
||||
title={formatNameFallback(item)}
|
||||
onClick={(e) => {
|
||||
if (mode !== 1) return;
|
||||
e.stopPropagation();
|
||||
setPreview({ open: true, src: item.path, alt: item.name || 'pic' });
|
||||
}}
|
||||
>
|
||||
{formatNameFallback(item)}
|
||||
</Text>
|
||||
<Text>{item.created_at ? item.created_at.replace('T', ' ').slice(0, 19) : ''}</Text>
|
||||
{mode === 1 ? (
|
||||
<div className={styles.listActions}>
|
||||
<Button appearance="subtle" icon={<Edit24Regular />} title="替换文件" aria-label="替换文件" onClick={(e) => { e.stopPropagation(); openModify(item.path); }} />
|
||||
<Button appearance="subtle" icon={<Rename24Regular />} title="重命名" aria-label="重命名" onClick={(e) => { e.stopPropagation(); setActivePath(item.path); setRenameValue(item.name || ''); setRenameOpen(true); }} />
|
||||
<Button appearance="subtle" icon={<Delete24Regular />} title="删除" aria-label="删除" onClick={(e) => { e.stopPropagation(); setActivePath(item.path); setDeleteOpen(true); }} />
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.footerBar}>
|
||||
<div className={styles.pager}>
|
||||
<Button className={styles.pagerButton} appearance="subtle" icon={<ChevronLeft24Regular />} onClick={() => setPage(p => Math.max(1, p - 1))} />
|
||||
{pages.map((p, idx) => {
|
||||
if (p === 'ellipsis') {
|
||||
return (
|
||||
<div key={`ellipsis-${idx}`} className={styles.pagerInputWrap}>
|
||||
<Button appearance="subtle" onClick={() => setShowJump(v => !v)}>...</Button>
|
||||
{showJump && (
|
||||
<div className={styles.pagerInput}>
|
||||
<Input
|
||||
value={jumpValue}
|
||||
onChange={(_, d) => setJumpValue(d.value)}
|
||||
placeholder="跳转页"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const n = Number(jumpValue);
|
||||
if (!Number.isNaN(n) && n >= 1 && n <= totalPages) {
|
||||
setPage(n);
|
||||
}
|
||||
setShowJump(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={`page-${p}`}
|
||||
appearance={p === page ? 'primary' : 'subtle'}
|
||||
onClick={() => setPage(p as number)}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button className={styles.pagerButton} appearance="subtle" icon={<ChevronRight24Regular />} onClick={() => setPage(p => Math.min(totalPages || 1, p + 1))} />
|
||||
</div>
|
||||
<div>
|
||||
<Select value={String(perPage)} onChange={(_, data) => setPerPage(Number(data.value))}>
|
||||
<option value="5">5个/页</option>
|
||||
<option value="10">10个/页</option>
|
||||
<option value="20">20个/页</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={renameOpen} onOpenChange={(_, data) => setRenameOpen(!!data.open)}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>重命名</DialogTitle>
|
||||
<DialogContent>
|
||||
<Input value={renameValue} onChange={(_, d) => setRenameValue(d.value)} placeholder="输入新名称" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button appearance="secondary" onClick={() => setRenameOpen(false)}>取消</Button>
|
||||
<Button appearance="primary" onClick={handleRename}>确定</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={(_, data) => setDeleteOpen(!!data.open)}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogContent>确定要删除这张图片吗?</DialogContent>
|
||||
<DialogActions>
|
||||
<Button appearance="secondary" onClick={() => setDeleteOpen(false)}>取消</Button>
|
||||
<Button appearance="primary" onClick={handleDelete}>删除</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
|
||||
{preview.open && preview.src && (
|
||||
<ImageViewer src={preview.src} alt={preview.alt} onClose={() => setPreview({ open: false })} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebDrive;
|
||||
Reference in New Issue
Block a user