Refactor frontend workspaces and centralize API contracts
Organize domain APIs and types under common/apis, shared components under named folders, and private workspace logic alongside each page. Use Bearer sessions, explicit response envelopes, session-aware routing and streams, and configurable login captcha presentation for the deployed backend contract. Preserve news, economic calendar, crawler, market, chat, Skills, settings, and demo trading workflows with responsive layouts and explicit configuration saves. Validate with frontend lint, type checking, 22 test groups, production builds, and deployed desktop/mobile page checks.
This commit is contained in:
1 parent
77b33e5e77
commit
bc480fb806
102 files changed
+4716
-1707
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import type { AxiosRequestConfig } from "axios"
|
||||
import axios, { CanceledError } from "axios"
|
||||
import { ElMessage } from "element-plus"
|
||||
import { useUserStore } from "@/pinia/stores/user"
|
||||
import { router } from "@/router"
|
||||
|
||||
declare module "axios" {
|
||||
interface AxiosRequestConfig {
|
||||
anonymous?: boolean
|
||||
sessionVersion?: number
|
||||
notify?: boolean
|
||||
/** 明确声明未包装的 JSON,例如 OpenAPI;不自动探测响应格式。 */
|
||||
rawResponse?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
code: number
|
||||
data: T
|
||||
message: string
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public code: number, message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export function readEnvelope<T>(value: unknown): ApiEnvelope<T> {
|
||||
if (!value || typeof value !== "object" || !("code" in value) || typeof value.code !== "number"
|
||||
|| !("message" in value) || typeof value.message !== "string" || !("data" in value)) {
|
||||
throw new ApiError(502, "服务器响应格式无效")
|
||||
}
|
||||
return value as ApiEnvelope<T>
|
||||
}
|
||||
|
||||
/** 无 Vue 生命周期接管的轮询回调:请求层已提示,保留真实错误供排查。 */
|
||||
export function logRequestError(error: unknown) {
|
||||
if (!axios.isCancel(error)) console.error(error)
|
||||
}
|
||||
|
||||
export function rejectAuthentication(version: number, redirect = true) {
|
||||
const user = useUserStore()
|
||||
if (version !== user.sessionVersion)
|
||||
return
|
||||
user.setToken("")
|
||||
const route = router.currentRoute.value
|
||||
if (redirect && route.name !== "login")
|
||||
void router.replace({ name: "login", query: { redirect: route.fullPath } })
|
||||
}
|
||||
|
||||
const client = axios.create({ baseURL: "/api", timeout: 15000 })
|
||||
client.interceptors.request.use((config) => {
|
||||
const user = useUserStore()
|
||||
if (config.sessionVersion !== user.sessionVersion || (!config.anonymous && !user.token))
|
||||
throw new CanceledError("会话已切换")
|
||||
if (!config.anonymous && user.token)
|
||||
config.headers.set("Authorization", `Bearer ${user.token}`)
|
||||
return config
|
||||
})
|
||||
|
||||
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
const version = useUserStore().sessionVersion
|
||||
config.sessionVersion = version
|
||||
try {
|
||||
const response = await client.request(config)
|
||||
if (version !== useUserStore().sessionVersion)
|
||||
throw new CanceledError("会话已切换")
|
||||
if (config.rawResponse) return response.data
|
||||
const body = readEnvelope<T>(response.data)
|
||||
if (body.code !== 0)
|
||||
throw new ApiError(body.code, body.message)
|
||||
return body.data
|
||||
} catch (error) {
|
||||
if (version !== useUserStore().sessionVersion)
|
||||
throw new CanceledError("会话已切换")
|
||||
if (axios.isCancel(error))
|
||||
throw error
|
||||
let reason = error
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
try {
|
||||
const body = readEnvelope(error.response.data)
|
||||
reason = new ApiError(error.response.status, body.message || `请求失败:HTTP ${error.response.status}`)
|
||||
} catch (invalid) {
|
||||
reason = new ApiError(error.response.status, invalid instanceof Error ? invalid.message : "服务器响应格式无效")
|
||||
}
|
||||
}
|
||||
if (reason instanceof ApiError && reason.code === 401 && !config.anonymous) {
|
||||
rejectAuthentication(version, config.url !== "/auth/me")
|
||||
} else if (config.notify !== false) {
|
||||
ElMessage.error(reason instanceof Error ? reason.message : "请求失败")
|
||||
}
|
||||
throw reason
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import axios from "axios"
|
||||
import { ElMessage } from "element-plus"
|
||||
import { router } from "@/router"
|
||||
|
||||
// 鉴权靠 httpOnly cookie,同源请求自动携带
|
||||
export const http = axios.create({ baseURL: "/api", timeout: 15000 })
|
||||
|
||||
http.interceptors.response.use(
|
||||
response => response.data,
|
||||
(error) => {
|
||||
const url = error.config?.url
|
||||
// /auth/me 的 401 由路由守卫处理;其余接口 401 说明登录过期
|
||||
if (error.response?.status === 401 && url !== "/auth/login") {
|
||||
if (url !== "/auth/me")
|
||||
router.replace({ name: "login", query: { redirect: router.currentRoute.value.fullPath } })
|
||||
} else {
|
||||
ElMessage.error(error.response?.data?.detail ?? error.message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface StreamEvent {
|
||||
event: string
|
||||
data: string
|
||||
}
|
||||
|
||||
/** 按 SSE 行语义解析,支持分块、CRLF、多行 data 和最后一个未换行块。 */
|
||||
export async function* parseEvents(body: ReadableStream<Uint8Array>): AsyncGenerator<StreamEvent> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
let event = "message"
|
||||
let data: string[] = []
|
||||
function line(value: string): StreamEvent | undefined {
|
||||
if (!value) {
|
||||
const result = data.length ? { event, data: data.join("\n") } : undefined
|
||||
event = "message"
|
||||
data = []
|
||||
return result
|
||||
}
|
||||
if (value.startsWith(":"))
|
||||
return
|
||||
const colon = value.indexOf(":")
|
||||
const field = colon < 0 ? value : value.slice(0, colon)
|
||||
let content = colon < 0 ? "" : value.slice(colon + 1)
|
||||
if (content.startsWith(" ")) content = content.slice(1)
|
||||
if (field === "event") event = content
|
||||
if (field === "data") data.push(content)
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
buffer += decoder.decode(value, { stream: !done })
|
||||
while (true) {
|
||||
const end = buffer.search(/[\r\n]/)
|
||||
if (end < 0) break
|
||||
if (!done && buffer[end] === "\r" && end === buffer.length - 1)
|
||||
break
|
||||
const width = buffer[end] === "\r" && buffer[end + 1] === "\n" ? 2 : 1
|
||||
const result = line(buffer.slice(0, end))
|
||||
buffer = buffer.slice(end + width)
|
||||
if (result) yield result
|
||||
}
|
||||
if (done) {
|
||||
if (buffer) line(buffer)
|
||||
const result = line("")
|
||||
if (result) yield result
|
||||
return
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import axios, { CanceledError } from "axios"
|
||||
import { ApiError, readEnvelope, rejectAuthentication } from "@/http/axios"
|
||||
import { useUserStore } from "@/pinia/stores/user"
|
||||
import { parseEvents } from "./sse"
|
||||
|
||||
/** 只执行一次请求;重连只由只读订阅的调用方负责。 */
|
||||
export async function consumeStream<T>(url: string, init: RequestInit, version: number, onOpen: () => void, onEvent: (event: string, data: T) => void) {
|
||||
const user = useUserStore()
|
||||
const assertCurrent = () => {
|
||||
if (version !== user.sessionVersion || !user.token || init.signal?.aborted)
|
||||
throw new CanceledError("会话或订阅已结束")
|
||||
}
|
||||
assertCurrent()
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: { ...init.headers, Authorization: `Bearer ${user.token}` }
|
||||
})
|
||||
assertCurrent()
|
||||
if (!response.ok) {
|
||||
const body = readEnvelope(await response.json())
|
||||
assertCurrent()
|
||||
if (response.status === 401)
|
||||
rejectAuthentication(version)
|
||||
throw new ApiError(response.status, body.message || `请求失败:HTTP ${response.status}`)
|
||||
}
|
||||
if (!response.body || !response.headers.get("content-type")?.startsWith("text/event-stream"))
|
||||
throw new ApiError(502, "服务器未返回事件流")
|
||||
onOpen()
|
||||
for await (const event of parseEvents(response.body)) {
|
||||
assertCurrent()
|
||||
const data = JSON.parse(event.data)
|
||||
if (event.event === "auth_error") {
|
||||
rejectAuthentication(version)
|
||||
throw new ApiError(401, data.message)
|
||||
}
|
||||
if (event.event === "error" && typeof data.code === "number")
|
||||
throw new ApiError(data.code, data.message)
|
||||
onEvent(event.event, data)
|
||||
}
|
||||
assertCurrent()
|
||||
}
|
||||
|
||||
export function streamCanceled(error: unknown): boolean {
|
||||
return axios.isCancel(error) || (error instanceof Error && error.name === "AbortError")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ElMessage } from "element-plus"
|
||||
import { ApiError } from "@/http/axios"
|
||||
import { useUserStore } from "@/pinia/stores/user"
|
||||
import { streamCanceled } from "./stream"
|
||||
|
||||
/** Bearer SSE:只有只读订阅重连;身份失效和权限拒绝均停止。 */
|
||||
export function useEventSource<T>(subscribe: (signal: AbortSignal, version: number, onOpen: () => void, onMessage: (data: T) => void) => Promise<void>, onMessage: (data: T) => void) {
|
||||
const connected = ref(false)
|
||||
const user = useUserStore()
|
||||
const version = user.sessionVersion
|
||||
let closed = false
|
||||
let retryMs = 1000
|
||||
let controller: AbortController | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let reported = false
|
||||
async function connect() {
|
||||
if (closed || version !== user.sessionVersion)
|
||||
return
|
||||
controller = new AbortController()
|
||||
try {
|
||||
await subscribe(controller.signal, version, () => {
|
||||
connected.value = true
|
||||
retryMs = 1000
|
||||
reported = false
|
||||
}, onMessage)
|
||||
} catch (error) {
|
||||
if (streamCanceled(error)) return
|
||||
if (error instanceof ApiError && [401, 403].includes(error.code)) {
|
||||
closed = true
|
||||
if (error.code === 403) ElMessage.error(error.message)
|
||||
return
|
||||
}
|
||||
if (!reported) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "实时连接中断")
|
||||
reported = true
|
||||
}
|
||||
} finally {
|
||||
connected.value = false
|
||||
}
|
||||
if (!closed && version === user.sessionVersion) {
|
||||
timer = setTimeout(connect, retryMs)
|
||||
retryMs = Math.min(retryMs * 2, 30000)
|
||||
}
|
||||
}
|
||||
function close() {
|
||||
closed = true
|
||||
connected.value = false
|
||||
clearTimeout(timer)
|
||||
controller?.abort()
|
||||
}
|
||||
watch(() => user.sessionVersion, close, { flush: "sync" })
|
||||
void connect()
|
||||
onBeforeUnmount(close)
|
||||
return { connected }
|
||||
}
|
||||
Reference in new issue
Block a user