feat: 登录设备管理与编辑器图片上传
- 新增登录设备列表与会话下线(ListLoginDevices/RevokeLoginDevice),按设备指纹识别当前会话 - Markdown 编辑器新增图片插入弹窗与图片上传(ImageInsertModal) - 实时总线、账号守卫与用户事件适配
This commit is contained in:
@@ -2,77 +2,160 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Ban } from "lucide-react";
|
||||
import { Ban, LogOut } from "lucide-react";
|
||||
import { onForceLogout } from "@/lib/userEvents";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
import { apiMe } from "@/lib/api";
|
||||
import { realtime, RT_SESSION_REPLACED } from "@/lib/realtime";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
/**
|
||||
* 账号安全守卫:监听全局强制下线事件,被封禁时弹出不可误关的告知模态。
|
||||
* 账号安全守卫:监听全局强制下线事件,弹出不可误关的告知模态。
|
||||
*
|
||||
* 两类告知:
|
||||
* - 账号被封禁(reason=banned):管理员封禁后由 HTTP 拦截链路派发;
|
||||
* - 登录已失效(reason=session_replaced):其他浏览器登录顶掉旧会话、
|
||||
* 安全设置剔除本设备等场景,旧浏览器当场弹窗提示,而非等硬刷新才发现。
|
||||
*
|
||||
* 登录态被顶的感知入口(探测统一收敛在 lib/api 的 apiMe 判定):
|
||||
* 1) 后端 WS 即时事件 session:replaced(旧设备 WS 在线时秒级感知);
|
||||
* 2) WS 心跳复查失败 4401(realtime 内部转一次 /api/me 探测);
|
||||
* 3) 回前台对账:后台标签页错过 WS 事件,切回时经 /api/me 校正。
|
||||
*
|
||||
* 登录态清理(cookie/Header 游客化/router.refresh)由 Header 复用统一
|
||||
* 登出链路完成;本组件只负责"告知"这一职责,故不处理任何凭据逻辑。
|
||||
* 登出链路完成;本组件只负责"告知",故不处理任何凭据逻辑。
|
||||
* 挂载于 root layout,覆盖任意页面(含后台)。
|
||||
*/
|
||||
export default function AccountGuard() {
|
||||
const router = useRouter();
|
||||
const [banned, setBanned] = useState(false);
|
||||
const [sessionEnded, setSessionEnded] = useState(false);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
onForceLogout((reason) => {
|
||||
if (reason === "banned") setBanned(true);
|
||||
else if (reason === "session_replaced") setSessionEnded(true);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// 登录态被顶:后端 WS 即时事件 → 探测确认 → 派发 session_replaced
|
||||
useEffect(
|
||||
() => realtime.on(RT_SESSION_REPLACED, () => void apiMe().catch(() => {})),
|
||||
[]
|
||||
);
|
||||
|
||||
// 回前台对账(10s 限频):覆盖后台标签页错过 WS 事件的场景。
|
||||
// 仅当存在登录 cookie 痕迹时探测,游客不白发请求
|
||||
useEffect(
|
||||
() =>
|
||||
onDocumentVisible(() => {
|
||||
if (hasAuthCookieHint()) void apiMe().catch(() => {});
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={banned}
|
||||
title="账号已被封禁"
|
||||
onClose={() => {}}
|
||||
hideClose
|
||||
closeOnEscape={false}
|
||||
role="alertdialog"
|
||||
ariaLabel="账号已被封禁"
|
||||
maxWidthClass="max-w-[400px]"
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBanned(false);
|
||||
router.push("/");
|
||||
}}
|
||||
className="btn btn-primary w-full"
|
||||
autoFocus
|
||||
>
|
||||
我知道了
|
||||
</button>
|
||||
}
|
||||
header={
|
||||
<div className="w-full text-center">
|
||||
<span
|
||||
className="mx-auto w-14 h-14 rounded-full flex items-center justify-center"
|
||||
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
|
||||
<>
|
||||
<Modal
|
||||
open={banned}
|
||||
title="账号已被封禁"
|
||||
onClose={() => {}}
|
||||
hideClose
|
||||
closeOnEscape={false}
|
||||
role="alertdialog"
|
||||
ariaLabel="账号已被封禁"
|
||||
maxWidthClass="max-w-[400px]"
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBanned(false);
|
||||
router.push("/");
|
||||
}}
|
||||
className="btn btn-primary w-full"
|
||||
autoFocus
|
||||
>
|
||||
<Ban size={26} />
|
||||
</span>
|
||||
<h2
|
||||
id="banned-dialog-title"
|
||||
className="mt-4 text-[18px] font-extrabold tracking-tight"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
账号已被封禁
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p
|
||||
id="banned-dialog-desc"
|
||||
className="meta text-[13px] leading-relaxed text-center"
|
||||
我知道了
|
||||
</button>
|
||||
}
|
||||
header={
|
||||
<div className="w-full text-center">
|
||||
<span
|
||||
className="mx-auto w-14 h-14 rounded-full flex items-center justify-center"
|
||||
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
|
||||
>
|
||||
<Ban size={26} />
|
||||
</span>
|
||||
<h2
|
||||
id="banned-dialog-title"
|
||||
className="mt-4 text-[18px] font-extrabold tracking-tight"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
账号已被封禁
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
你的账号因违反社区规则已被管理员封禁,当前登录状态已失效,无法继续发布内容与互动。
|
||||
如对处理结果有异议,请联系管理员核实。
|
||||
</p>
|
||||
</Modal>
|
||||
<p
|
||||
id="banned-dialog-desc"
|
||||
className="meta text-[13px] leading-relaxed text-center"
|
||||
>
|
||||
你的账号因违反社区规则已被管理员封禁,当前登录状态已失效,无法继续发布内容与互动。
|
||||
如对处理结果有异议,请联系管理员核实。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={sessionEnded}
|
||||
title="登录已失效"
|
||||
onClose={() => {}}
|
||||
hideClose
|
||||
closeOnEscape={false}
|
||||
role="alertdialog"
|
||||
ariaLabel="登录已失效"
|
||||
maxWidthClass="max-w-[400px]"
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSessionEnded(false);
|
||||
router.push("/login");
|
||||
}}
|
||||
className="btn btn-primary w-full"
|
||||
autoFocus
|
||||
>
|
||||
重新登录
|
||||
</button>
|
||||
}
|
||||
header={
|
||||
<div className="w-full text-center">
|
||||
<span
|
||||
className="mx-auto w-14 h-14 rounded-full flex items-center justify-center"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
<LogOut size={26} />
|
||||
</span>
|
||||
<h2
|
||||
id="session-ended-dialog-title"
|
||||
className="mt-4 text-[18px] font-extrabold tracking-tight"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
登录已失效
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p
|
||||
id="session-ended-dialog-desc"
|
||||
className="meta text-[13px] leading-relaxed text-center"
|
||||
>
|
||||
你的账号已在其他浏览器登录,或登录会话已被移除,当前设备的登录状态已失效。
|
||||
请重新登录后继续使用。
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
235
frontend/components/ImageInsertModal.tsx
Normal file
235
frontend/components/ImageInsertModal.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Images, Link2, Loader2, Upload } from "lucide-react";
|
||||
import Modal from "@/components/Modal";
|
||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||
|
||||
type InsertTab = "upload" | "url" | "media";
|
||||
|
||||
/**
|
||||
* 编辑器插图统一弹窗:上传本地文件 / 图片链接 / 从本人媒体库选择。
|
||||
* 上传与插入逻辑由父组件处理(需要拿到正文光标),这里只负责选择与展示。
|
||||
*/
|
||||
export default function ImageInsertModal({
|
||||
open,
|
||||
onClose,
|
||||
uploading,
|
||||
uploadError,
|
||||
onChooseFile,
|
||||
onDropFile,
|
||||
onInsertUrl,
|
||||
onPickMedia,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 图片上传进行中(来自编辑器,禁用操作并展示进度) */
|
||||
uploading: boolean;
|
||||
/** 上传失败的提示(也可重试),来自编辑器底部横幅同一状态 */
|
||||
uploadError: string;
|
||||
/** 打开系统文件选择框(选择后走编辑器上传流程) */
|
||||
onChooseFile: () => void;
|
||||
/** 拖拽图片到上传区 */
|
||||
onDropFile: (file: File) => void;
|
||||
/** 链接 tab 提交;校验失败由父组件 toast 并保持弹窗 */
|
||||
onInsertUrl: (url: string, alt: string) => void;
|
||||
/** 媒体库选中一张图片 */
|
||||
onPickMedia: (item: MediaAttachment) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<InsertTab>("upload");
|
||||
const [url, setUrl] = useState("https://");
|
||||
const [alt, setAlt] = useState("图片");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [media, setMedia] = useState<MediaAttachment[]>([]);
|
||||
const [mediaLoading, setMediaLoading] = useState(false);
|
||||
const [mediaError, setMediaError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTab("upload");
|
||||
setUrl("https://");
|
||||
setAlt("图片");
|
||||
setDragOver(false);
|
||||
}, [open]);
|
||||
|
||||
// 切到「我的图片」时拉取本人媒体库(每次进入都刷新,与上传后可见最新一致)
|
||||
useEffect(() => {
|
||||
if (!open || tab !== "media") return;
|
||||
let cancelled = false;
|
||||
setMediaLoading(true);
|
||||
setMediaError("");
|
||||
void apiMyMedia()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.error) {
|
||||
setMediaError(res.error);
|
||||
setMedia([]);
|
||||
return;
|
||||
}
|
||||
setMedia((res.attachments || []).filter((item) => item.kind === "image"));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setMediaError("加载媒体库失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setMediaLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, tab]);
|
||||
|
||||
const tabs: { key: InsertTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: "upload", label: "上传", icon: <Upload size={13} /> },
|
||||
{ key: "url", label: "链接", icon: <Link2 size={13} /> },
|
||||
{ key: "media", label: "我的图片", icon: <Images size={13} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="插入图片"
|
||||
description="上传本地文件、粘贴链接,或从已上传库中选择。"
|
||||
onClose={onClose}
|
||||
maxWidthClass="max-w-lg"
|
||||
dismissOnOverlay
|
||||
>
|
||||
<div className="j13-md-mode j13-img-tabs" role="tablist" aria-label="插图方式">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.key}
|
||||
className={tab === t.key ? "is-on" : ""}
|
||||
disabled={uploading}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.icon} {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "upload" && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
className={`j13-img-dropzone${dragOver ? " is-dragover" : ""}`}
|
||||
disabled={uploading}
|
||||
onClick={onChooseFile}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (uploading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) onDropFile(file);
|
||||
}}
|
||||
>
|
||||
<Upload size={22} aria-hidden />
|
||||
<span className="j13-img-dropzone-title">
|
||||
{uploading ? "图片上传中…" : "拖拽图片到此处,或点击选择文件"}
|
||||
</span>
|
||||
<span className="j13-img-dropzone-hint">支持 JPEG / PNG / WebP</span>
|
||||
</button>
|
||||
{uploadError && !uploading ? (
|
||||
<p className="j13-img-modal-error" role="alert">
|
||||
{uploadError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "url" && (
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<label className="j13-md-field">
|
||||
<span>说明</span>
|
||||
<input
|
||||
value={alt}
|
||||
onChange={(e) => setAlt(e.target.value)}
|
||||
className="j13-md-popover-input"
|
||||
/>
|
||||
</label>
|
||||
<label className="j13-md-field">
|
||||
<span>URL</span>
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="j13-md-popover-input"
|
||||
placeholder="https://…"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onInsertUrl(url, alt);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={uploading}
|
||||
onClick={() => onInsertUrl(url, alt)}
|
||||
>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "media" && (
|
||||
<div className="mt-4">
|
||||
{mediaLoading ? (
|
||||
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
|
||||
<Loader2 size={14} className="animate-spin" /> 加载中
|
||||
</p>
|
||||
) : mediaError ? (
|
||||
<p className="j13-img-modal-error">{mediaError}</p>
|
||||
) : media.length === 0 ? (
|
||||
<p className="meta text-[13px] py-6 text-center">
|
||||
媒体库还没有图片。发帖时上传或粘贴图片后,会自动出现在这里。
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[46vh] overflow-y-auto">
|
||||
{media.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPickMedia(item);
|
||||
onClose();
|
||||
}}
|
||||
className="relative group aspect-square rounded-xl overflow-hidden"
|
||||
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
|
||||
data-tip={`点击插入 · ${item.width}×${item.height}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.url}
|
||||
alt="媒体库图片"
|
||||
className="w-full h-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--ink) 40%, transparent)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
<Images size={14} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -30,14 +30,13 @@ import {
|
||||
Minimize2,
|
||||
Lock,
|
||||
ChevronDown,
|
||||
Upload,
|
||||
Link2,
|
||||
History,
|
||||
Ellipsis,
|
||||
} from "lucide-react";
|
||||
import { apiUploadPostImage } from "@/lib/api";
|
||||
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||
import ImageInsertModal from "@/components/ImageInsertModal";
|
||||
import TimelineEditor from "@/components/TimelineEditor";
|
||||
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
|
||||
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
|
||||
@@ -87,7 +86,7 @@ type WrapOpts = {
|
||||
linePrefix?: string;
|
||||
};
|
||||
|
||||
type PopoverKind = "link" | "imageUrl" | "code" | "hide" | "heading" | "image" | "more" | null;
|
||||
type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | null;
|
||||
|
||||
const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const;
|
||||
|
||||
@@ -128,6 +127,14 @@ function isHttpUrl(raw: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** 链接弹窗允许的目标:http(s) 完整链接,或站内相对路径(如 /post/123) */
|
||||
function isLinkTargetUrl(raw: string): boolean {
|
||||
const u = raw.trim();
|
||||
if (!u) return false;
|
||||
if (isHttpUrl(u)) return true;
|
||||
return u.startsWith("/") && !u.startsWith("//");
|
||||
}
|
||||
|
||||
export default function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
@@ -160,8 +167,12 @@ export default function MarkdownEditor({
|
||||
const [popover, setPopover] = useState<PopoverKind>(null);
|
||||
const [linkText, setLinkText] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState("https://");
|
||||
const [imageUrl, setImageUrl] = useState("https://");
|
||||
const [imageAlt, setImageAlt] = useState("图片");
|
||||
// 链接弹窗:站内帖子搜索(空关键词=最近发布),折叠在「高级选项」内
|
||||
const [postQuery, setPostQuery] = useState("");
|
||||
const [postResults, setPostResults] = useState<PostListItem[]>([]);
|
||||
const [postLoading, setPostLoading] = useState(false);
|
||||
const [linkAdvanced, setLinkAdvanced] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [codeQuery, setCodeQuery] = useState("");
|
||||
const [hidePoints, setHidePoints] = useState(10);
|
||||
const [hidePassword, setHidePassword] = useState("");
|
||||
@@ -272,6 +283,8 @@ export default function MarkdownEditor({
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
// 插图弹窗自管 ESC,不连退全屏
|
||||
if (imageModalOpen) return;
|
||||
if (popover) setPopover(null);
|
||||
else {
|
||||
rememberCaret();
|
||||
@@ -286,7 +299,7 @@ export default function MarkdownEditor({
|
||||
document.body.style.overflow = prev;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [fullscreen, popover]);
|
||||
}, [fullscreen, popover, imageModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!popover) return;
|
||||
@@ -311,6 +324,31 @@ export default function MarkdownEditor({
|
||||
};
|
||||
}, [popover]);
|
||||
|
||||
// 链接弹窗打开且展开高级选项时,搜索站内帖子(输入防抖;空关键词给最近发布)
|
||||
useEffect(() => {
|
||||
if (popover !== "link" || !linkAdvanced) return;
|
||||
let cancelled = false;
|
||||
const kw = postQuery.trim();
|
||||
setPostLoading(true);
|
||||
const t = setTimeout(() => {
|
||||
void apiSearchPosts(kw, 8)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setPostResults(res.posts || []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPostResults([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPostLoading(false);
|
||||
});
|
||||
}, kw ? 250 : 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [popover, postQuery, linkAdvanced]);
|
||||
|
||||
const rememberSel = () => {
|
||||
const el = taRef.current;
|
||||
if (!el) return;
|
||||
@@ -401,6 +439,7 @@ export default function MarkdownEditor({
|
||||
setMode("write");
|
||||
setUploadError("");
|
||||
retryFile.current = null;
|
||||
setImageModalOpen(false);
|
||||
toast("图片已插入", "ok");
|
||||
} catch {
|
||||
setUploadError("上传失败");
|
||||
@@ -418,10 +457,9 @@ export default function MarkdownEditor({
|
||||
const selected = el ? value.slice(el.selectionStart, el.selectionEnd) : "";
|
||||
setLinkText(selected || "链接文字");
|
||||
setLinkUrl("https://");
|
||||
}
|
||||
if (kind === "imageUrl") {
|
||||
setImageUrl("https://");
|
||||
setImageAlt("图片");
|
||||
setPostQuery("");
|
||||
setPostResults([]);
|
||||
setLinkAdvanced(false);
|
||||
}
|
||||
if (kind === "code") setCodeQuery("");
|
||||
if (kind === "hide") {
|
||||
@@ -432,10 +470,16 @@ export default function MarkdownEditor({
|
||||
setPopover((prev) => (prev === kind ? null : kind));
|
||||
};
|
||||
|
||||
/** 选中站内帖子:回填 URL 与文字,用户可再编辑后点「插入」 */
|
||||
const pickPostLink = (p: PostListItem) => {
|
||||
setLinkUrl(`/post/${p.id}`);
|
||||
setLinkText(p.title);
|
||||
};
|
||||
|
||||
const applyLink = () => {
|
||||
const url = linkUrl.trim();
|
||||
if (!isHttpUrl(url)) {
|
||||
toast("请输入有效的 http(s) 链接", "error");
|
||||
if (!isLinkTargetUrl(url)) {
|
||||
toast("请输入 http(s) 链接或站内路径(如 /post/123)", "error");
|
||||
return;
|
||||
}
|
||||
const el = taRef.current;
|
||||
@@ -455,15 +499,22 @@ export default function MarkdownEditor({
|
||||
setPopover(null);
|
||||
};
|
||||
|
||||
const applyImageUrl = () => {
|
||||
const url = imageUrl.trim();
|
||||
/** 打开插图弹窗(上传 / 链接 / 媒体库) */
|
||||
const openImageModal = () => {
|
||||
rememberSel();
|
||||
setImageModalOpen(true);
|
||||
};
|
||||
|
||||
/** 链接 tab 提交:校验后在光标处插入,失败 toast 并留在弹窗 */
|
||||
const insertImageUrl = (rawUrl: string, rawAlt: string) => {
|
||||
const url = rawUrl.trim();
|
||||
if (!isHttpUrl(url)) {
|
||||
toast("请输入有效的图片 URL(http/https)", "error");
|
||||
return;
|
||||
}
|
||||
const alt = imageAlt.trim() || "图片";
|
||||
const alt = rawAlt.trim() || "图片";
|
||||
insertAtCursor(``);
|
||||
setPopover(null);
|
||||
setImageModalOpen(false);
|
||||
};
|
||||
|
||||
const insertCodeBlock = (lang: string) => {
|
||||
@@ -858,6 +909,7 @@ export default function MarkdownEditor({
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
className="j13-md-popover-input"
|
||||
placeholder="https://… 或 /post/123"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -866,6 +918,50 @@ export default function MarkdownEditor({
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="j13-md-adv-toggle"
|
||||
aria-expanded={linkAdvanced}
|
||||
onClick={() => setLinkAdvanced((v) => !v)}
|
||||
>
|
||||
高级选项
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={`transition-transform${linkAdvanced ? " rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{linkAdvanced && (
|
||||
<div className="j13-md-link-search">
|
||||
<input
|
||||
className="j13-md-popover-input"
|
||||
placeholder="搜索帖子…"
|
||||
value={postQuery}
|
||||
onChange={(e) => setPostQuery(e.target.value)}
|
||||
/>
|
||||
<div className="j13-md-popover-list">
|
||||
{postLoading ? (
|
||||
<p className="meta px-2 py-3 text-center">搜索中…</p>
|
||||
) : postResults.length === 0 ? (
|
||||
<p className="meta px-2 py-3 text-center">
|
||||
{postQuery.trim() ? "无匹配帖子" : "暂无最近帖子"}
|
||||
</p>
|
||||
) : (
|
||||
postResults.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="j13-md-post-row"
|
||||
data-tip={p.title}
|
||||
onClick={() => pickPostLink(p)}
|
||||
>
|
||||
<span className="truncate flex-1">{p.title}</span>
|
||||
<span className="j13-md-post-tag">帖子</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="j13-md-popover-footer">
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
|
||||
取消
|
||||
@@ -881,67 +977,10 @@ export default function MarkdownEditor({
|
||||
<ToolBtn
|
||||
label={uploading ? "上传中…" : "图片"}
|
||||
icon={<ImageIcon size={15} />}
|
||||
active={popover === "image" || popover === "imageUrl"}
|
||||
active={imageModalOpen}
|
||||
disabled={uploading}
|
||||
onClick={() => openPopover("image")}
|
||||
onClick={openImageModal}
|
||||
/>
|
||||
{popover === "image" && (
|
||||
<div className="j13-md-popover" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={uploading}
|
||||
onClick={() => {
|
||||
setPopover(null);
|
||||
fileRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Upload size={14} /> 上传图片
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => openPopover("imageUrl")}
|
||||
>
|
||||
<Link2 size={14} /> 图片地址
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{popover === "imageUrl" && (
|
||||
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入图片 URL">
|
||||
<label className="j13-md-field">
|
||||
<span>说明</span>
|
||||
<input
|
||||
value={imageAlt}
|
||||
onChange={(e) => setImageAlt(e.target.value)}
|
||||
className="j13-md-popover-input"
|
||||
/>
|
||||
</label>
|
||||
<label className="j13-md-field">
|
||||
<span>URL</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
className="j13-md-popover-input"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
applyImageUrl();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="j13-md-popover-footer">
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={applyImageUrl}>
|
||||
插入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isComment && (
|
||||
<div className="j13-md-popover-anchor relative">
|
||||
@@ -1325,6 +1364,17 @@ export default function MarkdownEditor({
|
||||
}}
|
||||
onInsert={applyTimelineBlock}
|
||||
/>
|
||||
|
||||
<ImageInsertModal
|
||||
open={imageModalOpen}
|
||||
onClose={() => setImageModalOpen(false)}
|
||||
uploading={uploading}
|
||||
uploadError={uploadError}
|
||||
onChooseFile={() => fileRef.current?.click()}
|
||||
onDropFile={(file) => void uploadImageFile(file)}
|
||||
onInsertUrl={insertImageUrl}
|
||||
onPickMedia={(item) => insertAtCursor(``)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -202,7 +202,10 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
||||
const host = hostRef.current;
|
||||
const pre = host?.querySelector("pre") ?? null;
|
||||
setLineCount(countLines(pre, plainText));
|
||||
setLanguage(readLanguage(pre, className, dataLanguage));
|
||||
// clientHtml 替换后的 shiki pre 不带 data-language / language- class,
|
||||
// 读不到语言时保留原值,避免标签从真实语言回退成 "text"
|
||||
const next = readLanguage(pre, className, dataLanguage);
|
||||
setLanguage((prev) => next || prev);
|
||||
setFoldReady(true);
|
||||
}, [plainText, className, dataLanguage, clientHtml]);
|
||||
|
||||
@@ -222,7 +225,11 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
||||
// SSR 未真正着色(如自定义语言 loadLanguage 失败回退)时,客户端补双主题
|
||||
useEffect(() => {
|
||||
if (serverHighlighted || !plainText.trim()) return;
|
||||
const lang = readLanguage(null, className, dataLanguage) || "plaintext";
|
||||
// react-markdown 把 language-xxx 放在子 code 上,el=null 读不到,
|
||||
// 必须从挂载后的 DOM 取语言,否则永远按 plaintext 补色(代码块无色)
|
||||
const lang =
|
||||
readLanguage(hostRef.current?.querySelector("pre") ?? null, className, dataLanguage) ||
|
||||
"plaintext";
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user