feat(web): 新增媒体库管理页面
- 左侧面板:文件夹树形结构,支持创建/重命名/删除文件夹 - 右侧面板:媒体网格视图,支持上传/搜索/类型筛选/批量删除 - 上传弹窗:TreeSelect 选择目标文件夹 + 公开访问开关 - 编辑弹窗:修改文件名/替代文本/公开状态 - 移动弹窗:选择目标文件夹移动文件 - 权限码:health.media.list + health.media.manage
This commit is contained in:
308
apps/web/src/pages/health/MediaLibrary.tsx
Normal file
308
apps/web/src/pages/health/MediaLibrary.tsx
Normal file
@@ -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<MediaItem[]>([]);
|
||||
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<string | undefined>();
|
||||
const [folders, setFolders] = useState<FolderItem[]>([]);
|
||||
const [foldersLoading, setFoldersLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<MediaItem | null>(null);
|
||||
const [movingItem, setMovingItem] = useState<MediaItem | null>(null);
|
||||
const [editingFolder, setEditingFolder] = useState<FolderItem | null>(null);
|
||||
const [uploadForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm<EditFormValues>();
|
||||
const [folderForm] = Form.useForm<FolderFormValues>();
|
||||
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 (
|
||||
<Card key={item.id} hoverable size="small" style={{ borderColor: sel ? 'var(--ant-color-primary)' : undefined }}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
cover={
|
||||
<div onClick={() => 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) ? (
|
||||
<img src={item.thumbnail_path || item.storage_path} alt={item.alt_text || item.filename} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<InboxOutlined style={{ fontSize: 36, color: 'var(--ant-color-text-quaternary)' }} />
|
||||
)}
|
||||
{sel && <div style={{ position: 'absolute', top: 4, left: 4 }}><Checkbox checked /></div>}
|
||||
<div style={{ position: 'absolute', top: 4, right: 4, opacity: 0, transition: 'opacity 0.2s' }}>
|
||||
<Dropdown menu={{ items: actions }} trigger={['click']}>
|
||||
<Button type="text" size="small" icon={<EllipsisOutlined />} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ padding: '6px 8px' }}>
|
||||
<Typography.Text ellipsis style={{ fontSize: 12, display: 'block' }} title={item.filename}>{item.filename}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{formatSize(item.file_size)} · {formatDateTime(item.created_at)}</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const treeData = [{ id: '__all__', name: '全部文件', children: buildTree(folders) }];
|
||||
const pageCount = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: 'calc(100vh - 64px)', overflow: 'hidden' }}>
|
||||
{/* 左侧 — 文件夹树 */}
|
||||
<div style={{ width: 260, borderRight: '1px solid var(--ant-color-border-secondary, #f0f0f0)', display: 'flex', flexDirection: 'column', background: 'var(--ant-color-bg-container, #fff)', flexShrink: 0 }}>
|
||||
<div style={{ padding: '12px 12px 8px', borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>文件夹</Typography.Text>
|
||||
<AuthButton code="health.media.manage">
|
||||
<Button type="text" size="small" icon={<FolderAddOutlined />} onClick={openCreateFolder} title="新建文件夹" />
|
||||
</AuthButton>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '8px 4px' }}>
|
||||
{foldersLoading ? <div style={{ textAlign: 'center', padding: 20 }}><Spin size="small" /></div> : (
|
||||
<Tree defaultExpandAll selectedKeys={folderId ? [folderId] : ['__all__']} treeData={treeData}
|
||||
fieldNames={{ title: 'name', key: 'id', children: 'children' }} onSelect={handleFolderSelect}
|
||||
titleRender={(node: TreeNode) => {
|
||||
if (node.id === '__all__') return <span>{node.name}</span>;
|
||||
const matched = folders.find((f) => f.id === node.id);
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{node.name}</span>
|
||||
{matched && <span style={{ marginLeft: 4 }}>
|
||||
<AuthButton code="health.media.manage">
|
||||
<Space size={4}>
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={(e) => { e.stopPropagation(); openRenameFolder(matched); }} />
|
||||
<Popconfirm title="确定删除此文件夹?" description="仅空文件夹可删除" onConfirm={() => handleDeleteFolder(matched)}>
|
||||
<Button type="text" size="small" icon={<DeleteOutlined />} danger onClick={(e) => e.stopPropagation()} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</AuthButton>
|
||||
</span>}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧 — 媒体列表 */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{/* 工具栏 */}
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap', background: 'var(--ant-color-bg-container, #fff)' }}>
|
||||
<Space>
|
||||
<AuthButton code="health.media.manage">
|
||||
<Button type="primary" icon={<UploadOutlined />} onClick={() => { uploadForm.resetFields(); uploadForm.setFieldsValue({ is_public: false }); setUploadOpen(true); }}>上传文件</Button>
|
||||
</AuthButton>
|
||||
{selectedIds.size > 0 && <>
|
||||
<AuthButton code="health.media.manage">
|
||||
<Popconfirm title={`确定删除选中的 ${selectedIds.size} 个文件?`} onConfirm={handleBatchDelete}>
|
||||
<Button danger icon={<DeleteOutlined />}>批量删除 ({selectedIds.size})</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
<Button size="small" onClick={() => setSelectedIds(new Set())}>取消选择</Button>
|
||||
</>}
|
||||
</Space>
|
||||
<Space>
|
||||
<Input.Search placeholder="搜索文件名" allowClear style={{ width: 200 }} onSearch={(v) => { setKeyword(v); resetPage(); }} />
|
||||
<Select value={contentType} onChange={(v) => { setContentType(v); resetPage(); }} style={{ width: 120 }} options={CT_OPTIONS} />
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { fetchMedia(); fetchFolders(); }} />
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 媒体网格 */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
|
||||
{loading ? <div style={{ textAlign: 'center', padding: 60 }}><Spin size="large" /></div>
|
||||
: items.length === 0 ? <Empty description="暂无文件" />
|
||||
: <>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 12 }}>
|
||||
{items.map(renderCard)}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0', display: 'flex', justifyContent: 'center', gap: 8, alignItems: 'center' }}>
|
||||
<Button size="small" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>上一页</Button>
|
||||
<Typography.Text>{page} / {pageCount} (共 {total} 个)</Typography.Text>
|
||||
<Button size="small" disabled={page >= pageCount} onClick={() => setPage((p) => p + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传弹窗 */}
|
||||
<Modal title="上传文件" open={uploadOpen} onCancel={() => setUploadOpen(false)} footer={null} destroyOnClose>
|
||||
<Form form={uploadForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="folder_id" label="目标文件夹">
|
||||
<TreeSelect allowClear placeholder="根目录" treeDefaultExpandAll fieldNames={{ label: 'name', value: 'id', children: 'children' }} treeData={buildTree(folders)} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_public" label="公开访问" valuePropName="checked"><Switch /></Form.Item>
|
||||
<Upload.Dragger multiple customRequest={handleUpload} showUploadList={false} accept="image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx">
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined /></p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||
<p className="ant-upload-hint">支持图片、视频、文档等常见格式</p>
|
||||
</Upload.Dragger>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
<Modal title="编辑文件信息" open={editOpen} onCancel={() => setEditOpen(false)} onOk={() => editForm.submit()} confirmLoading={submitting} destroyOnClose>
|
||||
<Form form={editForm} onFinish={handleEditSubmit} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="filename" label="文件名" rules={[{ required: true, message: '请输入文件名' }]}><Input placeholder="文件名" /></Form.Item>
|
||||
<Form.Item name="alt_text" label="替代文本"><Input.TextArea rows={2} placeholder="描述图片内容(用于无障碍访问)" /></Form.Item>
|
||||
<Form.Item name="is_public" label="公开访问" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 移动弹窗 */}
|
||||
<Modal title="移动到文件夹" open={moveOpen} onCancel={() => setMoveOpen(false)} footer={null} destroyOnClose>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Paragraph type="secondary">选择目标文件夹:</Typography.Paragraph>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Button onClick={() => handleMove(undefined)} block type={movingItem?.folder_id ? 'default' : 'primary'}>根目录</Button>
|
||||
{folders.map((f) => <Button key={f.id} onClick={() => handleMove(f.id)} block type={movingItem?.folder_id === f.id ? 'primary' : 'default'}>{f.name}</Button>)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 文件夹创建/重命名弹窗 */}
|
||||
<Modal title={editingFolder ? '重命名文件夹' : '新建文件夹'} open={folderOpen} onCancel={() => setFolderOpen(false)} onOk={() => folderForm.submit()} confirmLoading={submitting} destroyOnClose>
|
||||
<Form form={folderForm} onFinish={handleFolderSubmit} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="name" label="文件夹名称" rules={[{ required: true, message: '请输入文件夹名称' }]}><Input placeholder="输入名称" maxLength={50} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user