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:
eezd committed 2026-10-26 11:39:52 +08:00
1 parent 77b33e5e77
commit bc480fb806
102 files changed
+4716 -1707

No files matched your search

+95
View File
@@ -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
}
}
-21
View File
@@ -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)
}
)
+54
View File
@@ -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()
}
}
+45
View File
@@ -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")
}
+55
View File
@@ -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 }
}