diff --git a/apps/web/src/components/MediaPicker/index.tsx b/apps/web/src/components/MediaPicker/index.tsx new file mode 100644 index 0000000..7658ead --- /dev/null +++ b/apps/web/src/components/MediaPicker/index.tsx @@ -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([]); + 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 ( + +
+ } + placeholder="搜索文件名..." + value={keyword} + onChange={(e) => { setKeyword(e.target.value); setPage(1); }} + allowClear + style={{ flex: 1 }} + /> + { handleUpload(file); return false; }} + > + + +
+ + {loading ? ( +
+ ) : items.length === 0 ? ( + + ) : ( + <> +
+ {items.map((item) => ( +
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'); }} + > +
+ {item.content_type.startsWith('image/') ? ( + { + 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==" + /> + ) : ( + {item.content_type.split('/')[1]} + )} +
+
+ {item.filename} +
+
+ ))} +
+ {totalPages > 1 && ( +
+ + {page} / {totalPages} + +
+ )} + + )} +
+ ); +} diff --git a/apps/web/src/pages/health/ArticleEditor.tsx b/apps/web/src/pages/health/ArticleEditor.tsx index a6cfa80..b33bc76 100644 --- a/apps/web/src/pages/health/ArticleEditor.tsx +++ b/apps/web/src/pages/health/ArticleEditor.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; 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 type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor'; import { @@ -13,7 +13,8 @@ import { } from '../../api/health/articles'; import { useThemeMode } from '../../hooks/useThemeMode'; 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 '@wangeditor/editor/dist/css/style.css'; @@ -42,6 +43,7 @@ export default function ArticleEditor() { const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [editor, setEditor] = useState(null); + const [mediaPickerOpen, setMediaPickerOpen] = useState(false); // 加载分类和标签 useEffect(() => { @@ -458,6 +460,9 @@ export default function ArticleEditor() { placeholder="请输入封面图片 URL 或上传文件" style={{ flex: 1 }} /> + + + setMediaPickerOpen(false)} + onSelect={(url) => { + setCoverImage(url); + message.success('已选择封面图'); + }} + /> ); }