feat(frontend): add api client and socket client wrappers with runtime config

This commit is contained in:
root
2026-05-12 13:46:19 +00:00
parent b3291b5874
commit 6219fbbcfe
5 changed files with 227 additions and 2 deletions

31
src/lib/api-client.ts Normal file
View File

@@ -0,0 +1,31 @@
import axios from 'axios'
import type { AxiosInstance } from 'axios'
import { getRuntimeConfig } from './runtime-config'
let client: AxiosInstance | null = null
function createClient(): AxiosInstance {
const cfg = getRuntimeConfig()
const instance = axios.create({
baseURL: cfg.guildApiBase,
timeout: 10000,
})
instance.interceptors.request.use((request) => {
const { apiKey } = getRuntimeConfig()
if (apiKey) request.headers['x-api-key'] = apiKey
request.headers['x-request-id'] = crypto.randomUUID()
return request
})
return instance
}
export function getApiClient(): AxiosInstance {
if (!client) client = createClient()
return client
}
export function resetApiClient(): void {
client = createClient()
}

27
src/lib/runtime-config.ts Normal file
View File

@@ -0,0 +1,27 @@
export type RuntimeConfig = {
guildApiBase: string
guildSocketBase: string
apiKey: string
}
const KEY = 'fabric.runtime.config.v1'
const defaults: RuntimeConfig = {
guildApiBase: import.meta.env.VITE_GUILD_API_BASE ?? 'http://localhost:7002/api',
guildSocketBase: import.meta.env.VITE_GUILD_SOCKET_BASE ?? 'http://localhost:7002/realtime',
apiKey: import.meta.env.VITE_API_KEY ?? '',
}
export function getRuntimeConfig(): RuntimeConfig {
const raw = localStorage.getItem(KEY)
if (!raw) return defaults
try {
return { ...defaults, ...(JSON.parse(raw) as Partial<RuntimeConfig>) }
} catch {
return defaults
}
}
export function setRuntimeConfig(next: RuntimeConfig): void {
localStorage.setItem(KEY, JSON.stringify(next))
}

36
src/lib/socket-client.ts Normal file
View File

@@ -0,0 +1,36 @@
import { io, Socket } from 'socket.io-client'
import { getRuntimeConfig } from './runtime-config'
let socket: Socket | null = null
export function getSocketClient(userId = 'frontend-user'): Socket {
if (socket) return socket
const cfg = getRuntimeConfig()
socket = io(cfg.guildSocketBase, {
transports: ['websocket'],
auth: {
apiKey: cfg.apiKey,
userId,
},
autoConnect: false,
})
return socket
}
export function reconnectSocket(): Socket {
if (socket) {
socket.disconnect()
socket = null
}
const next = getSocketClient()
next.connect()
return next
}
export function disconnectSocket(): void {
if (!socket) return
socket.disconnect()
socket = null
}

View File

@@ -1,8 +1,86 @@
import { useEffect, useMemo, useState } from 'react'
import { getApiClient } from '../lib/api-client'
import { disconnectSocket, getSocketClient } from '../lib/socket-client'
type MessageItem = {
messageId: string
seq: number
content: string
isDeleted?: boolean
}
export default function ChatPage() { export default function ChatPage() {
const [channelId, setChannelId] = useState('')
const [content, setContent] = useState('')
const [messages, setMessages] = useState<MessageItem[]>([])
const [socketState, setSocketState] = useState<'offline' | 'online'>('offline')
const socket = useMemo(() => getSocketClient('frontend-user'), [])
useEffect(() => {
socket.on('connect', () => setSocketState('online'))
socket.on('disconnect', () => setSocketState('offline'))
socket.on('message.created', (m: MessageItem) => setMessages((prev) => [...prev, m]))
socket.on('message.updated', (m: MessageItem) =>
setMessages((prev) => prev.map((x) => (x.messageId === m.messageId ? m : x))),
)
socket.on('message.deleted', (m: MessageItem) =>
setMessages((prev) =>
prev.map((x) => (x.messageId === m.messageId ? { ...x, isDeleted: true, content: '[deleted]' } : x)),
),
)
socket.connect()
return () => {
socket.removeAllListeners()
disconnectSocket()
}
}, [socket])
async function pullMessages() {
if (!channelId) return
const res = await getApiClient().get(`/channels/${channelId}/messages`, {
params: { seq_from: 1, seq_to: 999999, limit: 100 },
})
setMessages(res.data.items ?? [])
}
async function sendMessage() {
if (!channelId || !content.trim()) return
await getApiClient().post(`/channels/${channelId}/messages`, {
content,
authorUserId: 'frontend-user',
})
setContent('')
}
useEffect(() => {
if (!channelId || !socket.connected) return
socket.emit('join_channel', { channelId })
return () => {
socket.emit('leave_channel', { channelId })
}
}, [channelId, socket, socketState])
return ( return (
<section> <section>
<h2></h2> <h2></h2>
<p>///</p> <p>Socket: {socketState}</p>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input value={channelId} onChange={(e) => setChannelId(e.target.value)} placeholder="Channel ID" />
<button onClick={pullMessages}></button>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<input value={content} onChange={(e) => setContent(e.target.value)} placeholder="输入消息" />
<button onClick={sendMessage}></button>
</div>
<ul>
{messages.map((m) => (
<li key={m.messageId}>
#{m.seq} {m.content}
</li>
))}
</ul>
</section> </section>
) )
} }

View File

@@ -1,8 +1,61 @@
import { useState } from 'react'
import type { FormEvent } from 'react'
import { getApiClient, resetApiClient } from '../lib/api-client'
import {
getRuntimeConfig,
setRuntimeConfig,
} from '../lib/runtime-config'
import type { RuntimeConfig } from '../lib/runtime-config'
import { reconnectSocket } from '../lib/socket-client'
export default function WorkspacePage() { export default function WorkspacePage() {
const [form, setForm] = useState<RuntimeConfig>(getRuntimeConfig())
const [health, setHealth] = useState('')
async function onSave(e: FormEvent) {
e.preventDefault()
setRuntimeConfig(form)
resetApiClient()
reconnectSocket()
setHealth('配置已保存')
}
async function checkHealth() {
try {
const res = await getApiClient().get('/healthz')
setHealth(JSON.stringify(res.data))
} catch {
setHealth('healthz 访问失败')
}
}
return ( return (
<section> <section>
<h2></h2> <h2></h2>
<p> Guild/Channel </p> <form onSubmit={onSave} style={{ display: 'grid', gap: 8, maxWidth: 760 }}>
<input
value={form.guildApiBase}
onChange={(e) => setForm((v) => ({ ...v, guildApiBase: e.target.value }))}
placeholder="Guild API Base"
/>
<input
value={form.guildSocketBase}
onChange={(e) => setForm((v) => ({ ...v, guildSocketBase: e.target.value }))}
placeholder="Guild Socket Base"
/>
<input
value={form.apiKey}
onChange={(e) => setForm((v) => ({ ...v, apiKey: e.target.value }))}
placeholder="API Key"
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit"></button>
<button type="button" onClick={checkHealth}>
healthz
</button>
</div>
</form>
<p>{health}</p>
</section> </section>
) )
} }