feat(web): 新增媒体库管理页面

- 左侧面板:文件夹树形结构,支持创建/重命名/删除文件夹
- 右侧面板:媒体网格视图,支持上传/搜索/类型筛选/批量删除
- 上传弹窗:TreeSelect 选择目标文件夹 + 公开访问开关
- 编辑弹窗:修改文件名/替代文本/公开状态
- 移动弹窗:选择目标文件夹移动文件
- 权限码:health.media.list + health.media.manage
This commit is contained in:
iven
2026-05-10 16:18:47 +08:00
parent 2c7d4a3d63
commit 6bf8cc53f8

View 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>
);
}