From 6bf8cc53f843a5278a5000a8a619b16d5d357ad5 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 10 May 2026 16:18:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=96=B0=E5=A2=9E=E5=AA=92?= =?UTF-8?q?=E4=BD=93=E5=BA=93=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 左侧面板:文件夹树形结构,支持创建/重命名/删除文件夹 - 右侧面板:媒体网格视图,支持上传/搜索/类型筛选/批量删除 - 上传弹窗:TreeSelect 选择目标文件夹 + 公开访问开关 - 编辑弹窗:修改文件名/替代文本/公开状态 - 移动弹窗:选择目标文件夹移动文件 - 权限码:health.media.list + health.media.manage --- apps/web/src/pages/health/MediaLibrary.tsx | 308 +++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 apps/web/src/pages/health/MediaLibrary.tsx diff --git a/apps/web/src/pages/health/MediaLibrary.tsx b/apps/web/src/pages/health/MediaLibrary.tsx new file mode 100644 index 0000000..4964c4f --- /dev/null +++ b/apps/web/src/pages/health/MediaLibrary.tsx @@ -0,0 +1,308 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Button, Space, Input, Select, TreeSelect, Tree, Upload, Modal, Form, + Switch, message, Popconfirm, Empty, Card, Checkbox, Typography, Dropdown, Spin, +} from 'antd'; +import { + UploadOutlined, FolderAddOutlined, EditOutlined, DeleteOutlined, + EllipsisOutlined, InboxOutlined, ReloadOutlined, +} from '@ant-design/icons'; +import type { UploadProps } from 'antd'; +import { mediaApi, mediaFolderApi, type MediaItem, type FolderItem } from '../../api/health/media'; +import { AuthButton } from '../../components/AuthButton'; +import { formatDateTime } from '../../utils/format'; + +// --- 工具函数 --- + +const CT_OPTIONS = [ + { value: '', label: '全部类型' }, + { value: 'image/', label: '图片' }, + { value: 'video/', label: '视频' }, + { value: 'application/', label: '文档' }, +]; + +const formatSize = (b: number) => b > 1048576 ? (b / 1048576).toFixed(1) + 'MB' : b > 1024 ? (b / 1024).toFixed(1) + 'KB' : b + 'B'; +const isImage = (ct: string) => ct.startsWith('image/'); + +interface TreeNode { id: string; name: string; children: TreeNode[] } + +function buildTree(folders: FolderItem[]): TreeNode[] { + return folders.map((f) => ({ id: f.id, name: `${f.name} (${f.item_count})`, children: buildTree(f.children) })); +} + +interface EditFormValues { filename: string; alt_text?: string; is_public: boolean } +interface FolderFormValues { name: string } + +// =========================================================================== + +export default function MediaLibrary() { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const pageSize = 24; + const [loading, setLoading] = useState(false); + const [keyword, setKeyword] = useState(''); + const [contentType, setContentType] = useState(''); + const [folderId, setFolderId] = useState(); + const [folders, setFolders] = useState([]); + const [foldersLoading, setFoldersLoading] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [uploadOpen, setUploadOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [moveOpen, setMoveOpen] = useState(false); + const [folderOpen, setFolderOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [movingItem, setMovingItem] = useState(null); + const [editingFolder, setEditingFolder] = useState(null); + const [uploadForm] = Form.useForm(); + const [editForm] = Form.useForm(); + const [folderForm] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + + const fetchMedia = useCallback(async () => { + setLoading(true); + try { + const r = await mediaApi.list({ page, page_size: pageSize, folder_id: folderId, content_type: contentType || undefined, keyword: keyword || undefined }); + setItems(r.data); + setTotal(r.total); + } catch { message.error('加载媒体列表失败'); } finally { setLoading(false); } + }, [page, pageSize, folderId, contentType, keyword]); + + const fetchFolders = useCallback(async () => { + setFoldersLoading(true); + try { setFolders(await mediaFolderApi.tree()); } + catch { message.error('加载文件夹失败'); } finally { setFoldersLoading(false); } + }, []); + + useEffect(() => { fetchMedia(); }, [fetchMedia]); + useEffect(() => { fetchFolders(); }, [fetchFolders]); + + const resetPage = () => { setPage(1); setSelectedIds(new Set()); }; + const handleFolderSelect = (keys: React.Key[]) => { setFolderId(keys.length ? (keys[0] as string) : undefined); resetPage(); }; + + const handleUpload: UploadProps['customRequest'] = async (opts) => { + const { file, onSuccess, onError } = opts; + try { + const fid = uploadForm.getFieldValue('folder_id') as string | undefined; + const pub = uploadForm.getFieldValue('is_public') as boolean; + const fd = new FormData(); + fd.append('file', file as File); + if (fid) fd.append('folder_id', fid); + fd.append('is_public', String(pub ?? false)); + await mediaApi.upload(fd); + onSuccess?.(null); + message.success('上传成功'); + fetchMedia(); fetchFolders(); + } catch (e) { onError?.(e as Error); message.error('上传失败'); } + }; + + const openEdit = (item: MediaItem) => { + setEditingItem(item); + editForm.setFieldsValue({ filename: item.filename, alt_text: item.alt_text, is_public: item.is_public }); + setEditOpen(true); + }; + + const handleEditSubmit = async (v: EditFormValues) => { + if (!editingItem) return; + setSubmitting(true); + try { await mediaApi.update(editingItem.id, { ...v, version: editingItem.version }); message.success('更新成功'); setEditOpen(false); fetchMedia(); } + catch { message.error('更新失败'); } finally { setSubmitting(false); } + }; + + const handleMove = async (targetId: string | undefined) => { + if (!movingItem) return; + try { await mediaApi.move(movingItem.id, { folder_id: targetId, version: movingItem.version }); message.success('移动成功'); setMoveOpen(false); fetchMedia(); fetchFolders(); } + catch { message.error('移动失败'); } + }; + + const handleDelete = async (item: MediaItem) => { + try { await mediaApi.delete(item.id, item.version); message.success('删除成功'); fetchMedia(); fetchFolders(); } + catch { message.error('删除失败'); } + }; + + const handleBatchDelete = async () => { + if (!selectedIds.size) return; + try { await mediaApi.batchDelete([...selectedIds]); message.success(`已删除 ${selectedIds.size} 个文件`); setSelectedIds(new Set()); fetchMedia(); fetchFolders(); } + catch { message.error('批量删除失败'); } + }; + + const openCreateFolder = () => { setEditingFolder(null); folderForm.resetFields(); setFolderOpen(true); }; + const openRenameFolder = (f: FolderItem) => { setEditingFolder(f); folderForm.setFieldsValue({ name: f.name }); setFolderOpen(true); }; + + const handleFolderSubmit = async (v: FolderFormValues) => { + setSubmitting(true); + try { + if (editingFolder) { await mediaFolderApi.update(editingFolder.id, { name: v.name, version: editingFolder.version }); message.success('文件夹已重命名'); } + else { await mediaFolderApi.create({ name: v.name, parent_id: folderId }); message.success('文件夹已创建'); } + setFolderOpen(false); fetchFolders(); + } catch { message.error(editingFolder ? '重命名失败' : '创建失败'); } finally { setSubmitting(false); } + }; + + const handleDeleteFolder = async (f: FolderItem) => { + try { await mediaFolderApi.delete(f.id, f.version); message.success('文件夹已删除'); if (folderId === f.id) setFolderId(undefined); fetchFolders(); fetchMedia(); } + catch { message.error('删除文件夹失败(可能不为空)'); } + }; + + const toggleSelect = (id: string) => setSelectedIds((prev) => { const n = new Set(prev); if (n.has(id)) { n.delete(id); } else { n.add(id); } return n; }); + + const renderCard = (item: MediaItem) => { + const sel = selectedIds.has(item.id); + const actions = [ + { key: 'edit', label: '编辑信息', onClick: () => openEdit(item) }, + { key: 'move', label: '移动到...', onClick: () => { setMovingItem(item); setMoveOpen(true); } }, + { key: 'delete', label: '删除', danger: true as const, onClick: () => handleDelete(item) }, + ]; + return ( + toggleSelect(item.id)} style={{ height: 140, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--ant-color-fill-quaternary, #f5f5f5)', overflow: 'hidden', position: 'relative', cursor: 'pointer' }}> + {isImage(item.content_type) ? ( + {item.alt_text + ) : ( + + )} + {sel &&
} +
+ +
+ + }> +
+ {item.filename} + {formatSize(item.file_size)} · {formatDateTime(item.created_at)} +
+
+ ); + }; + + const treeData = [{ id: '__all__', name: '全部文件', children: buildTree(folders) }]; + const pageCount = Math.ceil(total / pageSize); + + return ( +
+ {/* 左侧 — 文件夹树 */} +
+
+ 文件夹 + +
+
+ {foldersLoading ?
: ( + { + if (node.id === '__all__') return {node.name}; + const matched = folders.find((f) => f.id === node.id); + return ( +
+ {node.name} + {matched && + + +
+ ); + }} + /> + )} +
+
+ + {/* 右侧 — 媒体列表 */} +
+ {/* 工具栏 */} +
+ + + + + {selectedIds.size > 0 && <> + + + + + + + } + + + { setKeyword(v); resetPage(); }} /> + + + + + + + {/* 移动弹窗 */} + setMoveOpen(false)} footer={null} destroyOnClose> +
+ 选择目标文件夹: +
+ + {folders.map((f) => )} +
+
+
+ + {/* 文件夹创建/重命名弹窗 */} + setFolderOpen(false)} onOk={() => folderForm.submit()} confirmLoading={submitting} destroyOnClose> +
+ +
+
+
+ ); +}