Files
crawler-plugin/admin-frontend-vue/src/api/envelope.ts
T
huangzd1997 b70557a077 feat(认证/通知): 单设备登录互踢 + 站内通知铃铛系统
- 单设备登录:登录成功即 last-login-wins 绑定 users.machine;非超管旧 token
  在下一次受保护请求抛 4011 下线,超管豁免;仅认 token 内签名 deviceId,不用请求头。
  前端两端接入踢下线跳转(?kicked=1 提示),V118 清空历史 machine
- 站内通知:新增 notification 模块(任务失败扫描 / 密钥欠费 / 下游服务探测三类来源),
  前后台铃铛组件 + 轮询;后台列表按主管数据范围(UserDataScopeSupport)过滤;V116 建表

均已于 2026-09-13 部署上线,此次补提交源码(此前仅存在于已部署 JAR/构建产物中)
2026-09-13 23:08:22 +08:00

110 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** API 响应解包与错误归一化(任务 22):纯函数,无框架依赖。 */
export const REQUEST_FALLBACK_MESSAGE = '网络异常,请稍后重试'
function textOf(value: Record<string, unknown>): string {
const message = value.message
const error = value.error
if (typeof message === 'string' && message.trim()) return message.trim()
if (typeof error === 'string' && error.trim()) return error.trim()
return ''
}
/**
* 统一解包 Java 响应:
* - success=false -> 抛错(取 message/error,空则“请求失败”);
* - 带非空 data -> 返回 data
* - 否则原样返回(允许 {item}/{items} 等信封由调用方再解)。
*/
export function unwrap<T>(payload: unknown): T {
const value = payload as Record<string, unknown> | null
if (!value || typeof value !== 'object') return payload as T
if (value.success === false) {
throw new Error(textOf(value) || '请求失败')
}
if ('data' in value && value.data !== null && value.data !== undefined) {
return value.data as T
}
return payload as T
}
/** 从任意负载取错误文案;缺省回退。 */
export function errorTextOf(payload: unknown): string {
if (payload instanceof Error) return payload.message.trim() || REQUEST_FALLBACK_MESSAGE
if (payload && typeof payload === 'object') {
const text = textOf(payload as Record<string, unknown>)
if (text) return text
}
if (typeof payload === 'string' && payload.trim()) return payload.trim()
return REQUEST_FALLBACK_MESSAGE
}
/** 负载/状态码是否 401(未登录)。 */
export function isUnauthorized(payload: unknown): boolean {
const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null
if (!record || typeof record !== 'object') return false
return [record.status, record.statusCode, record.code].some((v) => v === 401)
}
/** 负载/状态码是否 403(已登录但无后台权限)。 */
export function isForbidden(payload: unknown): boolean {
const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null
if (!record || typeof record !== 'object') return false
return [record.status, record.statusCode, record.code].some((v) => v === 403)
}
/** 单设备登录:账号已在其他设备登录(4011,与后端 DeviceSessionPolicy.CODE_KICKED 对齐)。 */
export function isKicked(payload: unknown): boolean {
const record = payload as { code?: unknown } | null
if (!record || typeof record !== 'object') return false
return record.code === 4011
}
/** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */
export function requestErrorMessage(error: unknown): string {
const response = (error as { response?: { data?: unknown; status?: number } })?.response
if (response) {
const text = errorTextOf(response.data)
if (text) return text
}
return errorTextOf(error)
}
/** 当前地址作为登录跳转的 redirect 参数(encodeURIComponent 后)。 */
export function loginRedirectTarget(location: { pathname: string; search: string }): string {
return encodeURIComponent(`${location.pathname}${location.search}`)
}
/** 登录页统一入口;401 一律跳此路径。 */
export const LOGIN_PATH = '/login'
/** 当前 pathname 是否处于登录页(去掉可能携带的查询串后比较)。 */
export function isLoginLocation(pathname: string): boolean {
if (typeof pathname !== 'string') return false
const queryAt = pathname.indexOf('?')
return (queryAt >= 0 ? pathname.slice(0, queryAt) : pathname) === LOGIN_PATH
}
/** 把请求 URL(相对或绝对)规整为相对路径,便于与登录入口比较。 */
export function requestRelativePath(url: string): string {
if (typeof url !== 'string') return ''
const noQuery = url.split('?')[0]
const match = noQuery.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(.*)$/)
return match ? match[1] : noQuery
}
/** 401 失败请求本身是否指向登录/认证端点(登录提交失败不应再被踢回登录页)。 */
export function isAuthEndpointRequest(url: string | undefined): boolean {
return requestRelativePath(url || '') === LOGIN_PATH
}
/**
* 401 是否应执行“跳 /login”:已处于登录页或失败请求即登录端点时不应再跳,
* 否则会在登录页循环跳转或吞掉登录失败反馈。
*/
export function shouldRedirectUnauthorized(currentPathname: string, requestUrl?: string): boolean {
if (isLoginLocation(currentPathname)) return false
if (isAuthEndpointRequest(requestUrl)) return false
return true
}