feat(frontend): 搭建桌面端基础界面与 Workspace

- App Shell 壳层:主导航、次导航、Title Bar、状态栏
- Vault 入口页:最近 Vault、打开/创建 Vault、AI Core 状态
- Workspace 与文件树:浏览、打开、创建笔记/文件夹
- Design Token:浅色/深色主题 CSS Variables
- Contracts / Service / Store / Router 基础架构
- 统一 ApiClient 与 SseClient,对接后端 API 契约
This commit is contained in:
2026-08-29 10:36:16 +08:00
parent 694c1b27c1
commit f9efc4f4a2
42 changed files with 8044 additions and 167 deletions
+114
View File
@@ -0,0 +1,114 @@
export type SseEventHandler = (event: string, data: Record<string, unknown>) => void
export interface SseClientOptions {
url: string
method?: string
body?: unknown
token?: string
onEvent?: SseEventHandler
onError?: (error: Error) => void
onOpen?: () => void
onDone?: () => void
}
export class SseClient {
private controller: AbortController
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null
private options: SseClientOptions
private buffer = ''
private connected = false
constructor(options: SseClientOptions) {
this.options = options
this.controller = new AbortController()
}
async connect() {
const { url, method = 'POST', body, token, onEvent, onError, onOpen, onDone } = this.options
try {
const headers: Record<string, string> = {
Accept: 'text/event-stream',
}
if (body !== undefined) {
headers['Content-Type'] = 'application/json'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const resp = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: this.controller.signal,
})
if (!resp.ok || !resp.body) {
throw new Error(`SSE connection failed: ${resp.status}`)
}
this.reader = resp.body.getReader()
this.connected = true
onOpen?.()
const decoder = new TextDecoder('utf-8')
while (true) {
const { value, done } = await this.reader.read()
if (done) break
this.buffer += decoder.decode(value, { stream: true })
const lines = this.buffer.split('\n')
this.buffer = lines.pop() || ''
let eventName = 'message'
let dataStr = ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
if (dataStr) {
try {
const data = JSON.parse(dataStr)
onEvent?.(eventName, data)
if (eventName === 'Done' || eventName === 'RunCompleted' || eventName === 'RunFailed' || eventName === 'RunCancelled') {
onDone?.()
}
} catch {
/* ignore malformed json */
}
eventName = 'message'
dataStr = ''
}
continue
}
if (trimmed.startsWith('event:')) {
eventName = trimmed.slice(6).trim()
} else if (trimmed.startsWith('data:')) {
const d = trimmed.slice(5).trim()
dataStr += dataStr ? '\n' + d : d
}
}
}
} catch (e) {
if ((e as Error).name === 'AbortError') return
onError?.(e as Error)
} finally {
this.connected = false
this.reader = null
}
}
cancel() {
this.controller.abort()
}
isConnected() {
return this.connected
}
}
export default SseClient