feat(web): 添加 MediaPicker 组件并集成到 ArticleEditor 封面图选择
This commit is contained in:
192
apps/web/src/components/MediaPicker/index.tsx
Normal file
192
apps/web/src/components/MediaPicker/index.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Modal, Input, Upload, Image, Empty, Spin, message } from 'antd';
|
||||||
|
import { SearchOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
|
import { mediaApi, type MediaItem } from '../../api/health/media';
|
||||||
|
import { uploadFile } from '../../api/upload';
|
||||||
|
|
||||||
|
interface MediaPickerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (url: string, item?: MediaItem) => void;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export default function MediaPicker({ open, onClose, onSelect, accept = 'image/*' }: MediaPickerProps) {
|
||||||
|
const [items, setItems] = useState<MediaItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await mediaApi.list({
|
||||||
|
page,
|
||||||
|
page_size: PAGE_SIZE,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
content_type: accept === 'image/*' ? 'image' : undefined,
|
||||||
|
});
|
||||||
|
setItems(result.data);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch {
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, keyword, accept]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) loadData();
|
||||||
|
}, [open, loadData]);
|
||||||
|
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
await uploadFile(file);
|
||||||
|
message.success('上传成功');
|
||||||
|
await loadData();
|
||||||
|
} catch {
|
||||||
|
message.error('上传失败');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (item: MediaItem) => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const url = token ? `${item.storage_path}?token=${token}` : item.storage_path;
|
||||||
|
onSelect(url, item);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="选择媒体文件"
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={720}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
|
<Input
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder="搜索文件名..."
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
|
||||||
|
allowClear
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Upload
|
||||||
|
accept={accept}
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={(file) => { handleUpload(file); return false; }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: '4px 15px', borderRadius: 6, border: '1px solid #d9d9d9',
|
||||||
|
background: '#fff', cursor: 'pointer', fontSize: 14,
|
||||||
|
}}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{uploading ? <Spin size="small" /> : <UploadOutlined />}
|
||||||
|
上传
|
||||||
|
</button>
|
||||||
|
</Upload>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Empty description="暂无媒体文件" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||||
|
gap: 12,
|
||||||
|
maxHeight: 400,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
background: '#fafafa',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { (e.currentTarget.style.borderColor = '#1677ff'); }}
|
||||||
|
onMouseLeave={(e) => { (e.currentTarget.style.borderColor = '#f0f0f0'); }}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{item.content_type.startsWith('image/') ? (
|
||||||
|
<Image
|
||||||
|
src={(() => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const base = item.thumbnail_path || item.storage_path;
|
||||||
|
return token ? `${base}?token=${token}` : base;
|
||||||
|
})()}
|
||||||
|
alt={item.alt_text || item.filename}
|
||||||
|
style={{ maxWidth: '100%', maxHeight: 100, objectFit: 'cover' }}
|
||||||
|
preview={false}
|
||||||
|
fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNlOGU4ZTgiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0iIzk5OSIgZm9udC1zaXplPSIxMiI+PC90ZXh0Pjwvc3ZnPg=="
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#999', fontSize: 12 }}>{item.content_type.split('/')[1]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#666',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
title={item.filename}
|
||||||
|
>
|
||||||
|
{item.filename}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 12, alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
style={{ padding: '4px 12px', borderRadius: 4, border: '1px solid #d9d9d9', cursor: page <= 1 ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span style={{ fontSize: 13, color: '#666' }}>{page} / {totalPages}</span>
|
||||||
|
<button
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
style={{ padding: '4px 12px', borderRadius: 4, border: '1px solid #d9d9d9', cursor: page >= totalPages ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Button, Input, Select, Space, message, Spin, Upload } from 'antd';
|
import { Button, Input, Select, Space, message, Spin, Upload } from 'antd';
|
||||||
import { ArrowLeftOutlined, SaveOutlined, SendOutlined, UploadOutlined } from '@ant-design/icons';
|
import { ArrowLeftOutlined, SaveOutlined, SendOutlined, UploadOutlined, PictureOutlined } from '@ant-design/icons';
|
||||||
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
|
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
|
||||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
} from '../../api/health/articles';
|
} from '../../api/health/articles';
|
||||||
import { useThemeMode } from '../../hooks/useThemeMode';
|
import { useThemeMode } from '../../hooks/useThemeMode';
|
||||||
import { AuthButton } from '../../components/AuthButton';
|
import { AuthButton } from '../../components/AuthButton';
|
||||||
import client, { handleApiError } from '../../api/client';
|
import MediaPicker from '../../components/MediaPicker';
|
||||||
|
import { handleApiError } from '../../api/client';
|
||||||
import { uploadFile } from '../../api/upload';
|
import { uploadFile } from '../../api/upload';
|
||||||
import '@wangeditor/editor/dist/css/style.css';
|
import '@wangeditor/editor/dist/css/style.css';
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ export default function ArticleEditor() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [editor, setEditor] = useState<IDomEditor | null>(null);
|
const [editor, setEditor] = useState<IDomEditor | null>(null);
|
||||||
|
const [mediaPickerOpen, setMediaPickerOpen] = useState(false);
|
||||||
|
|
||||||
// 加载分类和标签
|
// 加载分类和标签
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -458,6 +460,9 @@ export default function ArticleEditor() {
|
|||||||
placeholder="请输入封面图片 URL 或上传文件"
|
placeholder="请输入封面图片 URL 或上传文件"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
|
<Button icon={<PictureOutlined />} onClick={() => setMediaPickerOpen(true)}>
|
||||||
|
媒体库
|
||||||
|
</Button>
|
||||||
<Upload
|
<Upload
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
@@ -538,6 +543,15 @@ export default function ArticleEditor() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MediaPicker
|
||||||
|
open={mediaPickerOpen}
|
||||||
|
onClose={() => setMediaPickerOpen(false)}
|
||||||
|
onSelect={(url) => {
|
||||||
|
setCoverImage(url);
|
||||||
|
message.success('已选择封面图');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user