feat: 登录设备管理与编辑器图片上传
- 新增登录设备列表与会话下线(ListLoginDevices/RevokeLoginDevice),按设备指纹识别当前会话 - Markdown 编辑器新增图片插入弹窗与图片上传(ImageInsertModal) - 实时总线、账号守卫与用户事件适配
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"github.com/freefire/jiang13-bbs/realtime"
|
||||||
"github.com/freefire/jiang13-bbs/service"
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -153,8 +154,8 @@ func (h *Handlers) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = h.Chat.EnsureDefaultMembership(user.ID)
|
_ = h.Chat.EnsureDefaultMembership(user.ID)
|
||||||
// 自动登录:走与 Login 相同的 token 签发链路
|
// 自动登录:走与 Login 相同的 token 签发链路(新注册无旧会话,不会有踢出广播)
|
||||||
accessToken, refreshToken, loginUser, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
|
accessToken, refreshToken, loginUser, _, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 签发失败不应让注册白做:仍返回成功,让前端引导去登录页
|
// 签发失败不应让注册白做:仍返回成功,让前端引导去登录页
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -191,7 +192,7 @@ func (h *Handlers) Login(c *gin.Context) {
|
|||||||
if !h.quotaResponse(c, wait, e) {
|
if !h.quotaResponse(c, wait, e) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
h.Ops.RecordFailure(req.Username, c.ClientIP(), cfg)
|
h.Ops.RecordFailure(req.Username, c.ClientIP(), cfg)
|
||||||
// 登录失败也留痕:用户存在时带 user_id(封禁/错密),不存在时为 0
|
// 登录失败也留痕:用户存在时带 user_id(封禁/错密),不存在时为 0
|
||||||
@@ -212,6 +213,14 @@ func (h *Handlers) Login(c *gin.Context) {
|
|||||||
// dev 模式不设 Secure,生产环境需 HTTPS
|
// dev 模式不设 Secure,生产环境需 HTTPS
|
||||||
h.Ops.ClearFailure(req.Username)
|
h.Ops.ClearFailure(req.Username)
|
||||||
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"user": gin.H{
|
"user": gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
@@ -338,6 +347,11 @@ func (h *Handlers) RevokeLoginDevice(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "剔除设备失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "剔除设备失败"})
|
||||||
return
|
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})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const (
|
|||||||
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
|
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
|
||||||
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
|
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
|
||||||
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
|
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
|
||||||
|
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
|
||||||
)
|
)
|
||||||
|
|
||||||
// RoomStaff 管理团队房间(板块管理员及以上)
|
// RoomStaff 管理团队房间(板块管理员及以上)
|
||||||
|
|||||||
@@ -132,31 +132,32 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
|
|||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 用户登录,返回 access token + refresh token + user
|
// Login 用户登录,返回 access token + refresh token + user;
|
||||||
func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, error) {
|
// kicked 为本次登录顶掉的旧会话数(真·单会话:任何新登录踢掉所有旧设备;>0 供上层广播告知)
|
||||||
|
func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, int, error) {
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
|
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
|
||||||
// 仍然消耗一次 bcrypt 比较的时间,避免用户名存在性时序枚举
|
// 仍然消耗一次 bcrypt 比较的时间,避免用户名存在性时序枚举
|
||||||
_ = bcrypt.CompareHashAndPassword(dummyPasswordHash, []byte(password))
|
_ = 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 {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
|
||||||
return "", "", nil, errors.New("用户名或密码错误")
|
return "", "", nil, 0, errors.New("用户名或密码错误")
|
||||||
}
|
}
|
||||||
// 封禁判定放在密码比较之后,同样避免时序差异
|
// 封禁判定放在密码比较之后,同样避免时序差异
|
||||||
if user.Banned {
|
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 {
|
if err != nil {
|
||||||
return "", "", nil, err
|
return "", "", nil, 0, err
|
||||||
}
|
}
|
||||||
accessToken, err := s.generateToken(&user, familyID)
|
accessToken, err := s.generateToken(&user, familyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", nil, err
|
return "", "", nil, 0, err
|
||||||
}
|
}
|
||||||
return accessToken, refreshToken, &user, nil
|
return accessToken, refreshToken, &user, kicked, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateToken 签发 access token(短期)
|
// generateToken 签发 access token(短期)
|
||||||
@@ -315,12 +316,13 @@ func (s *AuthService) decryptToken(encoded string) (string, error) {
|
|||||||
return string(plain), nil
|
return string(plain), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)
|
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)。
|
||||||
func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, error) {
|
// 返回值 kicked 为被顶掉的旧会话行数(真·单会话:踢掉所有旧设备)。
|
||||||
|
func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, int, error) {
|
||||||
token := generateRandomToken()
|
token := generateRandomToken()
|
||||||
cipherText, err := s.encryptToken(token)
|
cipherText, err := s.encryptToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, err
|
return "", 0, 0, err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
rt := &model.RefreshToken{
|
rt := &model.RefreshToken{
|
||||||
@@ -334,14 +336,14 @@ func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, ui
|
|||||||
ExpiresAt: now.Add(RefreshTokenTTL),
|
ExpiresAt: now.Add(RefreshTokenTTL),
|
||||||
}
|
}
|
||||||
if err := s.db.Create(rt).Error; err != nil {
|
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 {
|
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
|
rt.FamilyID = rt.ID
|
||||||
s.revokeSameFingerprintExcept(userID, rt.FamilyID, ip, ua)
|
kicked := s.revokeAllSessionsExcept(userID, rt.FamilyID)
|
||||||
return token, rt.FamilyID, nil
|
return token, rt.FamilyID, kicked, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadActiveUser 加载用户并实时校验封禁状态
|
// loadActiveUser 加载用户并实时校验封禁状态
|
||||||
|
|||||||
@@ -191,27 +191,30 @@ func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint)
|
|||||||
return fid
|
return fid
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AuthService) revokeSameFingerprintExcept(userID, keepFamily uint, ip, ua string) {
|
// revokeAllSessionsExcept 真·单会话:登录时吊销该用户除新会话外的全部
|
||||||
fp := deviceFingerprint(ip, ua)
|
// 有效会话(任何新登录都踢掉所有旧设备);返回吊销行数供上层广播告知。
|
||||||
|
func (s *AuthService) revokeAllSessionsExcept(userID, keepFamily uint) int {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var actives []model.RefreshToken
|
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).
|
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
|
||||||
Find(&actives).Error; err != nil {
|
Find(&actives).Error; err != nil {
|
||||||
return
|
return 0
|
||||||
}
|
}
|
||||||
ids := make([]uint, 0)
|
ids := make([]uint, 0)
|
||||||
for _, t := range actives {
|
for _, t := range actives {
|
||||||
if sessionFamilyID(t) == keepFamily {
|
if sessionFamilyID(t) == keepFamily {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if deviceFingerprint(t.IP, t.UserAgent) == fp {
|
ids = append(ids, t.ID)
|
||||||
ids = append(ids, t.ID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if len(ids) == 0 {
|
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})
|
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now})
|
||||||
|
if res.Error != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(res.RowsAffected)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2088,6 +2088,100 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
|
|||||||
background: var(--panel-2) !important;
|
background: var(--panel-2) !important;
|
||||||
color: var(--ink) !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 {
|
.j13-md-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -2,77 +2,160 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Ban } from "lucide-react";
|
import { Ban, LogOut } from "lucide-react";
|
||||||
import { onForceLogout } from "@/lib/userEvents";
|
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";
|
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 复用统一
|
* 登录态清理(cookie/Header 游客化/router.refresh)由 Header 复用统一
|
||||||
* 登出链路完成;本组件只负责"告知"这一职责,故不处理任何凭据逻辑。
|
* 登出链路完成;本组件只负责"告知",故不处理任何凭据逻辑。
|
||||||
* 挂载于 root layout,覆盖任意页面(含后台)。
|
* 挂载于 root layout,覆盖任意页面(含后台)。
|
||||||
*/
|
*/
|
||||||
export default function AccountGuard() {
|
export default function AccountGuard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [banned, setBanned] = useState(false);
|
const [banned, setBanned] = useState(false);
|
||||||
|
const [sessionEnded, setSessionEnded] = useState(false);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
onForceLogout((reason) => {
|
onForceLogout((reason) => {
|
||||||
if (reason === "banned") setBanned(true);
|
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 (
|
return (
|
||||||
<Modal
|
<>
|
||||||
open={banned}
|
<Modal
|
||||||
title="账号已被封禁"
|
open={banned}
|
||||||
onClose={() => {}}
|
title="账号已被封禁"
|
||||||
hideClose
|
onClose={() => {}}
|
||||||
closeOnEscape={false}
|
hideClose
|
||||||
role="alertdialog"
|
closeOnEscape={false}
|
||||||
ariaLabel="账号已被封禁"
|
role="alertdialog"
|
||||||
maxWidthClass="max-w-[400px]"
|
ariaLabel="账号已被封禁"
|
||||||
footer={
|
maxWidthClass="max-w-[400px]"
|
||||||
<button
|
footer={
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
setBanned(false);
|
onClick={() => {
|
||||||
router.push("/");
|
setBanned(false);
|
||||||
}}
|
router.push("/");
|
||||||
className="btn btn-primary w-full"
|
}}
|
||||||
autoFocus
|
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)" }}
|
|
||||||
>
|
>
|
||||||
<Ban size={26} />
|
我知道了
|
||||||
</span>
|
</button>
|
||||||
<h2
|
}
|
||||||
id="banned-dialog-title"
|
header={
|
||||||
className="mt-4 text-[18px] font-extrabold tracking-tight"
|
<div className="w-full text-center">
|
||||||
style={{ color: "var(--ink)" }}
|
<span
|
||||||
>
|
className="mx-auto w-14 h-14 rounded-full flex items-center justify-center"
|
||||||
账号已被封禁
|
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
|
||||||
</h2>
|
>
|
||||||
</div>
|
<Ban size={26} />
|
||||||
}
|
</span>
|
||||||
>
|
<h2
|
||||||
<p
|
id="banned-dialog-title"
|
||||||
id="banned-dialog-desc"
|
className="mt-4 text-[18px] font-extrabold tracking-tight"
|
||||||
className="meta text-[13px] leading-relaxed text-center"
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
账号已被封禁
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
你的账号因违反社区规则已被管理员封禁,当前登录状态已失效,无法继续发布内容与互动。
|
<p
|
||||||
如对处理结果有异议,请联系管理员核实。
|
id="banned-dialog-desc"
|
||||||
</p>
|
className="meta text-[13px] leading-relaxed text-center"
|
||||||
</Modal>
|
>
|
||||||
|
你的账号因违反社区规则已被管理员封禁,当前登录状态已失效,无法继续发布内容与互动。
|
||||||
|
如对处理结果有异议,请联系管理员核实。
|
||||||
|
</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,
|
Minimize2,
|
||||||
Lock,
|
Lock,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Upload,
|
|
||||||
Link2,
|
|
||||||
History,
|
History,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiUploadPostImage } from "@/lib/api";
|
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||||
|
import ImageInsertModal from "@/components/ImageInsertModal";
|
||||||
import TimelineEditor from "@/components/TimelineEditor";
|
import TimelineEditor from "@/components/TimelineEditor";
|
||||||
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
|
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
|
||||||
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
|
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
|
||||||
@@ -87,7 +86,7 @@ type WrapOpts = {
|
|||||||
linePrefix?: string;
|
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;
|
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({
|
export default function MarkdownEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -160,8 +167,12 @@ export default function MarkdownEditor({
|
|||||||
const [popover, setPopover] = useState<PopoverKind>(null);
|
const [popover, setPopover] = useState<PopoverKind>(null);
|
||||||
const [linkText, setLinkText] = useState("");
|
const [linkText, setLinkText] = useState("");
|
||||||
const [linkUrl, setLinkUrl] = useState("https://");
|
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 [codeQuery, setCodeQuery] = useState("");
|
||||||
const [hidePoints, setHidePoints] = useState(10);
|
const [hidePoints, setHidePoints] = useState(10);
|
||||||
const [hidePassword, setHidePassword] = useState("");
|
const [hidePassword, setHidePassword] = useState("");
|
||||||
@@ -272,6 +283,8 @@ export default function MarkdownEditor({
|
|||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// 插图弹窗自管 ESC,不连退全屏
|
||||||
|
if (imageModalOpen) return;
|
||||||
if (popover) setPopover(null);
|
if (popover) setPopover(null);
|
||||||
else {
|
else {
|
||||||
rememberCaret();
|
rememberCaret();
|
||||||
@@ -286,7 +299,7 @@ export default function MarkdownEditor({
|
|||||||
document.body.style.overflow = prev;
|
document.body.style.overflow = prev;
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
};
|
};
|
||||||
}, [fullscreen, popover]);
|
}, [fullscreen, popover, imageModalOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!popover) return;
|
if (!popover) return;
|
||||||
@@ -311,6 +324,31 @@ export default function MarkdownEditor({
|
|||||||
};
|
};
|
||||||
}, [popover]);
|
}, [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 rememberSel = () => {
|
||||||
const el = taRef.current;
|
const el = taRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -401,6 +439,7 @@ export default function MarkdownEditor({
|
|||||||
setMode("write");
|
setMode("write");
|
||||||
setUploadError("");
|
setUploadError("");
|
||||||
retryFile.current = null;
|
retryFile.current = null;
|
||||||
|
setImageModalOpen(false);
|
||||||
toast("图片已插入", "ok");
|
toast("图片已插入", "ok");
|
||||||
} catch {
|
} catch {
|
||||||
setUploadError("上传失败");
|
setUploadError("上传失败");
|
||||||
@@ -418,10 +457,9 @@ export default function MarkdownEditor({
|
|||||||
const selected = el ? value.slice(el.selectionStart, el.selectionEnd) : "";
|
const selected = el ? value.slice(el.selectionStart, el.selectionEnd) : "";
|
||||||
setLinkText(selected || "链接文字");
|
setLinkText(selected || "链接文字");
|
||||||
setLinkUrl("https://");
|
setLinkUrl("https://");
|
||||||
}
|
setPostQuery("");
|
||||||
if (kind === "imageUrl") {
|
setPostResults([]);
|
||||||
setImageUrl("https://");
|
setLinkAdvanced(false);
|
||||||
setImageAlt("图片");
|
|
||||||
}
|
}
|
||||||
if (kind === "code") setCodeQuery("");
|
if (kind === "code") setCodeQuery("");
|
||||||
if (kind === "hide") {
|
if (kind === "hide") {
|
||||||
@@ -432,10 +470,16 @@ export default function MarkdownEditor({
|
|||||||
setPopover((prev) => (prev === kind ? null : kind));
|
setPopover((prev) => (prev === kind ? null : kind));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 选中站内帖子:回填 URL 与文字,用户可再编辑后点「插入」 */
|
||||||
|
const pickPostLink = (p: PostListItem) => {
|
||||||
|
setLinkUrl(`/post/${p.id}`);
|
||||||
|
setLinkText(p.title);
|
||||||
|
};
|
||||||
|
|
||||||
const applyLink = () => {
|
const applyLink = () => {
|
||||||
const url = linkUrl.trim();
|
const url = linkUrl.trim();
|
||||||
if (!isHttpUrl(url)) {
|
if (!isLinkTargetUrl(url)) {
|
||||||
toast("请输入有效的 http(s) 链接", "error");
|
toast("请输入 http(s) 链接或站内路径(如 /post/123)", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const el = taRef.current;
|
const el = taRef.current;
|
||||||
@@ -455,15 +499,22 @@ export default function MarkdownEditor({
|
|||||||
setPopover(null);
|
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)) {
|
if (!isHttpUrl(url)) {
|
||||||
toast("请输入有效的图片 URL(http/https)", "error");
|
toast("请输入有效的图片 URL(http/https)", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const alt = imageAlt.trim() || "图片";
|
const alt = rawAlt.trim() || "图片";
|
||||||
insertAtCursor(``);
|
insertAtCursor(``);
|
||||||
setPopover(null);
|
setImageModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertCodeBlock = (lang: string) => {
|
const insertCodeBlock = (lang: string) => {
|
||||||
@@ -858,6 +909,7 @@ export default function MarkdownEditor({
|
|||||||
value={linkUrl}
|
value={linkUrl}
|
||||||
onChange={(e) => setLinkUrl(e.target.value)}
|
onChange={(e) => setLinkUrl(e.target.value)}
|
||||||
className="j13-md-popover-input"
|
className="j13-md-popover-input"
|
||||||
|
placeholder="https://… 或 /post/123"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -866,6 +918,50 @@ export default function MarkdownEditor({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<div className="j13-md-popover-footer">
|
||||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
|
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
|
||||||
取消
|
取消
|
||||||
@@ -881,67 +977,10 @@ export default function MarkdownEditor({
|
|||||||
<ToolBtn
|
<ToolBtn
|
||||||
label={uploading ? "上传中…" : "图片"}
|
label={uploading ? "上传中…" : "图片"}
|
||||||
icon={<ImageIcon size={15} />}
|
icon={<ImageIcon size={15} />}
|
||||||
active={popover === "image" || popover === "imageUrl"}
|
active={imageModalOpen}
|
||||||
disabled={uploading}
|
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>
|
</div>
|
||||||
{isComment && (
|
{isComment && (
|
||||||
<div className="j13-md-popover-anchor relative">
|
<div className="j13-md-popover-anchor relative">
|
||||||
@@ -1325,6 +1364,17 @@ export default function MarkdownEditor({
|
|||||||
}}
|
}}
|
||||||
onInsert={applyTimelineBlock}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,7 +202,10 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
|||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
const pre = host?.querySelector("pre") ?? null;
|
const pre = host?.querySelector("pre") ?? null;
|
||||||
setLineCount(countLines(pre, plainText));
|
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);
|
setFoldReady(true);
|
||||||
}, [plainText, className, dataLanguage, clientHtml]);
|
}, [plainText, className, dataLanguage, clientHtml]);
|
||||||
|
|
||||||
@@ -222,7 +225,11 @@ export default function MdCodeBlock({ children, className, ...rest }: MdCodeBloc
|
|||||||
// SSR 未真正着色(如自定义语言 loadLanguage 失败回退)时,客户端补双主题
|
// SSR 未真正着色(如自定义语言 loadLanguage 失败回退)时,客户端补双主题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (serverHighlighted || !plainText.trim()) return;
|
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;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -660,6 +660,19 @@ let refreshPromise: Promise<{ ok: boolean; banned: boolean }> | null = null;
|
|||||||
// 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示
|
// 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示
|
||||||
let bannedNotified = false;
|
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 探测,原响应照常交给调用方
|
// 识别响应体中的封禁 code;body 只能读一次,故 clone 探测,原响应照常交给调用方
|
||||||
function detectBannedBody(res: Response): void {
|
function detectBannedBody(res: Response): void {
|
||||||
if (res.status !== 403 || bannedNotified) return;
|
if (res.status !== 403 || bannedNotified) return;
|
||||||
@@ -717,6 +730,10 @@ async function fetchWithRefresh(url: string, init: RequestInit): Promise<Respons
|
|||||||
} else if (result.banned && !bannedNotified) {
|
} else if (result.banned && !bannedNotified) {
|
||||||
bannedNotified = true;
|
bannedNotified = true;
|
||||||
emitForceLogout("banned");
|
emitForceLogout("banned");
|
||||||
|
} else if (!result.banned) {
|
||||||
|
// refresh 失败且非封禁:登录态已失效(被顶/被剔除/过期),
|
||||||
|
// 曾登录的标签页统一告知,避免界面仍显示已登录
|
||||||
|
notifySessionEnded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
@@ -747,6 +764,22 @@ export async function fetchPosts(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 编辑器站内链接:按关键词搜帖子(公开接口;keyword 留空返回最近发布)
|
||||||
|
export async function apiSearchPosts(
|
||||||
|
keyword = "",
|
||||||
|
size = 8
|
||||||
|
): Promise<{ posts?: PostListItem[]; error?: string }> {
|
||||||
|
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<PostsResponse>;
|
||||||
|
return { posts: data.posts || [] };
|
||||||
|
}
|
||||||
|
|
||||||
// SSR 首页聚合数据;失败时抛错,由首页调用方 try/catch 降级为无侧栏模块
|
// SSR 首页聚合数据;失败时抛错,由首页调用方 try/catch 降级为无侧栏模块
|
||||||
export async function fetchOverview(cookieHeader?: string): Promise<OverviewResponse> {
|
export async function fetchOverview(cookieHeader?: string): Promise<OverviewResponse> {
|
||||||
const res = await fetch(`${API_BASE}/api/overview`, ssrInit(cookieHeaders(cookieHeader)));
|
const res = await fetch(`${API_BASE}/api/overview`, ssrInit(cookieHeaders(cookieHeader)));
|
||||||
@@ -1302,10 +1335,15 @@ export async function apiMe(): Promise<MeResponse> {
|
|||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
const data: MeResponse = await res.json();
|
const data: MeResponse = await res.json();
|
||||||
|
if (data.user) sessionSeen = true;
|
||||||
// /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径)
|
// /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径)
|
||||||
if (data.banned === true && !bannedNotified) {
|
if (data.banned === true && !bannedNotified) {
|
||||||
bannedNotified = true;
|
bannedNotified = true;
|
||||||
emitForceLogout("banned");
|
emitForceLogout("banned");
|
||||||
|
} else if (!data.user && res.ok) {
|
||||||
|
// 曾登录、现在明确是游客:登录态在别处被顶/被剔除(其他浏览器登录、
|
||||||
|
// 设备剔除等)。挂载校正/回前台对账/WS 4401 探测都汇聚到这条判定
|
||||||
|
notifySessionEnded();
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -1318,8 +1356,12 @@ export async function apiLogin(username: string, password: string) {
|
|||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,15 @@
|
|||||||
// - 单条 WebSocket(浏览器握手自动携带 HttpOnly cookie 鉴权),后续群聊/通知/
|
// - 单条 WebSocket(浏览器握手自动携带 HttpOnly cookie 鉴权),后续群聊/通知/
|
||||||
// 审核角标都复用本连接,按 message type 路由。
|
// 审核角标都复用本连接,按 message type 路由。
|
||||||
// - 连接状态机 + 指数退避重连;应用层心跳(协议层 Ping 由浏览器自动应答)。
|
// - 连接状态机 + 指数退避重连;应用层心跳(协议层 Ping 由浏览器自动应答)。
|
||||||
// - 鉴权失效关闭码 4401 不自动重连(封禁/强制下线由 AccountGuard 统一处理)。
|
// - 鉴权失效关闭码 4401 不自动重连;转一次 /api/me 探测(封禁走现有弹窗链路,
|
||||||
|
// 被顶下线由 AccountGuard 弹"登录已失效",见 lib/api 的统一判定)。
|
||||||
// - 在线集合:hello 帧给 staff 全量快照,之后 presence:update 增量维护。
|
// - 在线集合:hello 帧给 staff 全量快照,之后 presence:update 增量维护。
|
||||||
// - 群聊:进入群详情页调用 joinRoom(roomID) 订阅 chat:{id} 房间,
|
// - 群聊:进入群详情页调用 joinRoom(roomID) 订阅 chat:{id} 房间,
|
||||||
// 重连后会自动重订当前已订阅集合;离开页面/退出群调用 leaveRoom。
|
// 重连后会自动重订当前已订阅集合;离开页面/退出群调用 leaveRoom。
|
||||||
// - HTTP 仅作回前台 / 进页校准;实时靠 WS(通知、待审、主题、群聊未读)。
|
// - HTTP 仅作回前台 / 进页校准;实时靠 WS(通知、待审、主题、群聊未读)。
|
||||||
|
|
||||||
|
import { apiMe } from "./api";
|
||||||
|
|
||||||
export const RT_HELLO = "hello";
|
export const RT_HELLO = "hello";
|
||||||
export const RT_PONG = "pong";
|
export const RT_PONG = "pong";
|
||||||
export const RT_SETTINGS_CHANGED = "settings:changed";
|
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_NOTIFICATION_NEW = "notification:new";
|
||||||
export const RT_FEED_CHANGED = "feed:changed";
|
export const RT_FEED_CHANGED = "feed:changed";
|
||||||
export const RT_MODERATION_CHANGED = "moderation:changed";
|
export const RT_MODERATION_CHANGED = "moderation:changed";
|
||||||
|
// 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备),推 user:{id}
|
||||||
|
export const RT_SESSION_REPLACED = "session:replaced";
|
||||||
|
|
||||||
export interface RtEnvelope<T = unknown> {
|
export interface RtEnvelope<T = unknown> {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -323,10 +328,13 @@ class RealtimeClient {
|
|||||||
this.clearHeartbeat();
|
this.clearHeartbeat();
|
||||||
if (this.ws === ws) this.ws = null;
|
if (this.ws === ws) this.ws = null;
|
||||||
this.state = "closed";
|
this.state = "closed";
|
||||||
// 鉴权失效:账号封禁/角色变更导致 token_version 失效,
|
// 鉴权失效:账号封禁/角色变更/会话被顶导致 claims 校验失效,
|
||||||
// 不重连(强制下线/重新登录由现有 HTTP 拦截链路处理)
|
// 不重连;探测一次登录态(封禁→现有弹窗链路;被顶→"登录已失效"提示)
|
||||||
if (!this.wanted || ev.code === CLOSE_AUTH_INVALID) {
|
if (!this.wanted || ev.code === CLOSE_AUTH_INVALID) {
|
||||||
this.wanted = false;
|
this.wanted = false;
|
||||||
|
if (ev.code === CLOSE_AUTH_INVALID) {
|
||||||
|
void apiMe().catch(() => {});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.scheduleReconnect();
|
this.scheduleReconnect();
|
||||||
|
|||||||
@@ -32,12 +32,14 @@ export function onUserUpdate(handler: (patch: Partial<User>) => void): () => voi
|
|||||||
* 强制下线事件。
|
* 强制下线事件。
|
||||||
*
|
*
|
||||||
* 触发源统一收敛在 lib/api.ts:任何客户端请求收到后端的
|
* 触发源统一收敛在 lib/api.ts:任何客户端请求收到后端的
|
||||||
* code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发,
|
* code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发;
|
||||||
* Header 负责复用登出清理链路(清 cookie、游客化、router.refresh),
|
* 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备)经探测确认后
|
||||||
* AccountGuard 负责弹出封禁告知——状态清理与告知 UI 解耦,
|
* 以 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";
|
export const FORCE_LOGOUT_EVENT = "j13:force-logout";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user