From eb5b51a474d5c91c8e0677fe99955e28c8cb2192 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Sat, 26 Sep 2026 23:46:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(talk):=20=E6=96=B0=E5=A2=9E=20Talk=20?= =?UTF-8?q?=E4=BC=81=E4=B8=9A=E8=81=8A=E5=A4=A9=E9=A1=B5=E9=9D=A2=EF=BC=88?= =?UTF-8?q?=E4=BB=BF=E5=BE=AE=E4=BF=A1=E4=B8=89=E6=A0=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/views/talk/{Talk.vue,GroupCreateDialog.vue}、src/api/talk.ts、src/utils/talkSocket.ts - 引入 socket.io-client:/talk 命名空间连接、断线重连、401 自动换令牌、心跳与 typing 节流 - 会话列表 / 消息气泡区(图片预览、文件卡片、时间分组、未读红点、上滑加载更早消息)/ 群成员面板 - 建群对话框:选本公司成员 + 配额实时提示(公司群/个人群/单群人数/公司参与人数) - 路由 /talk(menuGuarded)+ apps/index.ts 注册 + vite.config.ts 增加 /socket.io 代理(ws) --- package-lock.json | 86 ++ package.json | 1 + src/api/talk.ts | 237 +++++ src/apps/index.ts | 8 + src/router/index.ts | 7 + src/utils/talkSocket.ts | 208 ++++ src/views/talk/GroupCreateDialog.vue | 292 +++++ src/views/talk/Talk.vue | 1463 ++++++++++++++++++++++++++ vite.config.ts | 6 + 9 files changed, 2308 insertions(+) create mode 100644 src/api/talk.ts create mode 100644 src/utils/talkSocket.ts create mode 100644 src/views/talk/GroupCreateDialog.vue create mode 100644 src/views/talk/Talk.vue diff --git a/package-lock.json b/package-lock.json index 0d53ddf..249d40d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "element-plus": "^2.14.3", "marked": "^18.0.9", "pinia": "^4.0.2", + "socket.io-client": "^4.8.4", "turndown": "^7.2.4", "vue": "^3.5.40", "vue-router": "^5.2.0", @@ -884,6 +885,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tinymce/tinymce-vue": { "version": "6.3.0", "resolved": "https://registry.npmmirror.com/@tinymce/tinymce-vue/-/tinymce-vue-6.3.0.tgz", @@ -1489,6 +1496,28 @@ "vue": "^3.3.7" } }, + "node_modules/engine.io-client": { + "version": "6.6.7", + "resolved": "https://registry.npmmirror.com/engine.io-client/-/engine.io-client-6.6.7.tgz", + "integrity": "sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmmirror.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", @@ -2839,6 +2868,34 @@ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, + "node_modules/socket.io-client": { + "version": "4.8.4", + "resolved": "https://registry.npmmirror.com/socket.io-client/-/socket.io-client-4.8.4.tgz", + "integrity": "sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmmirror.com/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3372,12 +3429,41 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xe-utils": { "version": "4.0.12", "resolved": "https://registry.npmmirror.com/xe-utils/-/xe-utils-4.0.12.tgz", "integrity": "sha512-4y2OXEVlXHoBMSyzdAcXDM9oyb8EL9U4LIEq642JO0liULVpC9U5aEiVv2xbS9hhoebYCMdZZYASqh/7H5Wd0Q==", "license": "MIT" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index b17d7ed..2c01fd2 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "element-plus": "^2.14.3", "marked": "^18.0.9", "pinia": "^4.0.2", + "socket.io-client": "^4.8.4", "turndown": "^7.2.4", "vue": "^3.5.40", "vue-router": "^5.2.0", diff --git a/src/api/talk.ts b/src/api/talk.ts new file mode 100644 index 0000000..1a662ed --- /dev/null +++ b/src/api/talk.ts @@ -0,0 +1,237 @@ +/** + * Talk 企业通讯 · REST 客户端与类型定义 + * + * 接口前缀 /API/Talk/,响应统一 { code, msg, data },code===0 成功。 + * request.ts 的响应拦截器在成功时自动解包 data,故下列函数返回的即为业务数据。 + * + * Socket.IO 实时通道见 @/utils/talkSocket(本文件仅提供 SocketToken 获取)。 + */ +import { get, post, upload } from './request' + +/** Talk 接口前缀 */ +const TALK_BASE = '/API/Talk' + +/** 附件下载基址(与 src/views/assets/Manager.vue 的 ASSET_DOWNLOAD_BASE 保持一致) */ +export const ASSET_DOWNLOAD_BASE = 'https://api.gvsds.com/API/Asset/Download' + +/** 会话类型:0=单聊 1=群聊 */ +export const TalkConversationType = { Single: 0, Group: 1 } as const + +/** 消息类型:0=文本 1=图片 2=文件(后端契约未显式约定,按此实现,见汇总说明) */ +export const TalkMsgType = { Text: 0, Image: 1, File: 2 } as const + +/** Talk 使用配额上限 */ +export interface TalkQuotas { + /** 公司可创建的群聊上限 */ + GroupCompanyLimit: number + /** 单用户可创建的群聊上限 */ + GroupUserLimit: number + /** 单个群聊成员上限 */ + GroupMemberLimit: number + /** 公司成员总量上限 */ + CompanyMemberLimit: number +} + +/** Talk 配额占用 */ +export interface TalkUsage { + GroupCompanyUsed: number + GroupUserUsed: number + CompanyMemberUsed: number +} + +/** Entry 接口返回(页面准入与配额) */ +export interface TalkEntry { + /** 是否已被管理员在应用市场授权 */ + CanUse: boolean + /** 当前用户是否管理员(用于解散群聊等特权) */ + IsAdmin: boolean + CompanyUUID: string + Quotas: TalkQuotas + Usage: TalkUsage +} + +/** 成员简要信息 */ +export interface TalkMemberBrief { + UserUUID: string + UserName: string + DisplayName: string +} + +/** 会话项 */ +export interface TalkConversation { + ConversationUUID: string + /** 0=单聊 1=群聊 */ + ConversationType: number + /** 会话标题(单聊可能为空,需由前端取对方名) */ + Title: string + MemberCount: number + LastMessageTime: string + LastMessagePreview: string + UnreadCount: number + Members: TalkMemberBrief[] + /** 当前用户是否为该群群主(前端据此显示解散入口) */ + IsOwner?: boolean + /** 群主(创建者)UserUUID */ + OwnerUUID?: string +} + +/** 会话列表返回 */ +export interface TalkConversationList { + List: TalkConversation[] + Total: number +} + +/** 附件信息 */ +export interface TalkAttachment { + SHA1: string + Name: string + Size: number + Mime: string + /** 形如 /API/Asset/Download/,可直接作为 img src */ + Url: string + IsImage: boolean +} + +/** 消息记录 */ +export interface TalkMessage { + MessageUUID: string + ConversationUUID: string + SenderUUID: string + SenderName: string + /** 0=文本 1=图片 2=文件 */ + MsgType: number + Content: string + Attachment: TalkAttachment | null + CreateTime: string +} + +/** 消息分页返回(Records 升序,BeforeTime 取更早一页) */ +export interface TalkMessagePage { + Records: TalkMessage[] + Total: number +} + +/** 发送消息参数 */ +export interface TalkSendPayload { + ConversationUUID: string + MsgType: number + Content?: string + AttachmentSHA1?: string + AttachmentName?: string + AttachmentSize?: number + AttachmentMime?: string +} + +/** 上传返回 */ +export interface TalkUploadResult { + SHA1: string + Name: string + Size: number + Mime: string + IsImage: boolean + Url: string +} + +/** SocketToken 返回 */ +export interface TalkSocketToken { + Token: string + SocketPath: string + ExpiresIn: number +} + +/** Entry:页面准入与配额。silent=true 时未授权/失败不弹全局提示 */ +export function getTalkEntry(silent = false): Promise { + return get(`${TALK_BASE}/Entry`, undefined, silent ? { silent: true } : undefined) +} + +/** 会话列表(最后消息时间倒序) */ +export function getTalkConversations(): Promise { + return get(`${TALK_BASE}/Conversations`) +} + +/** 创建会话(Type 0 单聊需恰好 1 人,1 群聊) */ +export function createTalkConversation(data: { + ConversationType: number + Title: string + MemberUUIDs: string[] +}): Promise<{ ConversationUUID: string; MemberCount: number }> { + return post<{ ConversationUUID: string; MemberCount: number }>(`${TALK_BASE}/Conversation/Create`, data) +} + +/** 邀请成员入群 */ +export function inviteTalkMembers(data: { ConversationUUID: string; MemberUUIDs: string[] }): Promise { + return post(`${TALK_BASE}/Conversation/Invite`, data) +} + +/** 退出群聊 */ +export function leaveTalkConversation(data: { ConversationUUID: string }): Promise { + return post(`${TALK_BASE}/Conversation/Leave`, data) +} + +/** 修改群名 */ +export function renameTalkConversation(data: { ConversationUUID: string; Title: string }): Promise { + return post(`${TALK_BASE}/Conversation/Rename`, data) +} + +/** 解散群聊(群主/管理员) */ +export function dissolveTalkConversation(data: { ConversationUUID: string }): Promise { + return post(`${TALK_BASE}/Conversation/Dissolve`, data) +} + +/** 消息分页(BeforeTime 取更早一页) */ +export function getTalkMessages(params: { + ConversationUUID: string + PageIndex?: number + PageSize?: number + BeforeTime?: string +}): Promise { + return get(`${TALK_BASE}/Messages`, params) +} + +/** 发送消息 */ +export function sendTalkMessage(payload: TalkSendPayload): Promise<{ Message: TalkMessage }> { + return post<{ Message: TalkMessage }>(`${TALK_BASE}/Send`, payload) +} + +/** 推送已读位点 */ +export function readTalkConversation(data: { ConversationUUID: string; LastReadMessageUUID: string }): Promise { + return post(`${TALK_BASE}/Read`, data) +} + +/** 搜索本公司成员 */ +export function searchTalkMembers(keyword: string): Promise<{ Users: TalkMemberBrief[] }> { + return get<{ Users: TalkMemberBrief[] }>(`${TALK_BASE}/Members/Search`, { Keyword: keyword }) +} + +/** 上传附件(multipart,字段名 file,≤10MB) */ +export function uploadTalkAttachment(file: File): Promise { + const formData = new FormData() + formData.append('file', file) + return upload(`${TALK_BASE}/Upload`, formData) +} + +/** 获取 SocketToken(连接实时通道用) */ +export function getTalkSocketToken(): Promise { + return get(`${TALK_BASE}/SocketToken`) +} + +/** + * 将附件解析为可直接用于 /下载 的 URL。 + * 后端返回相对路径 /API/Asset/Download/: + * - 开发环境保留相对路径,经 Vite 代理(/API)直连本地后端; + * - 生产环境按 assets/Manager.vue 的 ASSET_DOWNLOAD_BASE 绝对化到统一 API 域名。 + */ +export function resolveTalkAttachmentUrl(attachment: TalkAttachment | null | undefined): string { + if (!attachment) return '' + const url = attachment.Url || '' + if (/^https?:\/\//i.test(url)) return url + if (attachment.SHA1 && import.meta.env.PROD) return `${ASSET_DOWNLOAD_BASE}/${attachment.SHA1}` + return url || (attachment.SHA1 ? `${ASSET_DOWNLOAD_BASE}/${attachment.SHA1}` : '') +} + +/** 附件下载 URL(追加 download=1 触发浏览器下载) */ +export function resolveTalkAttachmentDownloadUrl(attachment: TalkAttachment | null | undefined): string { + const base = resolveTalkAttachmentUrl(attachment) + if (!base) return '' + return base.includes('?') ? `${base}&download=1` : `${base}?download=1` +} diff --git a/src/apps/index.ts b/src/apps/index.ts index 9ef18c3..c09e253 100644 --- a/src/apps/index.ts +++ b/src/apps/index.ts @@ -58,6 +58,14 @@ const builtin: Record = { FrontendType: 'vue', LegacySrc: '', }, + talk: { + AppCode: 'talk', + Title: 'Talk 企业通讯', + Icon: 'ChatDotRound', + FrontendRoute: '/talk', + FrontendType: 'vue', + LegacySrc: '', + }, shop: { AppCode: 'shop', Title: '枢机协律-商店', diff --git a/src/router/index.ts b/src/router/index.ts index 0042a18..07ca497 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -197,6 +197,13 @@ export const staticRoutes: RouteRecordRaw[] = [ component: () => import('@/views/mail/MailAdmin.vue'), meta: { title: '邮箱管理', icon: 'Message', requiresAuth: true, menuGuarded: true }, }, + // === Talk 企业通讯 === + { + path: 'talk', + name: 'Talk', + component: () => import('@/views/talk/Talk.vue'), + meta: { title: 'Talk', icon: 'ChatDotRound', requiresAuth: true, menuGuarded: true }, + }, // === Gitea 代码托管对接 === { path: 'gitea/mine', diff --git a/src/utils/talkSocket.ts b/src/utils/talkSocket.ts new file mode 100644 index 0000000..d76fecd --- /dev/null +++ b/src/utils/talkSocket.ts @@ -0,0 +1,208 @@ +/** + * Talk 实时通道 · Socket.IO 连接管理(单例) + * + * 生命周期: + * - connectTalkSocket() 拉取 SocketToken 后连接 /talk 命名空间(SocketPath 由后端下发) + * - 断线由 socket.io 自动重连(1s 起,最大 5s);重连成功后重新鉴权并重启心跳 + * - talk:error 且 code===401 时重新拉取 SocketToken 并重连一次 + * - 页面销毁调用 disconnectTalkSocket() 断开并清理订阅 + * + * 事件分发:服务端响应统一 { code, msg, data }。成功(code===200)时向订阅者 + * 仅传递 data;talk:error 或非成功码时传递完整响应,便于上层读取 code/msg。 + */ +import { io, type Socket } from 'socket.io-client' +import { getTalkSocketToken } from '@/api/talk' + +/** 服务端 → 客户端事件名 */ +export type TalkServerEvent = + | 'talk:ready' + | 'talk:message' + | 'talk:read' + | 'talk:typing' + | 'talk:pong' + | 'talk:error' + +/** 事件处理器(payload 为已解包数据;talk:error 为原始 { code, msg }) */ +export type TalkEventHandler = (payload: unknown) => void + +/** 服务端事件全集(连接时统一挂载分发) */ +const SERVER_EVENTS: TalkServerEvent[] = [ + 'talk:ready', + 'talk:message', + 'talk:read', + 'talk:typing', + 'talk:pong', + 'talk:error', +] + +/** 心跳间隔(毫秒) */ +const HEARTBEAT_INTERVAL = 25000 + +/** 单例连接与令牌 */ +let SocketInstance: Socket | null = null +let CurrentToken = '' +/** 心跳定时器 */ +let HeartbeatTimer: ReturnType | null = null +/** 是否已针对 401 重连过一次(防止无限刷新令牌) */ +let ReauthAttempted = false +/** 连接中 Promise(并发调用去重) */ +let Connecting: Promise | null = null + +/** 事件订阅表(与连接解耦,可在连接前注册) */ +const EventHandlers = new Map>() + +/** 是否为统一响应包装 */ +function IsWrapped(payload: unknown): payload is { code: number; msg?: string; data?: unknown } { + return !!payload && typeof payload === 'object' && 'code' in (payload as Record) +} + +/** 分发服务端事件到已注册处理器 */ +function Dispatch(event: TalkServerEvent, payload: unknown): void { + // 401:令牌过期,刷新后重连一次 + if (event === 'talk:error' && IsWrapped(payload) && payload.code === 401) { + void HandleUnauthorized() + } + let out: unknown = payload + if (IsWrapped(payload) && event !== 'talk:error' && payload.code === 200) { + out = payload.data + } + const set = EventHandlers.get(event) + if (!set) return + for (const handler of set) { + try { + handler(out) + } catch { + // 单个处理器异常不影响其他订阅者 + } + } +} + +/** 启动心跳(talk:ping) */ +function StartHeartbeat(socket: Socket): void { + StopHeartbeat() + HeartbeatTimer = setInterval(() => { + socket.emit('talk:ping') + }, HEARTBEAT_INTERVAL) +} + +/** 停止心跳 */ +function StopHeartbeat(): void { + if (HeartbeatTimer) { + clearInterval(HeartbeatTimer) + HeartbeatTimer = null + } +} + +/** 401 处理:重新拉取 SocketToken 并重连一次 */ +async function HandleUnauthorized(): Promise { + if (ReauthAttempted || !SocketInstance) return + ReauthAttempted = true + try { + const token = await getTalkSocketToken() + CurrentToken = token.Token + SocketInstance.auth = { Token: token.Token } + SocketInstance.disconnect() + SocketInstance.connect() + } catch { + // 令牌刷新失败:保留自动重连,等待后续 connect_error 再试 + } +} + +/** 挂载连接层核心监听(连接态、重连、心跳、事件分发) */ +function BindCoreEvents(socket: Socket): void { + socket.on('connect', () => { + ReauthAttempted = false + // 命名空间鉴权:auth.Token 已携带,仍按契约显式发一次 talk:auth + socket.emit('talk:auth', { Token: CurrentToken }) + StartHeartbeat(socket) + }) + socket.on('disconnect', () => { + StopHeartbeat() + }) + // 重连前刷新 auth,保证使用最新令牌 + socket.io.on('reconnect_attempt', () => { + socket.auth = { Token: CurrentToken } + }) + for (const event of SERVER_EVENTS) { + socket.on(event, (payload: unknown) => Dispatch(event, payload)) + } +} + +/** + * 连接 Talk 实时通道(幂等):已连接则直接返回;连接中则复用同一 Promise。 + * 失败返回 null(socket.io 仍按配置自动重连)。 + */ +export async function connectTalkSocket(): Promise { + if (SocketInstance?.connected) return SocketInstance + if (Connecting) return Connecting + Connecting = (async () => { + try { + const token = await getTalkSocketToken() + if (!token?.Token) return SocketInstance + CurrentToken = token.Token + // 已有实例:仅刷新令牌并按需重连,避免重复创建连接 + if (SocketInstance) { + SocketInstance.auth = { Token: token.Token } + if (!SocketInstance.connected) SocketInstance.connect() + return SocketInstance + } + const socket = io(token.SocketPath || '/talk', { + path: '/socket.io', + withCredentials: true, + transports: ['websocket', 'polling'], + auth: { Token: token.Token }, + reconnection: true, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + }) + BindCoreEvents(socket) + SocketInstance = socket + return socket + } catch { + return null + } finally { + Connecting = null + } + })() + return Connecting +} + +/** 订阅服务端事件,返回取消订阅函数 */ +export function subscribeTalkEvent(event: TalkServerEvent, handler: TalkEventHandler): () => void { + let set = EventHandlers.get(event) + if (!set) { + set = new Set() + EventHandlers.set(event, set) + } + set.add(handler) + const target = set + return () => { + target.delete(handler) + } +} + +/** 发送客户端事件(未连接时返回 false,由调用方走 REST 兜底) */ +export function emitTalkEvent(event: string, payload?: unknown): boolean { + if (!SocketInstance?.connected) return false + SocketInstance.emit(event, payload) + return true +} + +/** 当前实时通道是否已连接 */ +export function isTalkSocketConnected(): boolean { + return !!SocketInstance?.connected +} + +/** 断开并清理(页面销毁时调用) */ +export function disconnectTalkSocket(): void { + StopHeartbeat() + EventHandlers.clear() + if (SocketInstance) { + SocketInstance.removeAllListeners() + SocketInstance.disconnect() + SocketInstance = null + } + ReauthAttempted = false + CurrentToken = '' + Connecting = null +} diff --git a/src/views/talk/GroupCreateDialog.vue b/src/views/talk/GroupCreateDialog.vue new file mode 100644 index 0000000..bc55b31 --- /dev/null +++ b/src/views/talk/GroupCreateDialog.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/src/views/talk/Talk.vue b/src/views/talk/Talk.vue new file mode 100644 index 0000000..331009d --- /dev/null +++ b/src/views/talk/Talk.vue @@ -0,0 +1,1463 @@ + + + + + diff --git a/vite.config.ts b/vite.config.ts index 0b9c47f..c8525ff 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -98,6 +98,12 @@ export default defineConfig({ target: 'http://127.0.0.1:8000', changeOrigin: true, }, + // Talk 实时通道(Socket.IO):需 ws 转发 + '/socket.io': { + target: 'http://127.0.0.1:8000', + ws: true, + changeOrigin: true, + }, }, }, build: {