feat: 站点媒体库与移动端底部导航,服务测试补强

- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
2026-09-27 02:35:38 +08:00
parent 734afbc39f
commit 1d862a0dd3
61 changed files with 4757 additions and 1129 deletions

View File

@@ -13,7 +13,7 @@ import {
type FooterLinksAlign,
} from "./brand";
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
import { CSRF_COOKIE } from "./cookies";
import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies";
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
import { emitForceLogout } from "./userEvents";
@@ -306,12 +306,14 @@ export interface PublicSponsorItem {
hover_text?: string;
}
/** 板块右栏:管理员公开资料 */
/** 板块右栏:管理员公开资料(含等级与颁发的徽章) */
export interface BoardModerator {
id: number;
username: string;
nickname: string;
avatar: string;
level?: number;
badges?: UserBadgeView[];
}
/** 板块右栏:板级统计 */
@@ -686,13 +688,13 @@ function clientHeaders(extra: Record<string, string> = {}): HeadersInit {
// ===== Refresh token 自动续期 =====
let refreshPromise: Promise<{ ok: boolean; banned: boolean }> | null = null;
let refreshPromise: ReturnType<typeof apiRefresh> | null = null;
// 被封禁通知去重:同一登录生命周期内只强制下线/弹一次,
// 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示
let bannedNotified = false;
// 登录态被顶/被剔除通知去重(其他浏览器登录、安全设置剔除设备等)。
// 登录态被剔除通知去重(安全设置剔除设备、会话数超上限被淘汰等)。
// sessionSeen 标记本标签页曾处于登录态:游客触发 401 不应弹"登录已失效"
let sessionEndedNotified = false;
let sessionSeen = false;
@@ -720,22 +722,25 @@ function detectBannedBody(res: Response): void {
.catch(() => {});
}
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean }> {
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)。
// transient=true 表示网络异常或后端暂不可用(含代理 5xx):此时无法证明登录态
// 失效,调用方不应据此弹"登录已失效"——dev 后端 go run 重启窗口内尤其常见
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean; transient: boolean }> {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
headers: clientHeaders(),
});
if (res.ok) return { ok: true, banned: false };
if (res.ok) return { ok: true, banned: false, transient: false };
if (res.status >= 500) return { ok: false, banned: false, transient: true };
if (res.status === 403) {
const data = await res.json().catch(() => ({} as { code?: string }));
if (data.code === "account_banned") return { ok: false, banned: true };
if (data.code === "account_banned") return { ok: false, banned: true, transient: false };
}
return { ok: false, banned: false };
return { ok: false, banned: false, transient: false };
} catch {
return { ok: false, banned: false };
return { ok: false, banned: false, transient: true };
}
}
@@ -762,9 +767,10 @@ async function fetchWithRefresh(url: string, init: RequestInit): Promise<Respons
} else if (result.banned && !bannedNotified) {
bannedNotified = true;
emitForceLogout("banned");
} else if (!result.banned) {
// refresh 失败且非封禁:登录态已失效(被顶/被剔除/过期),
// 曾登录的标签页统一告知,避免界面仍显示已登录
} else if (!result.banned && !result.transient) {
// refresh 明确失败且非封禁:登录态已失效(被顶/被剔除/过期),
// 曾登录的标签页统一告知,避免界面仍显示已登录。
// 瞬态失败(网络/后端暂不可用)不动登录态,等后续请求自然恢复
notifySessionEnded();
}
}
@@ -1399,19 +1405,38 @@ export async function fetchNotifications(page = 1, cookieHeader?: string): Promi
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 })
export async function apiMe(): Promise<MeResponse> {
const res = await fetch("/api/me", {
let res = await fetch("/api/me", {
credentials: "include",
cache: "no-store",
});
const data: MeResponse = await res.json();
let 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 探测都汇聚到这条判定
if (sessionSeen && hasAuthCookieHint()) {
// access cookie 到达 15min MaxAge 会被浏览器删除,/api/me 只能以游客
// 应答——这不代表会话被剔除(refresh token 仍有效)。先 refresh 恢复
// access 再复判;真被剔除时 refresh 也会明确失败,结论不变
const refreshed = await apiRefresh();
if (refreshed.ok) {
res = await fetch("/api/me", { credentials: "include", cache: "no-store" });
data = await res.json();
if (data.user) sessionSeen = true;
} else if (refreshed.transient) {
// 网络/后端暂不可用:无法证明登录态失效,不下结论
return data;
}
if (refreshed.banned && !bannedNotified) {
bannedNotified = true;
emitForceLogout("banned");
return data;
}
}
// 曾登录、经复判仍是游客:登录态在别处被剔除(设备剔除、
// 会话超限淘汰等)。挂载校正/回前台对账/WS 4401 探测都汇聚到这条判定
notifySessionEnded();
}
return data;
@@ -2243,7 +2268,7 @@ export async function apiUploadAvatar(
return res.json();
}
// 上传帖子插图(JPEG / PNG / WebP,≤5MB)
// 上传帖子插图(JPEG / PNG / WebP / GIF,≤5MB;服务端自动转存 WebP,GIF 除外)
export async function apiUploadPostImage(
file: File | Blob,
filename = "image.jpg"
@@ -2949,6 +2974,7 @@ export type LegacyUserReport = {
total: number;
imported: number;
skipped: number;
excluded: number;
avatar_written: number;
conflicts?: string[];
avatar_missing?: string[];
@@ -2965,9 +2991,13 @@ export type LegacyPostReport = {
total: number;
imported: number;
skipped: number;
excluded: number;
excluded_detail?: string[];
polls_skipped: number;
poll_titles?: string[];
owner_fallback?: string[];
images_written: number;
images_missing?: string[];
failed?: string[];
};
@@ -2975,37 +3005,94 @@ export type LegacyCommentReport = {
total: number;
imported: number;
skipped: number;
excluded: number;
excluded_detail?: string[];
skipped_detail?: string[];
owner_fallback?: string[];
images_written: number;
images_missing?: string[];
failed?: string[];
};
// ===== 预检清单(dry-run 返回,供勾选导入范围) =====
export type LegacyUserPreview = {
id: number;
username: string;
nickname: string;
posts: number;
comments: number;
exists: boolean;
has_avatar: boolean;
};
export type LegacyPostPreview = {
id: number;
title: string;
author: string;
board: string;
created_at: string;
poll: boolean;
published: boolean;
};
export type LegacyCommentPreview = {
id: number;
post_id: number;
post_title: string;
author: string;
excerpt: string;
created_at: string;
published: boolean;
};
export type LegacyImportReport = {
dry_run: boolean;
users: LegacyUserReport;
boards?: LegacyBoardReport;
posts?: LegacyPostReport;
comments?: LegacyCommentReport;
user_list?: LegacyUserPreview[];
post_list?: LegacyPostPreview[];
comment_list?: LegacyCommentPreview[];
notes?: string[];
};
export type LegacyImportOptions = {
withContent: boolean;
dryRun: boolean;
/** 不创建账号的旧用户 ID(内容仍按归属规则落位) */
skipUsers?: number[];
/** 内容归到站长的旧用户 ID */
operatorUsers?: number[];
/** 旧用户 ID → 指定已有账号用户名(内容归属) */
userMap?: { oldId: number; username: string }[];
/** 排除的旧帖 ID(其评论自动跳过) */
skipPosts?: number[];
/** 排除的旧评论 ID */
skipComments?: number[];
};
/** 上传旧站 SQLite 库(必填)与可选头像 zip,导入用户与内容。幂等可重复执行 */
/** 上传旧站 SQLite 库(必填)、可选头像 / 帖子图片 zip 与勾选参数,导入用户与内容。幂等可重复执行 */
export async function apiAdminImportLegacy(
source: string,
dbFile: File,
zipFile: File | null,
imagesZipFile: File | null,
opts: LegacyImportOptions
): Promise<LegacyImportReport> {
const form = new FormData();
form.append("db_file", dbFile, dbFile.name || "jiang13.db");
if (zipFile) form.append("avatars_zip", zipFile, zipFile.name || "avatars.zip");
if (imagesZipFile) form.append("images_zip", imagesZipFile, imagesZipFile.name || "images.zip");
form.append("with_content", String(opts.withContent));
form.append("dry_run", String(opts.dryRun));
if (opts.skipUsers?.length) form.append("skip_users", opts.skipUsers.join(","));
if (opts.operatorUsers?.length) form.append("operator_users", opts.operatorUsers.join(","));
if (opts.userMap?.length)
form.append("user_map", opts.userMap.map((m) => `${m.oldId}:${m.username}`).join(","));
if (opts.skipPosts?.length) form.append("skip_posts", opts.skipPosts.join(","));
if (opts.skipComments?.length) form.append("skip_comments", opts.skipComments.join(","));
const res = await fetchWithRefresh(`/api/admin/import/${source}`, {
method: "POST",
headers: clientHeaders(),
@@ -4101,6 +4188,7 @@ export interface AdDurationOption {
days: number;
label: string;
price_hint: string;
price_hint_text: string;
}
export interface AdPaymentOption {
@@ -4293,6 +4381,35 @@ export async function fetchAdminSidebar(
return res.json();
}
/** 媒体库条目(管理后台全站图片盘点) */
export interface MediaLibraryItem {
url: string;
original_url?: string; // 同名原图(仅当原图与 WebP 并存)
category: string; // image/avatar/background/ads/brand
category_name: string;
name: string;
mime: string;
size: number;
width: number;
height: number;
uploader?: string;
uploaded_at?: string;
source_url?: string; // 来源回链(帖子/评论锚点/用户主页)
source_label?: string; // 来源描述(如:帖子《…》下的评论)
}
/** SSR 管理端媒体库:全站图片盘点 */
export async function fetchAdminMediaLibrary(
cookie?: string
): Promise<{ items: MediaLibraryItem[]; counts: Record<string, number>; total: number }> {
const res = await fetch(`${API_BASE}/api/admin/media-library`, {
...ssrInit(cookie ? { Cookie: cookie } : undefined),
credentials: "include",
});
if (!res.ok) throw new Error("加载媒体库失败");
return res.json();
}
export async function apiAdminGetSidebar(): Promise<{
config: SidebarConfig;
widgets: SidebarWidgetRecord[];

View File

@@ -34,7 +34,7 @@ export function authCookieHeader(store: CookieReader): string {
}
/**
* 登录设备列表 SSR:必须同时转发 refresh cookie,才能识别「当前会话」并校准 IP/UA。
* 登录设备列表 SSR:必须同时转发 refresh cookie,才能识别「当前会话」。
* 其它 SSR 接口不要用这个,避免把 refresh token 带到无关后端请求。
*/
export function sessionCookieHeader(store: CookieReader): string {

View File

@@ -23,7 +23,7 @@ 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}
// 登录态被剔除(安全设置剔除设备、会话数超上限被淘汰),推 user:{id}
export const RT_SESSION_REPLACED = "session:replaced";
export interface RtEnvelope<T = unknown> {

View File

@@ -33,7 +33,7 @@ export function onUserUpdate(handler: (patch: Partial<User>) => void): () => voi
*
* 触发源统一收敛在 lib/api.ts:任何客户端请求收到后端的
* code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发;
* 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备)经探测确认后
* 登录态被剔除(安全设置剔除设备、会话数超上限被淘汰)经探测确认后
* 以 reason=session_replaced 派发。Header 负责复用登出清理链路
* (清 cookie、游客化、router.refresh),AccountGuard 负责弹出对应告知——
* 状态清理与告知 UI 解耦,避免各请求调用方各自处理导致残留