From 31f5360c263cc0ec030d3f3b4fcff5ddc6f00d3b Mon Sep 17 00:00:00 2001 From: freefire Date: Sat, 26 Sep 2026 01:10:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=99=BB=E5=BD=95=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8E=E7=BC=96=E8=BE=91=E5=99=A8=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增登录设备列表与会话下线(ListLoginDevices/RevokeLoginDevice),按设备指纹识别当前会话 - Markdown 编辑器新增图片插入弹窗与图片上传(ImageInsertModal) - 实时总线、账号守卫与用户事件适配 --- backend/handler/auth.go | 20 +- backend/realtime/hub.go | 1 + backend/service/auth.go | 34 +-- backend/service/login_device.go | 21 +- frontend/app/globals.css | 94 ++++++++ frontend/components/AccountGuard.tsx | 181 ++++++++++---- frontend/components/ImageInsertModal.tsx | 235 +++++++++++++++++++ frontend/components/MarkdownEditor.tsx | 202 ++++++++++------ frontend/components/markdown/MdCodeBlock.tsx | 11 +- frontend/lib/api.ts | 46 +++- frontend/lib/realtime.ts | 14 +- frontend/lib/userEvents.ts | 12 +- 12 files changed, 706 insertions(+), 165 deletions(-) create mode 100644 frontend/components/ImageInsertModal.tsx diff --git a/backend/handler/auth.go b/backend/handler/auth.go index 7869f38..5e512bb 100644 --- a/backend/handler/auth.go +++ b/backend/handler/auth.go @@ -9,6 +9,7 @@ import ( "github.com/freefire/jiang13-bbs/middleware" "github.com/freefire/jiang13-bbs/model" + "github.com/freefire/jiang13-bbs/realtime" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) @@ -153,8 +154,8 @@ func (h *Handlers) Register(c *gin.Context) { return } _ = h.Chat.EnsureDefaultMembership(user.ID) - // 自动登录:走与 Login 相同的 token 签发链路 - accessToken, refreshToken, loginUser, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent()) + // 自动登录:走与 Login 相同的 token 签发链路(新注册无旧会话,不会有踢出广播) + accessToken, refreshToken, loginUser, _, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent()) if err != nil { // 签发失败不应让注册白做:仍返回成功,让前端引导去登录页 c.JSON(http.StatusOK, gin.H{ @@ -191,7 +192,7 @@ func (h *Handlers) Login(c *gin.Context) { if !h.quotaResponse(c, wait, e) { return } - accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent()) + accessToken, refreshToken, user, kicked, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent()) if err != nil { h.Ops.RecordFailure(req.Username, c.ClientIP(), cfg) // 登录失败也留痕:用户存在时带 user_id(封禁/错密),不存在时为 0 @@ -212,6 +213,14 @@ func (h *Handlers) Login(c *gin.Context) { // dev 模式不设 Secure,生产环境需 HTTPS h.Ops.ClearFailure(req.Username) setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode) + // 真·单会话:本次登录顶掉了所有旧会话,即时广播告知被踢设备(旧浏览器 WS 在线则 + // 当场弹"登录已失效",无需等 access token 过期或硬刷新才发现) + if kicked > 0 { + h.Hub.BroadcastUser(user.ID, realtime.Envelope{ + Type: realtime.EventSessionReplaced, + Data: gin.H{"reason": "new_login"}, + }) + } c.JSON(http.StatusOK, gin.H{ "user": gin.H{ "id": user.ID, @@ -338,6 +347,11 @@ func (h *Handlers) RevokeLoginDevice(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "剔除设备失败"}) return } + // 被剔除设备即时感知:WS 在线则当场弹"登录已失效",无需等 token 失效 + h.Hub.BroadcastUser(claims.ID, realtime.Envelope{ + Type: realtime.EventSessionReplaced, + Data: gin.H{"reason": "device_revoked"}, + }) c.JSON(http.StatusOK, gin.H{"ok": true}) } diff --git a/backend/realtime/hub.go b/backend/realtime/hub.go index 2890020..f183124 100644 --- a/backend/realtime/hub.go +++ b/backend/realtime/hub.go @@ -29,6 +29,7 @@ const ( EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID} EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标) EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标) + EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录) ) // RoomStaff 管理团队房间(板块管理员及以上) diff --git a/backend/service/auth.go b/backend/service/auth.go index de090b5..3adc6eb 100644 --- a/backend/service/auth.go +++ b/backend/service/auth.go @@ -132,31 +132,32 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e return &user, nil } -// Login 用户登录,返回 access token + refresh token + user -func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, error) { +// Login 用户登录,返回 access token + refresh token + user; +// kicked 为本次登录顶掉的旧会话数(真·单会话:任何新登录踢掉所有旧设备;>0 供上层广播告知) +func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, int, error) { var user model.User if err := s.db.Where("username = ?", username).First(&user).Error; err != nil { // 仍然消耗一次 bcrypt 比较的时间,避免用户名存在性时序枚举 _ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(password)) - return "", "", nil, errors.New("用户名或密码错误") + return "", "", nil, 0, errors.New("用户名或密码错误") } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { - return "", "", nil, errors.New("用户名或密码错误") + return "", "", nil, 0, errors.New("用户名或密码错误") } // 封禁判定放在密码比较之后,同样避免时序差异 if user.Banned { - return "", "", nil, ErrAccountBanned + return "", "", nil, 0, ErrAccountBanned } - refreshToken, familyID, err := s.CreateRefreshToken(user.ID, ip, ua) + refreshToken, familyID, kicked, err := s.CreateRefreshToken(user.ID, ip, ua) if err != nil { - return "", "", nil, err + return "", "", nil, 0, err } accessToken, err := s.generateToken(&user, familyID) if err != nil { - return "", "", nil, err + return "", "", nil, 0, err } - return accessToken, refreshToken, &user, nil + return accessToken, refreshToken, &user, kicked, nil } // generateToken 签发 access token(短期) @@ -315,12 +316,13 @@ func (s *AuthService) decryptToken(encoded string) (string, error) { return string(plain), nil } -// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息) -func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, error) { +// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)。 +// 返回值 kicked 为被顶掉的旧会话行数(真·单会话:踢掉所有旧设备)。 +func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, int, error) { token := generateRandomToken() cipherText, err := s.encryptToken(token) if err != nil { - return "", 0, err + return "", 0, 0, err } now := time.Now() rt := &model.RefreshToken{ @@ -334,14 +336,14 @@ func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, ui ExpiresAt: now.Add(RefreshTokenTTL), } if err := s.db.Create(rt).Error; err != nil { - return "", 0, err + return "", 0, 0, err } if err := s.db.Model(rt).Update("family_id", rt.ID).Error; err != nil { - return "", 0, err + return "", 0, 0, err } rt.FamilyID = rt.ID - s.revokeSameFingerprintExcept(userID, rt.FamilyID, ip, ua) - return token, rt.FamilyID, nil + kicked := s.revokeAllSessionsExcept(userID, rt.FamilyID) + return token, rt.FamilyID, kicked, nil } // loadActiveUser 加载用户并实时校验封禁状态 diff --git a/backend/service/login_device.go b/backend/service/login_device.go index 5c23820..2623471 100644 --- a/backend/service/login_device.go +++ b/backend/service/login_device.go @@ -191,27 +191,30 @@ func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint) return fid } -func (s *AuthService) revokeSameFingerprintExcept(userID, keepFamily uint, ip, ua string) { - fp := deviceFingerprint(ip, ua) +// revokeAllSessionsExcept 真·单会话:登录时吊销该用户除新会话外的全部 +// 有效会话(任何新登录都踢掉所有旧设备);返回吊销行数供上层广播告知。 +func (s *AuthService) revokeAllSessionsExcept(userID, keepFamily uint) int { now := time.Now() var actives []model.RefreshToken - if err := s.db.Select("id", "family_id", "ip", "user_agent"). + if err := s.db.Select("id", "family_id"). Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now). Find(&actives).Error; err != nil { - return + return 0 } ids := make([]uint, 0) for _, t := range actives { if sessionFamilyID(t) == keepFamily { continue } - if deviceFingerprint(t.IP, t.UserAgent) == fp { - ids = append(ids, t.ID) - } + ids = append(ids, t.ID) } if len(ids) == 0 { - return + return 0 } - s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids). + res := s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids). Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}) + if res.Error != nil { + return 0 + } + return int(res.RowsAffected) } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 6b52187..430f5f5 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -2088,6 +2088,100 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint { background: var(--panel-2) !important; color: var(--ink) !important; } +/* 链接弹窗:高级选项折叠开关 + 站内帖子搜索区与结果行(标题 sans 字体 + 右侧标签) */ +.j13-md-adv-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + align-self: flex-start; + padding: 2px 0; + border: 0; + appearance: none; + -webkit-appearance: none; + background: transparent !important; + color: var(--ink-3); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.j13-md-adv-toggle:hover { + color: var(--ink); +} +.j13-md-link-search { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 2px; + padding-top: 8px; + border-top: 1px solid var(--line); +} +.j13-md-popover-list button.j13-md-post-row { + display: flex; + align-items: center; + gap: 8px; + font-family: inherit; + font-size: 12.5px; + font-weight: 500; +} +.j13-md-post-tag { + flex-shrink: 0; + font-size: 11px; + font-weight: 500; + color: var(--ink-3); +} +/* 插图统一弹窗:tab 行、拖拽上传区、错误行 */ +.j13-md-mode.j13-img-tabs { + display: flex; + width: 100%; +} +.j13-img-tabs button { + flex: 1; + justify-content: center; +} +.j13-img-tabs button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.j13-img-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 36px 16px; + border-radius: 14px; + border: 1.5px dashed var(--control-border); + background: var(--panel-2); + color: var(--ink-3); + cursor: pointer; + appearance: none; + -webkit-appearance: none; + transition: border-color 0.15s, background 0.15s; +} +.j13-img-dropzone:hover, +.j13-img-dropzone.is-dragover { + border-color: var(--accent); + color: var(--ink-2); +} +.j13-img-dropzone:disabled { + opacity: 0.6; + cursor: progress; +} +.j13-img-dropzone-title { + font-size: 13.5px; + font-weight: 600; + color: var(--ink); +} +.j13-img-dropzone-hint { + font-size: 12px; + color: var(--ink-3); +} +.j13-img-modal-error { + margin-top: 10px; + font-size: 12.5px; + color: var(--danger, #b42318); +} .j13-md-field { display: flex; flex-direction: column; diff --git a/frontend/components/AccountGuard.tsx b/frontend/components/AccountGuard.tsx index d103587..4a2ef97 100644 --- a/frontend/components/AccountGuard.tsx +++ b/frontend/components/AccountGuard.tsx @@ -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 ( - {}} - hideClose - closeOnEscape={false} - role="alertdialog" - ariaLabel="账号已被封禁" - maxWidthClass="max-w-[400px]" - footer={ - - } - header={ -
- + {}} + hideClose + closeOnEscape={false} + role="alertdialog" + ariaLabel="账号已被封禁" + maxWidthClass="max-w-[400px]" + footer={ +
- } - > -

