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;
}