feat(frontend): add api client and socket client wrappers with runtime config
This commit is contained in:
31
src/lib/api-client.ts
Normal file
31
src/lib/api-client.ts
Normal 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
27
src/lib/runtime-config.ts
Normal 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
36
src/lib/socket-client.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user