feat: 增强编辑器链接对话框并统一图片插入选择器

链接支持网址/文字与站内搜索,新标签由全站设置控制;文章与评论共用已上传图片选择器。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 21:22:03 +08:00
parent 2159280af5
commit d5aa36416c
10 changed files with 1195 additions and 128 deletions

View File

@@ -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<PostItem[]>([]);
const [pages, setPages] = useState<SitePageSummary[]>([]);
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({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="article-link-dialog">
<DialogHeader>
<DialogTitle>插入链接</DialogTitle>
<DialogDescription>输入完整 URL,留空并确认可移除已有链接。</DialogDescription>
<DialogTitle>插入或编辑链接</DialogTitle>
</DialogHeader>
<Input
type="url"
value={url}
placeholder="https://"
onChange={e => setUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
autoFocus
/>
<section className="article-link-dialog__section">
<h3 className="article-link-dialog__section-title">输入目标 URL</h3>
<div className="article-link-dialog__field">
<Label htmlFor="article-link-url">网址</Label>
<Input
id="article-link-url"
type="url"
value={url}
placeholder="https://… 或 /post/123"
onChange={e => setUrl(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
autoFocus
/>
</div>
<div className="article-link-dialog__field">
<Label htmlFor="article-link-text">链接文字</Label>
<Input
id="article-link-text"
value={text}
placeholder="显示在正文中的文字"
onChange={e => setText(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
/>
</div>
</section>
<section className="article-link-dialog__section">
<h3 className="article-link-dialog__section-title">或链接到站点中的内容</h3>
<div className="article-link-dialog__field">
<Label htmlFor="article-link-search">搜索</Label>
<Input
id="article-link-search"
type="search"
value={query}
placeholder="搜索帖子或单页…"
onChange={e => setQuery(e.target.value)}
/>
</div>
<div className="article-link-dialog__hits">
<p className="article-link-dialog__hits-hint">
{debouncedQuery
? `搜索「${debouncedQuery}」`
: '未指定搜索条件。自动显示最近发布条目。'}
</p>
{loading && !loaded ? (
<div className="article-link-dialog__hits-empty">
<Loader2 size={18} className="article-link-dialog__spin" />
<span>加载中…</span>
</div>
) : !hits.length ? (
<div className="article-link-dialog__hits-empty">暂无匹配内容</div>
) : (
<ul className="article-link-dialog__hit-list">
{hits.map(hit => (
<li key={hit.key}>
<button
type="button"
className={`article-link-dialog__hit${url === hit.url ? ' is-selected' : ''}`}
onClick={() => pickHit(hit)}
>
<span className="article-link-dialog__hit-title">{hit.title}</span>
<span className="article-link-dialog__hit-kind">
{hit.kind === 'post' ? '帖子' : '单页'}
</span>
</button>
</li>
))}
</ul>
)}
{loading && loaded ? (
<div className="article-link-dialog__hits-loading">
<Loader2 size={14} className="article-link-dialog__spin" />
</div>
) : null}
</div>
</section>
<DialogFooter className="article-link-dialog__footer">
{onRemove && initialUrl ? (
<Button type="button" variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
{editing && onRemove ? (
<Button
type="button"
variant="outline"
className="article-link-dialog__remove"
onClick={() => {
onRemove();
onOpenChange(false);
}}
>
移除链接
</Button>
) : null}
@@ -67,7 +275,7 @@ export function ArticleLinkDialog({
取消
</Button>
<Button type="button" onClick={handleConfirm}>
确定
{editing ? '更新链接' : '添加链接'}
</Button>
</DialogFooter>
</DialogContent>