@@ -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 > {
cons t res = await fetch ( "/api/me" , {
le t res = await fetch ( "/api/me" , {
credentials : "include" ,
cache : "no-store" ,
} ) ;
cons t data : MeResponse = await res . json ( ) ;
le t 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 [ ] ;