Files
hms/apps/web/src/pages/health/MediaLibrary.tsx
iven 6bf8cc53f8 feat(web): 新增媒体库管理页面
- 左侧面板:文件夹树形结构,支持创建/重命名/删除文件夹
- 右侧面板:媒体网格视图,支持上传/搜索/类型筛选/批量删除
- 上传弹窗:TreeSelect 选择目标文件夹 + 公开访问开关
- 编辑弹窗:修改文件名/替代文本/公开状态
- 移动弹窗:选择目标文件夹移动文件
- 权限码:health.media.list + health.media.manage
2026-05-10 16:18:47 +08:00

309 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}