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

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

View File

@@ -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<Respons
} else if (result.banned && !bannedNotified) {
bannedNotified = true;
emitForceLogout("banned");
} else if (!result.banned) {
// refresh 失败且非封禁:登录态已失效(被顶/被剔除/过期),
// 曾登录的标签页统一告知,避免界面仍显示已登录
notifySessionEnded();
}
}
return res;
@@ -747,6 +764,22 @@ export async function fetchPosts(
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 降级为无侧栏模块
export async function fetchOverview(cookieHeader?: string): Promise<OverviewResponse> {
const res = await fetch(`${API_BASE}/api/overview`, ssrInit(cookieHeaders(cookieHeader)));
@@ -1302,10 +1335,15 @@ export async function apiMe(): Promise<MeResponse> {
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;
}

View File

@@ -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<T = unknown> {
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();

View File

@@ -32,12 +32,14 @@ export function onUserUpdate(handler: (patch: Partial<User>) => 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";