diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..d7edb2d --- /dev/null +++ b/src/lib/api-client.ts @@ -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() +} diff --git a/src/lib/runtime-config.ts b/src/lib/runtime-config.ts new file mode 100644 index 0000000..4f3f427 --- /dev/null +++ b/src/lib/runtime-config.ts @@ -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) } + } catch { + return defaults + } +} + +export function setRuntimeConfig(next: RuntimeConfig): void { + localStorage.setItem(KEY, JSON.stringify(next)) +} diff --git a/src/lib/socket-client.ts b/src/lib/socket-client.ts new file mode 100644 index 0000000..d95966b --- /dev/null +++ b/src/lib/socket-client.ts @@ -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 +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 61e825e..b227dc0 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -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() { + const [channelId, setChannelId] = useState('') + const [content, setContent] = useState('') + const [messages, setMessages] = useState([]) + 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 (

聊天

-

下一步接入消息拉取/发送/编辑/删除与实时订阅。

+

Socket: {socketState}

+
+ setChannelId(e.target.value)} placeholder="Channel ID" /> + +
+
+ setContent(e.target.value)} placeholder="输入消息" /> + +
+
    + {messages.map((m) => ( +
  • + #{m.seq} {m.content} +
  • + ))} +
) } diff --git a/src/pages/WorkspacePage.tsx b/src/pages/WorkspacePage.tsx index a9a6e26..f127a40 100644 --- a/src/pages/WorkspacePage.tsx +++ b/src/pages/WorkspacePage.tsx @@ -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() { + const [form, setForm] = useState(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 (

工作台

-

这里会展示 Guild/Channel 概览与会话入口。

+
+ setForm((v) => ({ ...v, guildApiBase: e.target.value }))} + placeholder="Guild API Base" + /> + setForm((v) => ({ ...v, guildSocketBase: e.target.value }))} + placeholder="Guild Socket Base" + /> + setForm((v) => ({ ...v, apiKey: e.target.value }))} + placeholder="API Key" + /> +
+ + +
+
+

{health}

) }