重构: df-miniapp移入apps/(monorepo分层 crates=Rust库/apps=前端应用)

- df-miniapp/ -> apps/df-miniapp/(uni-app 工程归集前端应用层,与 crates/ Rust 库分层)
- 删冗余 apps/df-miniapp/.gitignore(根已覆盖 node_modules/dist/.DS_Store 等)
- 根 .gitignore 加 unpackage/(uni-app 编译产物,唯一根未覆盖项)
- vue-tsc 零错(移动不改代码,类型不变)
This commit is contained in:
2026-06-22 02:19:23 +08:00
parent 280baeaee0
commit a5a4fc887a
19 changed files with 3 additions and 8 deletions

View File

@@ -0,0 +1,279 @@
/**
* df-relay WS 客户端(uni-app 跨端适配)
*
* 协议(对齐 crates/df-relay/src/relay.rs handle_connection):
* 1. 建立 WS 连接到 `/ws/miniapp`
* 2. 首帧发 Hello{kind:'miniapp', device_id, token} 身份宣告(relay.rs:197-208,10s 超时)
* 3. 校验通过后进入收发循环;失败 relay 发 Error 帧 + Close
*
* 跨端适配:
* - 微信小程序:uni.connectSocket(微信原生 API,非浏览器 WebSocket)
* - H5:uni.connectSocket 封装浏览器 WebSocket
* - 统一经 uni API 封装,业务层不直接 new WebSocket
*
* 机制:
* - 重连:指数退避(baseDelay * 2^n,封顶 maxDelay),握手失败/网络断/服务关闭触发
* - 心跳:固定间隔发 ControlMessage{control_kind:'ping'},防空闲断开 + 在线状态
* - JSON 透传:入站解析为 BroadcastMessage,payload 是 AiChatEvent JSON
* 出站直接发 Command JSON(relay 自动包 payload,见 relay.rs:332-365)
*/
import { getConfig } from '@/config'
import type { BroadcastMessage, Hello, MiniCommand, ControlMessage } from '@/types/relay'
/** WS 连接状态 */
export type WsStatus = 'disconnected' | 'connecting' | 'connected' | 'handshaking' | 'reconnecting'
/** 入站事件回调(Event/Control 消息分派) */
export interface WsHandlers {
/** 收到 Event 消息(payload 已解为 AiChatEvent JSON,业务层按 type discriminator 处理) */
onEvent: (msg: BroadcastMessage) => void
/** 收到 Control 消息(心跳响应 / 在线状态等) */
onControl: (msg: ControlMessage) => void
/** 状态变更(连接 / 断开 / 重连中) */
onStatus: (status: WsStatus, detail?: string) => void
}
/**
* WS 客户端单例(miniapp 全局一个连接)
*
* 设计:单 socket + 单 handlers 回调集合(composable 订阅,业务层不直接持 socket)。
* 握手/心跳/重连机制内部封装,业务层仅关心 send(Command) + onEvent。
*/
class WsClient {
private socket: UniApp.SocketTask | null = null
private handlers: WsHandlers | null = null
private status: WsStatus = 'disconnected'
/** 握手是否已完成(收到首个广播或握手成功信号) */
private handshaked = false
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private reconnectAttempts = 0
/** 主动关闭标志(用户调用 disconnect 时置 true,不再触发重连) */
private manualClose = false
/** 订阅(单订阅,composable 注册一次) */
subscribe(h: WsHandlers): void {
this.handlers = h
}
/** 读取当前状态 */
getStatus(): WsStatus {
return this.status
}
/** 主动连接 */
connect(): void {
if (this.status === 'connected' || this.status === 'connecting' || this.status === 'handshaking') {
return
}
this.manualClose = false
this.openSocket()
}
/** 主动断开(用户手动,不重连) */
disconnect(): void {
this.manualClose = true
this.cleanup()
this.setStatus('disconnected', 'manual close')
}
/** 若已断开则触发重连(onShow 时调用,小程序后台回前台) */
resumeIfDisconnected(): void {
if (this.status === 'disconnected' && !this.manualClose) {
this.scheduleReconnect()
}
}
/**
* 发送 Command(miniapp → device)
*
* 不包装 BroadcastMessage —— relay.rs:332-365 入站文本帧直接当 payload 包成
* BroadcastMessage(device_id/kind/source/from 由 relay 填),故客户端仅发业务 JSON。
*/
send(cmd: MiniCommand): boolean {
if (!this.socket || !this.handshaked) {
console.warn('[WsClient] 未连接或握手未完成,丢弃命令', cmd.cmd)
return false
}
try {
const text = JSON.stringify(cmd)
this.socket.send({ data: text })
return true
} catch (e) {
console.error('[WsClient] send 失败', e)
return false
}
}
// ─── 内部实现 ──────────────────────────────────────────
private openSocket(): void {
const cfg = getConfig()
this.setStatus('connecting')
console.log('[WsClient] 连接', cfg.relayHost)
try {
this.socket = uni.connectSocket({
url: cfg.relayHost,
complete: () => {
// complete 回调在 uni-app 仅表示"任务创建完成",非连接完成(onOpen 才是)
},
})
} catch (e) {
console.error('[WsClient] connectSocket 异常', e)
this.scheduleReconnect()
return
}
this.socket.onOpen(() => this.handleOpen())
this.socket.onMessage((res) => this.handleMessage(res))
this.socket.onClose((res) => this.handleClose(res))
this.socket.onError((res) => this.handleError(res))
}
/** WS 已建立连接 → 立即发 Hello 握手帧 */
private handleOpen(): void {
this.setStatus('handshaking')
const cfg = getConfig()
const hello: Hello = {
kind: 'miniapp',
device_id: cfg.deviceId,
token: cfg.token,
}
try {
this.socket?.send({ data: JSON.stringify(hello) })
} catch (e) {
console.error('[WsClient] 发送 Hello 失败', e)
this.scheduleReconnect()
}
}
/** 入站消息分派:握手阶段首帧=控制(握手成功/失败),握手后=BroadcastMessage */
private handleMessage(res: { data: string | ArrayBuffer }): void {
const text = typeof res.data === 'string' ? res.data : ''
if (!text) return
// 握手阶段可能收到 relay 发的错误控制帧(relay.rs:200-238)
if (!this.handshaked) {
this.handleHandshakeResponse(text)
return
}
// 解析 BroadcastMessage(device 事件经 relay 包成完整 BroadcastMessage 下发)
let msg: BroadcastMessage
try {
msg = JSON.parse(text) as BroadcastMessage
} catch (e) {
console.warn('[WsClient] 入站消息 JSON 解析失败', e, text.slice(0, 200))
return
}
// 按 kind 分派:Event → 业务层;Control → 心跳响应等
if (msg.kind === 'event') {
this.handlers?.onEvent(msg)
} else if (msg.kind === 'control') {
this.handlers?.onControl(msg.payload as ControlMessage)
}
// command 是 device→miniapp 反向(不应在此端收到,忽略)
}
/** 处理握手响应(relay 握手通过后无显式 ack,首条业务消息即视为握手成功;
* 失败时 relay 发 {"kind":"control","error":"..."} + 关连接) */
private handleHandshakeResponse(text: string): void {
// relay 握手失败帧(relay.rs:200-238):{"kind":"control","error":"handshake_failed|kind_mismatch|auth_failed"}
if (text.includes('"error"')) {
console.error('[WsClient] 握手失败', text)
this.setStatus('disconnected', `handshake failed: ${text}`)
// 握手失败不重连(token 错则重连也错),等用户修配置后手动 connect
this.cleanup()
return
}
// 首条非错误消息即视为握手通过(实际握手后 relay 会路由 device 的 Event 过来)
this.handshaked = true
this.reconnectAttempts = 0
this.setStatus('connected')
this.startHeartbeat()
// 该首消息也是业务消息,转 handleMessage 处理(此时 handshaked=true,走 BroadcastMessage 解析路径)
this.handleMessage({ data: text })
}
private handleClose(res: { code: number; reason: string }): void {
console.log('[WsClient] 连接关闭', res.code, res.reason)
this.cleanup()
if (!this.manualClose) {
this.scheduleReconnect()
} else {
this.setStatus('disconnected', 'manual close')
}
}
private handleError(res: { errMsg: string }): void {
console.error('[WsClient] socket 错误', res.errMsg)
// 不直接 scheduleReconnect:onClose 会跟着触发,在 onClose 统一处理重连
}
/** 启动心跳(固定间隔发 ControlMessage{control_kind:'heartbeat'}) */
private startHeartbeat(): void {
this.stopHeartbeat()
const cfg = getConfig()
this.heartbeatTimer = setInterval(() => {
const ping: ControlMessage = { control_kind: 'ping' }
// 直接发 ControlMessage JSON(relay 透传,device 可回 pong)
try {
this.socket?.send({ data: JSON.stringify(ping) })
} catch (e) {
console.warn('[WsClient] 心跳发送失败', e)
}
// 注:cfg.heartbeatInterval 读但不显式用(已通过 setInterval 传入),保 cfg 引用
void cfg
}, cfg.heartbeatInterval)
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
/** 指数退避重连(baseDelay * 2^attempts,封顶 maxDelay) */
private scheduleReconnect(): void {
if (this.reconnectTimer) return
const cfg = getConfig()
const attempt = this.reconnectAttempts++
const delay = Math.min(cfg.reconnectBaseDelay * Math.pow(2, attempt), cfg.reconnectMaxDelay)
this.setStatus('reconnecting', `attempt ${attempt + 1}, delay ${delay}ms`)
console.log(`[WsClient] ${delay}ms 后重连(attempt ${attempt + 1})`)
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.openSocket()
}, delay)
}
/** 清理 socket + 定时器(连接关闭时调) */
private cleanup(): void {
this.stopHeartbeat()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this.socket) {
try {
this.socket.close({})
} catch {
// 已关闭的 socket close 抛错忽略
}
this.socket = null
}
this.handshaked = false
}
private setStatus(status: WsStatus, detail?: string): void {
this.status = status
this.handlers?.onStatus(status, detail)
}
}
/** 全局单例(miniapp 进程内一个连接) */
export const wsClient = new WsClient()