Files
hms/apps/web/src/pages/health/MediaLibrary.tsx
iven ced1c0ad0c fix(web): 清零前端 TS 构建错误 — 31 文件类型修复 + 面包屑 + 超时配置
- 修复 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
2026-05-15 23:03:08 +08:00

310 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 { 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>
);
}