- 修复 verbatimModuleSyntax 要求的 import type 声明 - 修复未使用导入(Badge/EditOutlined/Space/Input/Switch 等) - 修复 mock.calls 类型注解([string,unknown] → any[]) - 修复 vitest 全局超时和 poolTimeout 配置 - 修复 PageContainer 缺少 onBack prop、MenuInfo children 可选 - 修复 CopilotAlert Badge status info→processing、useCopilotRisk 二次解包 - 修复 articles/doctors 测试 delete 调用缺少 version 参数 - 添加排班管理/预约管理面包屑标题 fallback
310 lines
17 KiB
TypeScript
310 lines
17 KiB
TypeScript
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 { resolveMediaUrl } from '../../utils/media';
|
||
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={resolveMediaUrl(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 as any}
|
||
fieldNames={{ title: 'name', key: 'id', children: 'children' }} onSelect={handleFolderSelect}
|
||
titleRender={(node: any) => {
|
||
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} destroyOnHidden>
|
||
<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} destroyOnHidden>
|
||
<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} destroyOnHidden>
|
||
<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} destroyOnHidden>
|
||
<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>
|
||
);
|
||
}
|