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(``);
- 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) => (
+
+ ))
+ )}
+
+
+ )}
{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(``)}
+ />
);
}
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";