feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user