diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a303736..c2bfce6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -419,6 +419,14 @@ export const api = { fd.append('image', file); return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} }); }, + /** 当前用户历史上传的帖子图片 */ + myPostImages: (params?: { page?: number; size?: number }) => { + const q = new URLSearchParams(); + if (params?.page) q.set('page', String(params.page)); + if (params?.size) q.set('size', String(params.size)); + const qs = q.toString(); + return request(`/api/uploads/images${qs ? `?${qs}` : ''}`); + }, createPost: (data: { board_id: string; title: string; content: string; tags?: string; post_type?: string; poll_options?: string; bounty_points?: number; lottery_winner_count?: number; diff --git a/frontend/src/components/ArticleEditor.tsx b/frontend/src/components/ArticleEditor.tsx index ea29d09..dfaeeb1 100644 --- a/frontend/src/components/ArticleEditor.tsx +++ b/frontend/src/components/ArticleEditor.tsx @@ -31,7 +31,6 @@ import { insertMarkdownLink, } from '../utils/markdownFormat'; import { countWords } from '../utils/text'; -import { api } from '../api/client'; import { notify } from '@/lib/notify'; import { MembersOnly } from './editor/MembersOnlyExtension'; import { ReplyOnly } from './editor/ReplyOnlyExtension'; @@ -40,7 +39,8 @@ import { TabIndent } from './editor/TabIndentExtension'; import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension'; import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension'; import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagraph'; -import { ArticleLinkDialog } from './editor/ArticleLinkDialog'; +import { ArticleLinkDialog, type ArticleLinkConfirm } from './editor/ArticleLinkDialog'; +import { ArticleImagePickerDialog } from './editor/ArticleImagePickerDialog'; import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog'; import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension'; import { @@ -73,6 +73,7 @@ interface Props { type EditorMode = 'rich' | 'markdown'; type LinkTarget = 'rich' | 'markdown'; +type ImagePickerTarget = 'rich' | 'markdown'; type CodeBlockTarget = 'rich' | 'markdown'; type TableTarget = 'rich' | 'markdown'; @@ -167,34 +168,6 @@ function cycleHeading(editor: Editor) { editor.chain().focus().toggleHeading({ level: 2 }).run(); } -/** 触发图片文件选择并上传(支持多选) */ -async function uploadPostImageFiles(multiple = true): Promise { - return new Promise(resolve => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'image/jpeg,image/png,image/gif,image/webp'; - input.multiple = multiple; - input.onchange = async () => { - const files = [...(input.files ?? [])]; - if (!files.length) { - resolve([]); - return; - } - const urls: string[] = []; - for (const file of files) { - try { - const { url } = await api.uploadPostImage(file); - urls.push(url); - } catch (e: unknown) { - notify.error(e instanceof Error ? e.message : '图片上传失败'); - } - } - resolve(urls); - }; - input.click(); - }); -} - /** 渲染工具栏按钮列表 */ function renderToolButtons(tools: ToolBtn[]) { return tools.map((t, i) => ( @@ -228,7 +201,11 @@ const ArticleEditor = forwardRef(function ArticleEdi const [markdownSource, setMarkdownSource] = useState(''); const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [linkDialogUrl, setLinkDialogUrl] = useState(''); + const [linkDialogText, setLinkDialogText] = useState(''); + const [linkDialogEditing, setLinkDialogEditing] = useState(false); const [linkTarget, setLinkTarget] = useState('rich'); + const [imagePickerOpen, setImagePickerOpen] = useState(false); + const [imagePickerTarget, setImagePickerTarget] = useState('rich'); const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false); const [codeBlockTarget, setCodeBlockTarget] = useState('rich'); const [codeBlockEditing, setCodeBlockEditing] = useState(false); @@ -262,6 +239,11 @@ const ArticleEditor = forwardRef(function ArticleEdi openOnClick: false, autolink: true, defaultProtocol: 'https', + // 新标签由全站设置在展示层处理,编辑器不写 target/rel + HTMLAttributes: { + target: null, + rel: null, + }, }), ArticleImage.configure({ inline: false, allowBase64: false }), ImageGroup, @@ -393,14 +375,33 @@ const ArticleEditor = forwardRef(function ArticleEdi const openLinkDialog = useCallback((target: LinkTarget) => { if (target === 'rich') { if (!editor) return; - const prev = editor.getAttributes('link').href as string | undefined; - setLinkDialogUrl(prev ?? ''); + const { from, to, empty } = editor.state.selection; + let text = empty ? '' : editor.state.doc.textBetween(from, to, ''); + let href = ''; + let editing = false; + if (editor.isActive('link')) { + const attrs = editor.getAttributes('link'); + href = (attrs.href as string) || ''; + editing = Boolean(href); + editor.chain().focus().extendMarkRange('link').run(); + const sel = editor.state.selection; + text = editor.state.doc.textBetween(sel.from, sel.to, '') || text; + } + setLinkDialogUrl(href); + setLinkDialogText(text); + setLinkDialogEditing(editing); } else { + const textarea = markdownRef.current; + const selected = textarea + ? markdownSource.slice(textarea.selectionStart, textarea.selectionEnd) + : ''; setLinkDialogUrl(''); + setLinkDialogText(selected); + setLinkDialogEditing(false); } setLinkTarget(target); setLinkDialogOpen(true); - }, [editor]); + }, [editor, markdownSource]); const openCodeBlockDialog = useCallback((target: CodeBlockTarget) => { setCodeBlockTarget(target); @@ -477,11 +478,12 @@ const ArticleEditor = forwardRef(function ArticleEdi } }, [editor]); - const applyLink = useCallback((url: string) => { + const applyLink = useCallback((payload: ArticleLinkConfirm) => { + const { url, text } = payload; if (linkTarget === 'markdown') { const textarea = markdownRef.current; if (!textarea || !url) return; - insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange); + insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange, { text }); return; } if (!editor) return; @@ -489,7 +491,31 @@ const ArticleEditor = forwardRef(function ArticleEdi editor.chain().focus().extendMarkRange('link').unsetLink().run(); return; } - editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); + // 新标签行为由全站 open_content_links_in_new_tab 在展示层处理 + const linkAttrs = { href: url }; + const { empty } = editor.state.selection; + const hasLink = editor.isActive('link'); + if (!empty || hasLink) { + editor.chain().focus().extendMarkRange('link').setLink(linkAttrs).run(); + const { from, to } = editor.state.selection; + const current = editor.state.doc.textBetween(from, to, ''); + if (text && current !== text) { + editor.chain().focus().insertContentAt( + { from, to }, + { + type: 'text', + text, + marks: [{ type: 'link', attrs: linkAttrs }], + }, + ).run(); + } + return; + } + editor.chain().focus().insertContent({ + type: 'text', + text: text || '链接文字', + marks: [{ type: 'link', attrs: linkAttrs }], + }).run(); }, [editor, linkTarget, markdownSource, handleMarkdownChange]); const removeLink = useCallback(() => { @@ -497,16 +523,33 @@ const ArticleEditor = forwardRef(function ArticleEdi editor.chain().focus().extendMarkRange('link').unsetLink().run(); }, [editor]); - const setImage = useCallback(async () => { - if (!editor) return; - const urls = await uploadPostImageFiles(true); + const openImagePicker = useCallback((target: ImagePickerTarget) => { + setImagePickerTarget(target); + setImagePickerOpen(true); + }, []); + + const applyImageUrls = useCallback((urls: string[]) => { if (!urls.length) return; + if (imagePickerTarget === 'markdown') { + const textarea = markdownRef.current; + if (!textarea) return; + if (urls.length === 1) { + insertAtCursor(textarea, markdownSource, `\n\n![图片](${urls[0]})\n\n`, handleMarkdownChange); + return; + } + const layout = suggestImageGroupLayout(urls.length); + const imgs = urls.map(u => ``).join(''); + const block = `\n\n
${imgs}
\n\n`; + insertAtCursor(textarea, markdownSource, block, handleMarkdownChange); + return; + } + if (!editor) return; if (urls.length === 1) { editor.chain().focus().setImage({ src: urls[0] }).run(); return; } editor.chain().focus().insertImageGroup(urls, suggestImageGroupLayout(urls.length)).run(); - }, [editor]); + }, [editor, imagePickerTarget, markdownSource, handleMarkdownChange]); const setImageDisplay = useCallback((display: ImageDisplay) => { if (!editor) return; @@ -596,21 +639,6 @@ const ArticleEditor = forwardRef(function ArticleEdi fn(textarea, markdownSource, handleMarkdownChange); }, [markdownSource, handleMarkdownChange]); - const insertMarkdownImage = useCallback(async () => { - const textarea = markdownRef.current; - if (!textarea) return; - const urls = await uploadPostImageFiles(true); - if (!urls.length) return; - if (urls.length === 1) { - insertAtCursor(textarea, markdownSource, `\n\n![图片](${urls[0]})\n\n`, handleMarkdownChange); - return; - } - const layout = suggestImageGroupLayout(urls.length); - const imgs = urls.map(u => ``).join(''); - const block = `\n\n
${imgs}
\n\n`; - insertAtCursor(textarea, markdownSource, block, handleMarkdownChange); - }, [markdownSource, handleMarkdownChange]); - const markdownPreviewHtml = useMemo( () => sanitizeHtml(markdownToHtml(markdownSource)), [markdownSource], @@ -637,9 +665,9 @@ const ArticleEditor = forwardRef(function ArticleEdi { icon: , title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') }, { icon: , - title: '上传图片', - hint: '可多选;多张自动并排成图组', - action: setImage, + title: '图片', + hint: '上传、链接或从已上传中选择', + action: () => openImagePicker('rich'), }, { icon: , @@ -732,7 +760,7 @@ const ArticleEditor = forwardRef(function ArticleEdi } return tools; - }, [editor, enableContentGates, openLinkDialog, openCodeBlockDialog, openTableDialog, setImage, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]); + }, [editor, enableContentGates, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker, wrapMembersOnly, wrapReplyOnly, wrapPointsOnly, wrapSelectedAsGroup, setImageDisplay]); const buildMarkdownTools = useCallback((): ToolBtn[] => { const tools: ToolBtn[] = [ @@ -748,7 +776,12 @@ const ArticleEditor = forwardRef(function ArticleEdi { icon: , title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') }, { icon: , title: '表格', hint: '插入 GFM 管道表', action: () => openTableDialog('markdown') }, { icon: , title: '链接', action: () => openLinkDialog('markdown') }, - { icon: , title: '上传图片', action: insertMarkdownImage }, + { + icon: , + title: '图片', + hint: '上传、链接或从已上传中选择', + action: () => openImagePicker('markdown'), + }, ]; if (enableContentGates) { tools.push( @@ -776,7 +809,7 @@ const ArticleEditor = forwardRef(function ArticleEdi ); } return tools; - }, [enableContentGates, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, insertMarkdownImage]); + }, [enableContentGates, withMarkdown, openLinkDialog, openCodeBlockDialog, openTableDialog, openImagePicker]); const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools(); const words = mode === 'markdown' @@ -897,8 +930,15 @@ const ArticleEditor = forwardRef(function ArticleEdi open={linkDialogOpen} onOpenChange={setLinkDialogOpen} initialUrl={linkDialogUrl} + initialText={linkDialogText} + editing={linkDialogEditing} onConfirm={applyLink} - onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined} + onRemove={linkTarget === 'rich' && linkDialogEditing ? removeLink : undefined} + /> + { - return new Promise(resolve => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = 'image/jpeg,image/png,image/gif,image/webp'; - input.multiple = false; - input.onchange = async () => { - const files = [...(input.files ?? [])]; - if (!files.length) { resolve([]); return; } - try { - const { url } = await api.uploadPostImage(files[0]); - resolve([url]); - } catch (e: unknown) { - notify.error(e instanceof Error ? e.message : '图片上传失败'); - resolve([]); - } - }; - input.click(); - }); -} - const CommentEditor = forwardRef(function CommentEditor( { value, onChange, placeholder = '说点什么吧…' }, ref, @@ -79,6 +57,11 @@ const CommentEditor = forwardRef(function CommentEdi const lastValueRef = useRef(value); const [, setTick] = useState(0); const [showSticker, setShowSticker] = useState(false); + const [imagePickerOpen, setImagePickerOpen] = useState(false); + const [linkDialogOpen, setLinkDialogOpen] = useState(false); + const [linkDialogUrl, setLinkDialogUrl] = useState(''); + const [linkDialogText, setLinkDialogText] = useState(''); + const [linkDialogEditing, setLinkDialogEditing] = useState(false); const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false); const [codeBlockEditing, setCodeBlockEditing] = useState(false); const [codeBlockInitial, setCodeBlockInitial] = useState(null); @@ -99,6 +82,11 @@ const CommentEditor = forwardRef(function CommentEdi openOnClick: false, autolink: true, defaultProtocol: 'https', + // 新标签由全站设置在展示层处理,编辑器不写 target/rel + HTMLAttributes: { + target: null, + rel: null, + }, }), ArticleImage.configure({ inline: true, allowBase64: true }), Placeholder.configure({ placeholder }), @@ -179,11 +167,14 @@ const CommentEditor = forwardRef(function CommentEdi setShowSticker(false); }, [editor]); - const setImage = useCallback(async () => { - if (!editor) return; - const urls = await uploadImageFiles(); - if (!urls.length) return; - editor.chain().focus().setImage({ src: urls[0] }).run(); + const applyImageUrls = useCallback((urls: string[]) => { + if (!editor || !urls.length) return; + // 评论为 inline 图,无图组:逐张插入并跟空格,便于光标落在右侧 + const nodes = urls.flatMap(src => [ + { type: 'image' as const, attrs: { src } }, + { type: 'text' as const, text: ' ' }, + ]); + editor.chain().focus().insertContent(nodes).run(); }, [editor]); const openCodeBlockDialog = useCallback(() => { @@ -213,23 +204,70 @@ const CommentEditor = forwardRef(function CommentEdi }).run(); }, [editor]); - const setLink = useCallback(() => { + const openLinkDialog = useCallback(() => { if (!editor) return; - const prev = editor.getAttributes('link').href as string | undefined; - const url = window.prompt('链接地址', prev ?? 'https://'); - if (url === null) return; + const { from, to, empty } = editor.state.selection; + let text = empty ? '' : editor.state.doc.textBetween(from, to, ''); + let href = ''; + let editing = false; + if (editor.isActive('link')) { + const attrs = editor.getAttributes('link'); + href = (attrs.href as string) || ''; + editing = Boolean(href); + editor.chain().focus().extendMarkRange('link').run(); + const sel = editor.state.selection; + text = editor.state.doc.textBetween(sel.from, sel.to, '') || text; + } + setLinkDialogUrl(href); + setLinkDialogText(text); + setLinkDialogEditing(editing); + setLinkDialogOpen(true); + }, [editor]); + + const applyLink = useCallback((payload: ArticleLinkConfirm) => { + if (!editor) return; + const { url, text } = payload; if (!url) { editor.chain().focus().extendMarkRange('link').unsetLink().run(); return; } - editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); + // 新标签行为由全站 open_content_links_in_new_tab 在展示层处理 + const linkAttrs = { href: url }; + const { empty } = editor.state.selection; + const hasLink = editor.isActive('link'); + if (!empty || hasLink) { + editor.chain().focus().extendMarkRange('link').setLink(linkAttrs).run(); + const { from, to } = editor.state.selection; + const current = editor.state.doc.textBetween(from, to, ''); + if (text && current !== text) { + editor.chain().focus().insertContentAt( + { from, to }, + { + type: 'text', + text, + marks: [{ type: 'link', attrs: linkAttrs }], + }, + ).run(); + } + return; + } + editor.chain().focus().insertContent({ + type: 'text', + text: text || '链接文字', + marks: [{ type: 'link', attrs: linkAttrs }], + }).run(); + }, [editor]); + + const removeLink = useCallback(() => { + if (!editor) return; + editor.chain().focus().extendMarkRange('link').unsetLink().run(); }, [editor]); if (!editor) { return
; } - const tools: { icon: React.ReactNode; title: string; active?: boolean; action: () => void; className?: string }[] = [ + const tools: { icon: React.ReactNode; title: string; hint?: string; active?: boolean; action: () => void; className?: string }[] = [ { icon: , title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() }, { icon: , title: '斜体', active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() }, { icon: , title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() }, @@ -237,8 +275,13 @@ const CommentEditor = forwardRef(function CommentEdi { icon: , title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() }, { icon: , title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() }, { icon: , title: '代码块', active: editor.isActive('codeBlock'), action: openCodeBlockDialog }, - { icon: , title: '链接', active: editor.isActive('link'), action: setLink }, - { icon: , title: '上传图片', action: setImage }, + { icon: , title: '链接', active: editor.isActive('link'), action: openLinkDialog }, + { + icon: , + title: '图片', + hint: '上传、链接或从已上传中选择', + action: () => setImagePickerOpen(true), + }, { icon: OwO, title: '表情 OwO', active: showSticker, action: () => setShowSticker(v => !v), className: 'article-tool-btn--owo' }, ]; @@ -247,14 +290,14 @@ const CommentEditor = forwardRef(function CommentEdi
{tools.map((t, i) => ( - + @@ -268,6 +311,20 @@ const CommentEditor = forwardRef(function CommentEdi
{showSticker && } + + void; + /** 上传或选中后插入一张或多张图片 URL */ + onInsert: (urls: string[]) => void; +} + +/** 校验可插入的图片地址:外链或站内绝对路径 */ +export function isValidImageSrc(url: string): boolean { + const u = url.trim(); + if (!u) return false; + if (u.startsWith('/') && !u.startsWith('//')) return true; + try { + const parsed = new URL(u); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +const ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'; +const PAGE_SIZE = 24; + +/** 文章编辑器:统一图片插入(上传 / 链接 / 我的图片) */ +export function ArticleImagePickerDialog({ + open, + onOpenChange, + onInsert, +}: Props) { + const [tab, setTab] = useState('upload'); + const [url, setUrl] = useState(''); + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + const fileInputRef = useRef(null); + + const [gallery, setGallery] = useState([]); + const [galleryPage, setGalleryPage] = useState(1); + const [galleryTotalPages, setGalleryTotalPages] = useState(1); + const [galleryLoading, setGalleryLoading] = useState(false); + const [galleryLoaded, setGalleryLoaded] = useState(false); + const [selected, setSelected] = useState>(new Set()); + + const resetState = useCallback(() => { + setTab('upload'); + setUrl(''); + setUploading(false); + setDragOver(false); + setGallery([]); + setGalleryPage(1); + setGalleryTotalPages(1); + setGalleryLoading(false); + setGalleryLoaded(false); + setSelected(new Set()); + }, []); + + useEffect(() => { + if (open) resetState(); + }, [open, resetState]); + + const handleOpenChange = (next: boolean) => { + if (!next) resetState(); + onOpenChange(next); + }; + + const finishInsert = (urls: string[]) => { + if (!urls.length) return; + onInsert(urls); + handleOpenChange(false); + }; + + const uploadFiles = async (files: File[]) => { + const images = files.filter(f => f.type.startsWith('image/')); + if (!images.length) { + notify.warning('请选择图片文件(jpeg / png / gif / webp)'); + return; + } + setUploading(true); + const urls: string[] = []; + try { + for (const file of images) { + try { + const { url: uploaded } = await api.uploadPostImage(file); + urls.push(uploaded); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '图片上传失败'); + } + } + if (urls.length) finishInsert(urls); + } finally { + setUploading(false); + } + }; + + const onFileChange = (list: FileList | null) => { + if (!list?.length) return; + void uploadFiles([...list]); + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + if (uploading) return; + void uploadFiles([...e.dataTransfer.files]); + }; + + const handleLinkInsert = () => { + const next = url.trim(); + if (!next) { + notify.warning('请输入图片地址'); + return; + } + if (!isValidImageSrc(next)) { + notify.warning('请使用 http(s) 外链或本站以 / 开头的路径'); + return; + } + finishInsert([next]); + }; + + const loadGallery = useCallback(async (page: number, append: boolean) => { + setGalleryLoading(true); + try { + const res = await api.myPostImages({ page, size: PAGE_SIZE }); + setGallery(prev => (append ? [...prev, ...res.files] : res.files)); + setGalleryPage(res.page); + setGalleryTotalPages(res.total_pages); + setGalleryLoaded(true); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '加载图片失败'); + setGalleryLoaded(true); + } finally { + setGalleryLoading(false); + } + }, []); + + useEffect(() => { + if (open && tab === 'gallery' && !galleryLoaded && !galleryLoading) { + void loadGallery(1, false); + } + }, [open, tab, galleryLoaded, galleryLoading, loadGallery]); + + const toggleSelect = (itemUrl: string) => { + setSelected(prev => { + const next = new Set(prev); + if (next.has(itemUrl)) next.delete(itemUrl); + else next.add(itemUrl); + return next; + }); + }; + + const handleGalleryInsert = () => { + if (!selected.size) { + notify.warning('请先选择图片'); + return; + } + // 保持网格出现顺序 + const urls = gallery.filter(f => selected.has(f.url)).map(f => f.url); + finishInsert(urls); + }; + + const tabs: { id: PickerTab; label: string; icon: typeof Upload }[] = [ + { id: 'upload', label: '上传', icon: Upload }, + { id: 'link', label: '链接', icon: Link2 }, + { id: 'gallery', label: '我的图片', icon: Images }, + ]; + + return ( + + + + 插入图片 + + 上传本地文件、粘贴链接,或从已上传图库中选择。 + + + +
+ {tabs.map(t => { + const Icon = t.icon; + return ( + + ); + })} +
+ + {tab === 'upload' && ( +
+
{ + e.preventDefault(); + if (!uploading) setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + onClick={() => !uploading && fileInputRef.current?.click()} + role="button" + tabIndex={0} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fileInputRef.current?.click(); + } + }} + > + {uploading ? ( + <> + +

正在上传…

+ + ) : ( + <> + +

拖拽图片到此处,或点击选择文件

+ 支持 jpeg / png / gif / webp;多选将并排成图组 + + )} +
+ { + onFileChange(e.target.files); + e.target.value = ''; + }} + /> +
+ )} + + {tab === 'link' && ( +
+ setUrl(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + handleLinkInsert(); + } + }} + autoFocus + /> +

+ 粘贴外链或本站已上传地址,无需重复上传。 +

+ + + + +
+ )} + + {tab === 'gallery' && ( +
+ {galleryLoading && !gallery.length ? ( +
+ + 加载中… +
+ ) : !gallery.length ? ( +
暂无上传记录
+ ) : ( + <> +
+ {gallery.map(item => { + const thumb = toPostImageThumbSrc(item.url) || item.url; + const isSel = selected.has(item.url); + return ( + + ); + })} +
+ {galleryPage < galleryTotalPages && ( +
+ +
+ )} + + )} + + + 已选 {selected.size} + + + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/editor/ArticleLinkDialog.tsx b/frontend/src/components/editor/ArticleLinkDialog.tsx index 6df261f..81273c6 100644 --- a/frontend/src/components/editor/ArticleLinkDialog.tsx +++ b/frontend/src/components/editor/ArticleLinkDialog.tsx @@ -1,39 +1,165 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { notify } from '@/lib/notify'; +import { api } from '@/api/client'; +import type { PostItem, SitePageSummary } from '@/api/types'; +import { pagePath, postPath } from '@/utils/permalink'; +import { useForumLimits } from '@/hooks/useForumLimits'; +import { Loader2 } from 'lucide-react'; + +export interface ArticleLinkConfirm { + url: string; + text: string; +} interface Props { open: boolean; onOpenChange: (open: boolean) => void; initialUrl?: string; - onConfirm: (url: string) => void; + initialText?: string; + /** 是否正在编辑已有链接 */ + editing?: boolean; + onConfirm: (payload: ArticleLinkConfirm) => void; onRemove?: () => void; } -/** 文章编辑器链接输入弹窗 */ +type SiteHit = { + key: string; + title: string; + url: string; + kind: 'post' | 'page'; +}; + +/** 校验可插入的链接地址:外链或站内绝对路径 */ +export function isValidLinkHref(url: string): boolean { + const u = url.trim(); + if (!u) return false; + if (u.startsWith('/') && !u.startsWith('//')) return true; + if (u.startsWith('#') && u.length > 1) return true; + try { + const parsed = new URL(u); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +const SEARCH_DEBOUNCE_MS = 300; +const POST_PAGE_SIZE = 12; + +/** 文章/评论编辑器:插入或编辑链接(含站内内容搜索) */ export function ArticleLinkDialog({ open, onOpenChange, initialUrl = '', + initialText = '', + editing = false, onConfirm, onRemove, }: Props) { - const [url, setUrl] = useState(initialUrl); + const { limits } = useForumLimits(); + const [url, setUrl] = useState(''); + const [text, setText] = useState(''); + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [posts, setPosts] = useState([]); + const [pages, setPages] = useState([]); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); useEffect(() => { - if (open) setUrl(initialUrl || 'https://'); - }, [open, initialUrl]); + if (!open) return; + setUrl(initialUrl || ''); + setText(initialText || ''); + setQuery(''); + setDebouncedQuery(''); + }, [open, initialUrl, initialText]); + + useEffect(() => { + if (!open) return; + const t = window.setTimeout(() => setDebouncedQuery(query.trim()), SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(t); + }, [query, open]); + + const loadSiteHits = useCallback(async (keyword: string) => { + setLoading(true); + try { + const [postsRes, pagesRes] = await Promise.all([ + api.posts({ + page: 1, + size: POST_PAGE_SIZE, + sort: 'new', + ...(keyword ? { keyword, title_only: '1' } : {}), + }), + api.pages(), + ]); + setPosts(postsRes.posts || []); + let nextPages = pagesRes.pages || []; + if (keyword) { + const q = keyword.toLowerCase(); + nextPages = nextPages.filter( + p => p.title.toLowerCase().includes(q) || p.slug.toLowerCase().includes(q), + ); + } + setPages(nextPages.slice(0, POST_PAGE_SIZE)); + setLoaded(true); + } catch (e: unknown) { + notify.error(e instanceof Error ? e.message : '加载站内内容失败'); + setLoaded(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!open) return; + void loadSiteHits(debouncedQuery); + }, [open, debouncedQuery, loadSiteHits]); + + const hits: SiteHit[] = useMemo(() => { + const postHits: SiteHit[] = posts.map(p => ({ + key: `post-${p.id}`, + title: p.title, + url: postPath(p.id, limits), + kind: 'post', + })); + const pageHits: SiteHit[] = pages.map(p => ({ + key: `page-${p.slug}`, + title: p.title, + url: pagePath(p.slug, limits), + kind: 'page', + })); + return [...postHits, ...pageHits]; + }, [posts, pages, limits]); + + const pickHit = (hit: SiteHit) => { + setUrl(hit.url); + setText(prev => (prev.trim() ? prev : hit.title)); + }; const handleConfirm = () => { - onConfirm(url.trim()); + const nextUrl = url.trim(); + if (!nextUrl) { + notify.warning('请输入网址'); + return; + } + if (!isValidLinkHref(nextUrl)) { + notify.warning('请使用 http(s) 外链或本站以 / 开头的路径'); + return; + } + onConfirm({ + url: nextUrl, + text: text.trim() || '链接文字', + }); onOpenChange(false); }; @@ -41,25 +167,107 @@ export function ArticleLinkDialog({ - 插入链接 - 输入完整 URL,留空并确认可移除已有链接。 + 插入或编辑链接 - setUrl(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') { - e.preventDefault(); - handleConfirm(); - } - }} - autoFocus - /> + +
+

输入目标 URL

+
+ + setUrl(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + handleConfirm(); + } + }} + autoFocus + /> +
+
+ + setText(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + handleConfirm(); + } + }} + /> +
+
+ +
+

或链接到站点中的内容

+
+ + setQuery(e.target.value)} + /> +
+
+

+ {debouncedQuery + ? `搜索「${debouncedQuery}」` + : '未指定搜索条件。自动显示最近发布条目。'} +

+ {loading && !loaded ? ( +
+ + 加载中… +
+ ) : !hits.length ? ( +
暂无匹配内容
+ ) : ( +
    + {hits.map(hit => ( +
  • + +
  • + ))} +
+ )} + {loading && loaded ? ( +
+ +
+ ) : null} +
+
+ - {onRemove && initialUrl ? ( - ) : null} @@ -67,7 +275,7 @@ export function ArticleLinkDialog({ 取消
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 93a3891..9a8cd9a 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -10956,8 +10956,270 @@ button.profile-stat:hover strong { color: var(--color-text-3); } +.article-link-dialog { + width: min(520px, 92vw); + max-width: min(520px, 92vw); + max-height: min(85vh, 720px); + display: flex; + flex-direction: column; + gap: 0; + overflow-y: auto; +} +.article-link-dialog__section { + display: flex; + flex-direction: column; + gap: 12px; + padding: 4px 0 16px; +} +.article-link-dialog__section + .article-link-dialog__section { + border-top: 1px solid var(--j13-border-light); + padding-top: 16px; +} +.article-link-dialog__section-title { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--color-text-1); +} +.article-link-dialog__field { + display: flex; + flex-direction: column; + gap: 6px; +} +.article-link-dialog__field label { + font-size: 12px; + color: var(--color-text-2); +} +.article-link-dialog__hits { + position: relative; + border: 1px solid var(--j13-border); + border-radius: 10px; + background: var(--j13-bg-block-muted, var(--color-fill-3)); + overflow: hidden; +} +.article-link-dialog__hits-hint { + margin: 0; + padding: 10px 12px 6px; + font-size: 12px; + color: var(--color-text-3); +} +.article-link-dialog__hit-list { + list-style: none; + margin: 0; + padding: 0 0 6px; + max-height: 220px; + overflow-y: auto; +} +.article-link-dialog__hit { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 12px; + border: none; + background: transparent; + text-align: left; + cursor: pointer; + color: var(--color-text-1); + transition: background 0.12s; +} +.article-link-dialog__hit:hover { + background: var(--j13-bg-surface, #fff); +} +.article-link-dialog__hit.is-selected { + background: var(--j13-green-bg); +} +.article-link-dialog__hit-title { + flex: 1; + min-width: 0; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.article-link-dialog__hit-kind { + flex-shrink: 0; + font-size: 11px; + color: var(--color-text-3); + padding: 2px 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--j13-border) 55%, transparent); +} +.article-link-dialog__hits-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 88px; + font-size: 13px; + color: var(--color-text-3); +} +.article-link-dialog__hits-loading { + position: absolute; + top: 10px; + right: 12px; + color: var(--color-text-3); +} +.article-link-dialog__spin { + animation: article-link-dialog-spin 0.8s linear infinite; +} +@keyframes article-link-dialog-spin { + to { transform: rotate(360deg); } +} .article-link-dialog__footer { gap: 8px; + margin-top: 4px; +} +.article-link-dialog__remove { + margin-right: auto; +} + +.article-image-picker { + width: min(520px, 92vw); + max-width: min(520px, 92vw); +} +.article-image-picker__tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; + margin: 4px 0 12px; + padding: 4px; + border: 1px solid var(--j13-border); + border-radius: 10px; + background: var(--j13-bg-block-muted, var(--color-fill-3)); +} +.article-image-picker__tab { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 36px; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--color-text-2); + font-size: 13px; + font-weight: 500; + line-height: 1.2; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s, box-shadow 0.12s; +} +.article-image-picker__tab:hover:not(:disabled) { + background: var(--j13-bg-surface, #fff); + border-color: color-mix(in srgb, var(--j13-green) 22%, var(--j13-border)); + color: var(--color-text-1); +} +.article-image-picker__tab.is-active { + background: var(--j13-bg-surface, #fff); + border-color: color-mix(in srgb, var(--j13-green) 40%, transparent); + color: var(--j13-green-hover, var(--j13-green)); + font-weight: 600; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px color-mix(in srgb, var(--j13-green) 18%, transparent); +} +.article-image-picker__tab:disabled { + opacity: 0.55; + cursor: not-allowed; +} +.article-image-picker__panel { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 160px; +} +.article-image-picker__drop { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 180px; + padding: 24px 16px; + border: 1.5px dashed var(--j13-border); + border-radius: 10px; + background: var(--j13-bg-block-muted, var(--color-fill-3)); + color: var(--color-text-2); + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.article-image-picker__drop p { + margin: 0; + font-size: 14px; + color: var(--color-text-1); +} +.article-image-picker__drop span { + font-size: 12px; + color: var(--color-text-3); +} +.article-image-picker__drop.is-dragover { + border-color: var(--j13-green); + background: var(--j13-green-bg); +} +.article-image-picker__drop.is-busy { + cursor: wait; + pointer-events: none; +} +.article-image-picker__hint { + margin: 0; + font-size: 12px; + color: var(--color-text-3); +} +.article-image-picker__footer { + gap: 8px; + align-items: center; +} +.article-image-picker__selected-count { + margin-right: auto; + font-size: 13px; + color: var(--color-text-2); +} +.article-image-picker__grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + max-height: 280px; + overflow-y: auto; + padding: 2px; +} +.article-image-picker__thumb { + aspect-ratio: 1; + padding: 0; + border: 2px solid var(--j13-border); + border-radius: 8px; + overflow: hidden; + background: var(--j13-bg-block-muted, var(--color-fill-3)); + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; +} +.article-image-picker__thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.article-image-picker__thumb.is-selected { + border-color: var(--j13-green); + box-shadow: 0 0 0 1px var(--j13-green); +} +.article-image-picker__empty { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 140px; + color: var(--color-text-3); + font-size: 13px; +} +.article-image-picker__more { + display: flex; + justify-content: center; +} +.article-image-picker__spin { + animation: article-image-picker-spin 0.8s linear infinite; +} +@keyframes article-image-picker-spin { + to { transform: rotate(360deg); } } /* 代码块插入弹窗 */ diff --git a/frontend/src/utils/markdownFormat.ts b/frontend/src/utils/markdownFormat.ts index 103e8fd..979bfdb 100644 --- a/frontend/src/utils/markdownFormat.ts +++ b/frontend/src/utils/markdownFormat.ts @@ -122,9 +122,12 @@ export function insertMarkdownLink( value: string, url: string, onChange: ChangeHandler, + opts?: { text?: string }, ) { const { selectionStart, selectionEnd } = textarea; - const selected = value.slice(selectionStart, selectionEnd) || '链接文字'; + const selected = opts?.text?.trim() + || value.slice(selectionStart, selectionEnd) + || '链接文字'; const insert = `[${selected}](${url})`; const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd); applyTextareaChange( diff --git a/handler/media.go b/handler/media.go index 0fcf320..65e2afc 100644 --- a/handler/media.go +++ b/handler/media.go @@ -8,6 +8,27 @@ import ( "github.com/gin-gonic/gin" ) +// APIMyPostImages 当前用户已上传的帖子图片列表 +func (h *Handlers) APIMyPostImages(c *gin.Context) { + if h.Store == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "上传存储未初始化"}) + return + } + uid := h.currentUserID(c) + if uid == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "24")) + result, err := h.Store.ListUserPostImages(uid, page, size) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} + // APIAdminMedia 列出媒体资源 func (h *Handlers) APIAdminMedia(c *gin.Context) { if h.Store == nil { diff --git a/router/router.go b/router/router.go index 0564f26..ac7094f 100644 --- a/router/router.go +++ b/router/router.go @@ -177,6 +177,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { api.POST("/profile/password", h.APIUpdatePassword) api.POST("/profile/avatar", h.APIUploadAvatar) api.POST("/uploads/image", h.APIUploadPostImage) + api.GET("/uploads/images", h.APIMyPostImages) api.POST("/posts", middleware.RateLimitMiddleware(limiter, "post"), h.APICreatePost) api.PUT("/posts/:id", h.APIUpdatePost) api.DELETE("/posts/:id", h.APIDeletePost) diff --git a/service/media.go b/service/media.go index 06156e4..125bafa 100644 --- a/service/media.go +++ b/service/media.go @@ -154,6 +154,122 @@ func (s *UploadStore) ListMedia(category, query string, page, size int) (*MediaL }, nil } +// ListUserPostImages 列出当前用户历史上传的帖子图片(category=posts) +func (s *UploadStore) ListUserPostImages(userID uint, page, size int) (*MediaListResult, error) { + if s == nil { + return nil, errors.New("上传存储未初始化") + } + if model.DB == nil { + return nil, errors.New("数据库未初始化") + } + if userID == 0 { + return nil, errors.New("未登录") + } + if page < 1 { + page = 1 + } + if size < 1 { + size = 24 + } + if size > 100 { + size = 100 + } + + var records []model.Media + if err := model.DB.Where("category = ? AND user_id = ?", UploadCategoryPosts, userID). + Order("created_at desc, id desc"). + Find(&records).Error; err != nil { + return nil, err + } + + // 上传会登记原图 + WebP;图库按 stem 去重,优先展示 WebP(与插入 URL 一致) + records = dedupePostMediaPreferWebP(records) + + total := len(records) + totalPages := 1 + if total > 0 { + totalPages = (total + size - 1) / size + } + if page > totalPages { + page = totalPages + } + start := (page - 1) * size + if start > total { + start = total + } + end := start + size + if end > total { + end = total + } + pageRecords := records[start:end] + + files := make([]MediaItem, 0, len(pageRecords)) + for _, r := range pageRecords { + mod := r.UpdatedAt + if mod.IsZero() { + mod = r.CreatedAt + } + files = append(files, MediaItem{ + Category: r.Category, + Name: r.Name, + URL: r.URL, + Size: r.Size, + ModifiedAt: mod.UTC(), + ContentType: r.ContentType, + StorageType: r.StorageType, + }) + } + + mode, _, _, _ := s.snapshot() + storageType := config.StorageTypeLocal + if mode == config.StorageTypeS3 { + storageType = config.StorageTypeS3 + } + + return &MediaListResult{ + Files: files, + Total: total, + Page: page, + TotalPages: totalPages, + StorageType: storageType, + CategoryCounts: map[string]int{UploadCategoryPosts: total}, + }, nil +} + +// dedupePostMediaPreferWebP 同一上传的原图/WebP 只保留一条,优先 WebP +func dedupePostMediaPreferWebP(records []model.Media) []model.Media { + type slot struct { + idx int + isWebP bool + } + seen := map[string]slot{} + out := make([]model.Media, 0, len(records)) + for _, r := range records { + stem := mediaFileStem(r.Name) + isWebP := strings.EqualFold(filepath.Ext(r.Name), ".webp") || + strings.EqualFold(r.ContentType, "image/webp") + if s, ok := seen[stem]; ok { + if isWebP && !s.isWebP { + out[s.idx] = r + seen[stem] = slot{idx: s.idx, isWebP: true} + } + continue + } + seen[stem] = slot{idx: len(out), isWebP: isWebP} + out = append(out, r) + } + return out +} + +func mediaFileStem(name string) string { + name = filepath.Base(strings.TrimSpace(name)) + ext := filepath.Ext(name) + if ext == "" { + return strings.ToLower(name) + } + return strings.ToLower(strings.TrimSuffix(name, ext)) +} + // DeleteMedia 按 URL 批量删除媒体(含伴生扩展名与数据库索引) func (s *UploadStore) DeleteMedia(urls []string) (int, error) { if s == nil {