+ } + header={ +

+ + + +

+ 账号已被封禁 +

+
+ } > - 你的账号因违反社区规则已被管理员封禁,当前登录状态已失效,无法继续发布内容与互动。 - 如对处理结果有异议,请联系管理员核实。 -

-
+

+ 你的账号因违反社区规则已被管理员封禁,当前登录状态已失效,无法继续发布内容与互动。 + 如对处理结果有异议,请联系管理员核实。 +

+ + + {}} + hideClose + closeOnEscape={false} + role="alertdialog" + ariaLabel="登录已失效" + maxWidthClass="max-w-[400px]" + footer={ + + } + header={ +
+ + + +

+ 登录已失效 +

+
+ } + > +

+ 你的账号已在其他浏览器登录,或登录会话已被移除,当前设备的登录状态已失效。 + 请重新登录后继续使用。 +

+
+ ); } diff --git a/frontend/components/ImageInsertModal.tsx b/frontend/components/ImageInsertModal.tsx new file mode 100644 index 0000000..12c5592 --- /dev/null +++ b/frontend/components/ImageInsertModal.tsx @@ -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("upload"); + const [url, setUrl] = useState("https://"); + const [alt, setAlt] = useState("图片"); + const [dragOver, setDragOver] = useState(false); + const [media, setMedia] = useState([]); + 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: }, + { key: "url", label: "链接", icon: }, + { key: "media", label: "我的图片", icon: }, + ]; + + return ( + +
+ {tabs.map((t) => ( + + ))} +
+ + {tab === "upload" && ( +
+ + {uploadError && !uploading ? ( +

+ {uploadError} +

+ ) : null} +
+ )} + + {tab === "url" && ( +
+ + +
+ +
+
+ )} + + {tab === "media" && ( +
+ {mediaLoading ? ( +

+ 加载中 +

+ ) : mediaError ? ( +

{mediaError}

+ ) : media.length === 0 ? ( +

+ 媒体库还没有图片。发帖时上传或粘贴图片后,会自动出现在这里。 +

+ ) : ( +
+ {media.map((item) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/components/MarkdownEditor.tsx b/frontend/components/MarkdownEditor.tsx index 94c38a3..5e6b7a4 100644 --- a/frontend/components/MarkdownEditor.tsx +++ b/frontend/components/MarkdownEditor.tsx @@ -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(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([]); + 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({ }} /> + + {linkAdvanced && ( +
+ setPostQuery(e.target.value)} + /> +
+ {postLoading ? ( +

搜索中…

+ ) : postResults.length === 0 ? ( +

+ {postQuery.trim() ? "无匹配帖子" : "暂无最近帖子"} +

+ ) : ( + postResults.map((p) => ( + + )) + )} +
+
+ )}
- -
- )} - {popover === "imageUrl" && ( -
- - -
- - -
-
- )} {isComment && (
@@ -1325,6 +1364,17 @@ export default function MarkdownEditor({ }} onInsert={applyTimelineBlock} /> + + setImageModalOpen(false)} + uploading={uploading} + uploadError={uploadError} + onChooseFile={() => fileRef.current?.click()} + onDropFile={(file) => void uploadImageFile(file)} + onInsertUrl={insertImageUrl} + onPickMedia={(item) => insertAtCursor(`![图片](${item.url})`)} + />
); } diff --git a/frontend/components/markdown/MdCodeBlock.tsx b/frontend/components/markdown/MdCodeBlock.tsx index 2fef1cc..b6a1ac1 100644 --- a/frontend/components/markdown/MdCodeBlock.tsx +++ b/frontend/components/markdown/MdCodeBlock.tsx @@ -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 { diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 7c5cc1d..49fda3b 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -660,6 +660,19 @@ let refreshPromise: Promise<{ ok: boolean; banned: boolean }> | null = null; // 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示 let bannedNotified = false; +// 登录态被顶/被剔除通知去重(其他浏览器登录、安全设置剔除设备等)。 +// sessionSeen 标记本标签页曾处于登录态:游客触发 401 不应弹"登录已失效" +let sessionEndedNotified = false; +let sessionSeen = false; + +// 曾登录、经探测确认已游客化 → 派发 session_replaced 强制下线事件 +function notifySessionEnded(): void { + if (sessionEndedNotified || !sessionSeen) return; + sessionEndedNotified = true; + sessionSeen = false; + emitForceLogout("session_replaced"); +} + // 识别响应体中的封禁 code;body 只能读一次,故 clone 探测,原响应照常交给调用方 function detectBannedBody(res: Response): void { if (res.status !== 403 || bannedNotified) return; @@ -717,6 +730,10 @@ async function fetchWithRefresh(url: string, init: RequestInit): Promise { + const params = new URLSearchParams({ page: "1", size: String(size), sort: "latest" }); + if (keyword) params.set("keyword", keyword); + const res = await fetchWithRefresh(`/api/posts?${params}`, { + method: "GET", + headers: clientHeaders(), + }); + if (!res.ok) return { error: "搜索帖子失败" }; + const data = (await res.json().catch(() => ({}))) as Partial; + return { posts: data.posts || [] }; +} + // SSR 首页聚合数据;失败时抛错,由首页调用方 try/catch 降级为无侧栏模块 export async function fetchOverview(cookieHeader?: string): Promise { const res = await fetch(`${API_BASE}/api/overview`, ssrInit(cookieHeaders(cookieHeader))); @@ -1302,10 +1335,15 @@ export async function apiMe(): Promise { cache: "no-store", }); const data: MeResponse = await res.json(); + if (data.user) sessionSeen = true; // /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径) if (data.banned === true && !bannedNotified) { bannedNotified = true; emitForceLogout("banned"); + } else if (!data.user && res.ok) { + // 曾登录、现在明确是游客:登录态在别处被顶/被剔除(其他浏览器登录、 + // 设备剔除等)。挂载校正/回前台对账/WS 4401 探测都汇聚到这条判定 + notifySessionEnded(); } return data; } @@ -1318,8 +1356,12 @@ export async function apiLogin(username: string, password: string) { body: JSON.stringify({ username, password }), }); const data = await res.json(); - // 新登录态开始:复位封禁通知去重(解封后再次被封仍可提示) - if (res.ok && data?.user) bannedNotified = false; + // 新登录态开始:复位封禁/被顶通知去重(解封后再次被封、再次被顶仍可提示) + if (res.ok && data?.user) { + bannedNotified = false; + sessionEndedNotified = false; + sessionSeen = true; + } return data; } diff --git a/frontend/lib/realtime.ts b/frontend/lib/realtime.ts index 6b7b1a8..2151eb4 100644 --- a/frontend/lib/realtime.ts +++ b/frontend/lib/realtime.ts @@ -3,12 +3,15 @@ // - 单条 WebSocket(浏览器握手自动携带 HttpOnly cookie 鉴权),后续群聊/通知/ // 审核角标都复用本连接,按 message type 路由。 // - 连接状态机 + 指数退避重连;应用层心跳(协议层 Ping 由浏览器自动应答)。 -// - 鉴权失效关闭码 4401 不自动重连(封禁/强制下线由 AccountGuard 统一处理)。 +// - 鉴权失效关闭码 4401 不自动重连;转一次 /api/me 探测(封禁走现有弹窗链路, +// 被顶下线由 AccountGuard 弹"登录已失效",见 lib/api 的统一判定)。 // - 在线集合:hello 帧给 staff 全量快照,之后 presence:update 增量维护。 // - 群聊:进入群详情页调用 joinRoom(roomID) 订阅 chat:{id} 房间, // 重连后会自动重订当前已订阅集合;离开页面/退出群调用 leaveRoom。 // - HTTP 仅作回前台 / 进页校准;实时靠 WS(通知、待审、主题、群聊未读)。 +import { apiMe } from "./api"; + export const RT_HELLO = "hello"; export const RT_PONG = "pong"; export const RT_SETTINGS_CHANGED = "settings:changed"; @@ -20,6 +23,8 @@ export const RT_CHAT_UNREAD = "chat:unread"; export const RT_NOTIFICATION_NEW = "notification:new"; export const RT_FEED_CHANGED = "feed:changed"; export const RT_MODERATION_CHANGED = "moderation:changed"; +// 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备),推 user:{id} +export const RT_SESSION_REPLACED = "session:replaced"; export interface RtEnvelope { type: string; @@ -323,10 +328,13 @@ class RealtimeClient { this.clearHeartbeat(); if (this.ws === ws) this.ws = null; this.state = "closed"; - // 鉴权失效:账号封禁/角色变更导致 token_version 失效, - // 不重连(强制下线/重新登录由现有 HTTP 拦截链路处理) + // 鉴权失效:账号封禁/角色变更/会话被顶导致 claims 校验失效, + // 不重连;探测一次登录态(封禁→现有弹窗链路;被顶→"登录已失效"提示) if (!this.wanted || ev.code === CLOSE_AUTH_INVALID) { this.wanted = false; + if (ev.code === CLOSE_AUTH_INVALID) { + void apiMe().catch(() => {}); + } return; } this.scheduleReconnect(); diff --git a/frontend/lib/userEvents.ts b/frontend/lib/userEvents.ts index 51e4087..eba9b2f 100644 --- a/frontend/lib/userEvents.ts +++ b/frontend/lib/userEvents.ts @@ -32,12 +32,14 @@ export function onUserUpdate(handler: (patch: Partial) => void): () => voi * 强制下线事件。 * * 触发源统一收敛在 lib/api.ts:任何客户端请求收到后端的 - * code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发, - * Header 负责复用登出清理链路(清 cookie、游客化、router.refresh), - * AccountGuard 负责弹出封禁告知——状态清理与告知 UI 解耦, - * 避免各请求调用方各自处理导致残留(参考强制下线单一入口原则)。 + * code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发; + * 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备)经探测确认后 + * 以 reason=session_replaced 派发。Header 负责复用登出清理链路 + * (清 cookie、游客化、router.refresh),AccountGuard 负责弹出对应告知—— + * 状态清理与告知 UI 解耦,避免各请求调用方各自处理导致残留 + * (参考强制下线单一入口原则)。 */ -export type ForceLogoutReason = "banned"; +export type ForceLogoutReason = "banned" | "session_replaced"; export const FORCE_LOGOUT_EVENT = "j13:force-logout";