feat: 登录设备管理与编辑器图片上传

- 新增登录设备列表与会话下线(ListLoginDevices/RevokeLoginDevice),按设备指纹识别当前会话
- Markdown 编辑器新增图片插入弹窗与图片上传(ImageInsertModal)
- 实时总线、账号守卫与用户事件适配
This commit is contained in:
2026-09-26 01:10:10 +08:00
parent 5270fee2b7
commit 31f5360c26
12 changed files with 706 additions and 165 deletions

View File

@@ -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(`![${alt}](${url})`);
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(`![图片](${item.url})`)}
/>
</div>
);
}