chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 26620707a0
347 files changed
+56172

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
/**
* 数据源:前端默认和后端同源(Cookie 会话)。也可以指到别的后端(如本机跑前端、连线上的 `https://mo.oeo.one`):
* 地址和令牌存在浏览器里(每个前端各存一份令牌),也可以用构建时的 `VITE_API_BASE` 给个默认地址。
* 跨域时不带 Cookie,只用 `Authorization: Bearer <API 令牌>`;后端要在 `Monitor__CorsOrigins` 里放行这个前端的来源。
*/
const BASE_KEY = 'monitor.api_base'
let app = 'app'
/** 每个前端启动时调用一次,令牌按前端分开存(展示端用只读令牌,管理端用管理令牌)。 */
export function configureSource(name: string) {
app = name
}
function read(key: string): string {
try {
return localStorage.getItem(key) ?? ''
} catch {
return ''
}
}
function write(key: string, value: string) {
try {
if (value) localStorage.setItem(key, value)
else localStorage.removeItem(key)
} catch {
// 浏览器禁用存储时只是不记住
}
}
export function normalizeBase(input: string): string {
const text = input.trim().replace(/\/+$/, '')
if (!text) return ''
return /^https?:\/\//i.test(text) ? text : `https://${text}`
}
/** 后端地址(不带末尾斜杠);空串 = 同源。 */
export function getBase(): string {
return normalizeBase(read(BASE_KEY) || (import.meta.env.VITE_API_BASE as string | undefined) || '')
}
export function getToken(): string {
return read(`monitor.api_token.${app}`)
}
export function setSource(base: string, token: string) {
write(BASE_KEY, normalizeBase(base))
write(`monitor.api_token.${app}`, token.trim())
}
export function setToken(token: string) {
write(`monitor.api_token.${app}`, token.trim())
}
export const isRemote = () => getBase() !== ''
/** 拼出请求地址:同源时原样返回。 */
export function resolveUrl(path: string): string {
return getBase() + path
}
export function authHeaders(): Record<string, string> {
const token = getToken()
return isRemote() && token ? { Authorization: `Bearer ${token}` } : {}
}
export interface LiveSource {
onopen: (() => void) | null
onerror: (() => void) | null
addEventListener: (type: string, listener: (event: MessageEvent<string>) => void) => void
close: () => void
}
/**
* SSE 订阅。同源用浏览器的 EventSource;跨域时 EventSource 带不了 Authorization 头,
* 所以用 fetch 读流并自己解析,断开后 3 秒重连。
*/
export function openLive(path: string): LiveSource {
if (!isRemote()) return new EventSource(path) as unknown as LiveSource
const listeners = new Map<string, ((event: MessageEvent<string>) => void)[]>()
const source: LiveSource = {
onopen: null,
onerror: null,
addEventListener: (type, listener) => listeners.set(type, [...(listeners.get(type) ?? []), listener]),
close: () => {
closed = true
controller?.abort()
},
}
let closed = false
let controller: AbortController | undefined
const run = async () => {
while (!closed) {
controller = new AbortController()
try {
const response = await fetch(resolveUrl(path), { headers: { Accept: 'text/event-stream', ...authHeaders() }, signal: controller.signal })
if (!response.ok || !response.body) throw new Error(String(response.status))
source.onopen?.()
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += value
let at: number
while ((at = buffer.search(/\r?\n\r?\n/)) >= 0) {
const block = buffer.slice(0, at)
buffer = buffer.slice(at).replace(/^\r?\n\r?\n/, '')
let type = 'message'
const data: string[] = []
for (const line of block.split(/\r?\n/)) {
if (line.startsWith('event:')) type = line.slice(6).trim()
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''))
}
if (data.length > 0) for (const l of listeners.get(type) ?? []) l({ data: data.join('\n') } as MessageEvent<string>)
}
}
} catch {
// 下面统一报错并重连
}
if (closed) return
source.onerror?.()
await new Promise((resolve) => setTimeout(resolve, 3000))
}
}
void run()
return source
}