feat(talk): 新增 Talk 企业聊天页面(仿微信三栏)
- 新增 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)
This commit is contained in:
1 parent
e79e3bb28b
commit
eb5b51a474
9 files changed
+2308
No files matched your search
Generated
+86
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+237
@@ -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/<sha1>,可直接作为 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<TalkEntry> {
|
||||
return get<TalkEntry>(`${TALK_BASE}/Entry`, undefined, silent ? { silent: true } : undefined)
|
||||
}
|
||||
|
||||
/** 会话列表(最后消息时间倒序) */
|
||||
export function getTalkConversations(): Promise<TalkConversationList> {
|
||||
return get<TalkConversationList>(`${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<void> {
|
||||
return post<void>(`${TALK_BASE}/Conversation/Invite`, data)
|
||||
}
|
||||
|
||||
/** 退出群聊 */
|
||||
export function leaveTalkConversation(data: { ConversationUUID: string }): Promise<void> {
|
||||
return post<void>(`${TALK_BASE}/Conversation/Leave`, data)
|
||||
}
|
||||
|
||||
/** 修改群名 */
|
||||
export function renameTalkConversation(data: { ConversationUUID: string; Title: string }): Promise<void> {
|
||||
return post<void>(`${TALK_BASE}/Conversation/Rename`, data)
|
||||
}
|
||||
|
||||
/** 解散群聊(群主/管理员) */
|
||||
export function dissolveTalkConversation(data: { ConversationUUID: string }): Promise<void> {
|
||||
return post<void>(`${TALK_BASE}/Conversation/Dissolve`, data)
|
||||
}
|
||||
|
||||
/** 消息分页(BeforeTime 取更早一页) */
|
||||
export function getTalkMessages(params: {
|
||||
ConversationUUID: string
|
||||
PageIndex?: number
|
||||
PageSize?: number
|
||||
BeforeTime?: string
|
||||
}): Promise<TalkMessagePage> {
|
||||
return get<TalkMessagePage>(`${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<void> {
|
||||
return post<void>(`${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<TalkUploadResult> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return upload<TalkUploadResult>(`${TALK_BASE}/Upload`, formData)
|
||||
}
|
||||
|
||||
/** 获取 SocketToken(连接实时通道用) */
|
||||
export function getTalkSocketToken(): Promise<TalkSocketToken> {
|
||||
return get<TalkSocketToken>(`${TALK_BASE}/SocketToken`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将附件解析为可直接用于 <img>/下载 的 URL。
|
||||
* 后端返回相对路径 /API/Asset/Download/<sha1>:
|
||||
* - 开发环境保留相对路径,经 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`
|
||||
}
|
||||
@@ -58,6 +58,14 @@ const builtin: Record<string, AppFrontendModule> = {
|
||||
FrontendType: 'vue',
|
||||
LegacySrc: '',
|
||||
},
|
||||
talk: {
|
||||
AppCode: 'talk',
|
||||
Title: 'Talk 企业通讯',
|
||||
Icon: 'ChatDotRound',
|
||||
FrontendRoute: '/talk',
|
||||
FrontendType: 'vue',
|
||||
LegacySrc: '',
|
||||
},
|
||||
shop: {
|
||||
AppCode: 'shop',
|
||||
Title: '枢机协律-商店',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof setInterval> | null = null
|
||||
/** 是否已针对 401 重连过一次(防止无限刷新令牌) */
|
||||
let ReauthAttempted = false
|
||||
/** 连接中 Promise(并发调用去重) */
|
||||
let Connecting: Promise<Socket | null> | null = null
|
||||
|
||||
/** 事件订阅表(与连接解耦,可在连接前注册) */
|
||||
const EventHandlers = new Map<TalkServerEvent, Set<TalkEventHandler>>()
|
||||
|
||||
/** 是否为统一响应包装 */
|
||||
function IsWrapped(payload: unknown): payload is { code: number; msg?: string; data?: unknown } {
|
||||
return !!payload && typeof payload === 'object' && 'code' in (payload as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 分发服务端事件到已注册处理器 */
|
||||
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<void> {
|
||||
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<Socket | null> {
|
||||
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<TalkEventHandler>()
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Talk · 新建群聊对话框
|
||||
*
|
||||
* - 搜索并多选本公司成员(/API/Talk/Members/Search)
|
||||
* - 填写群名称
|
||||
* - 实时展示配额占用(Entry.Usage vs Entry.Quotas),超限用 el-alert 说明原因
|
||||
* - 群成员数(含自己)超过 GroupMemberLimit 时禁用确认
|
||||
* - 创建成功后 emit('created', ConversationUUID)
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
createTalkConversation,
|
||||
searchTalkMembers,
|
||||
TalkConversationType,
|
||||
type TalkEntry,
|
||||
type TalkMemberBrief,
|
||||
} from '@/api/talk'
|
||||
|
||||
const Props = defineProps<{
|
||||
/** 双向绑定可见性 */
|
||||
modelValue: boolean
|
||||
/** Talk 准入与配额(来自 Entry 接口) */
|
||||
entry: TalkEntry | null
|
||||
/** 需从候选中排除的用户(如当前会话已在群里的成员) */
|
||||
excludeUuids?: string[]
|
||||
}>()
|
||||
|
||||
const Emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'created', conversationUUID: string): void
|
||||
}>()
|
||||
|
||||
/** 群名称 */
|
||||
const Title = ref('')
|
||||
/** 已选成员 UUID */
|
||||
const SelectedUUIDs = ref<string[]>([])
|
||||
/** 成员候选 */
|
||||
const Options = ref<TalkMemberBrief[]>([])
|
||||
/** 成员搜索中 */
|
||||
const Searching = ref(false)
|
||||
/** 提交中 */
|
||||
const Submitting = ref(false)
|
||||
|
||||
/** 搜索防抖句柄 */
|
||||
let SearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 配额与占用(缺省 0,避免空指针) */
|
||||
const GroupUserLimit = computed(() => Props.entry?.Quotas?.GroupUserLimit ?? 0)
|
||||
const GroupCompanyLimit = computed(() => Props.entry?.Quotas?.GroupCompanyLimit ?? 0)
|
||||
const GroupMemberLimit = computed(() => Props.entry?.Quotas?.GroupMemberLimit ?? 0)
|
||||
const CompanyMemberLimit = computed(() => Props.entry?.Quotas?.CompanyMemberLimit ?? 0)
|
||||
const GroupUserUsed = computed(() => Props.entry?.Usage?.GroupUserUsed ?? 0)
|
||||
const GroupCompanyUsed = computed(() => Props.entry?.Usage?.GroupCompanyUsed ?? 0)
|
||||
const CompanyMemberUsed = computed(() => Props.entry?.Usage?.CompanyMemberUsed ?? 0)
|
||||
|
||||
/** 已选成员数 */
|
||||
const SelectedCount = computed(() => SelectedUUIDs.value.length)
|
||||
/** 群成员总数(含创建者自己) */
|
||||
const MemberTotal = computed(() => SelectedCount.value + 1)
|
||||
|
||||
/** 个人建群额度是否已用完 */
|
||||
const GroupUserExceeded = computed(() => GroupUserLimit.value > 0 && GroupUserUsed.value >= GroupUserLimit.value)
|
||||
/** 公司建群额度是否已用完 */
|
||||
const GroupCompanyExceeded = computed(
|
||||
() => GroupCompanyLimit.value > 0 && GroupCompanyUsed.value >= GroupCompanyLimit.value,
|
||||
)
|
||||
/** 群成员数是否超上限 */
|
||||
const MemberExceeded = computed(() => GroupMemberLimit.value > 0 && MemberTotal.value > GroupMemberLimit.value)
|
||||
/** 公司成员配额是否将超出 */
|
||||
const CompanyMemberExceeded = computed(
|
||||
() => CompanyMemberLimit.value > 0 && CompanyMemberUsed.value + SelectedCount.value > CompanyMemberLimit.value,
|
||||
)
|
||||
|
||||
/** 超限提示列表(用于 el-alert 渲染) */
|
||||
const ExceedTips = computed<Array<{ type: 'error' | 'warning'; text: string }>>(() => {
|
||||
const tips: Array<{ type: 'error' | 'warning'; text: string }> = []
|
||||
if (GroupUserExceeded.value) {
|
||||
tips.push({
|
||||
type: 'error',
|
||||
text: `已达到个人建群上限(${GroupUserUsed.value}/${GroupUserLimit.value}),无法创建新群聊`,
|
||||
})
|
||||
}
|
||||
if (GroupCompanyExceeded.value) {
|
||||
tips.push({
|
||||
type: 'error',
|
||||
text: `公司群聊数量已达上限(${GroupCompanyUsed.value}/${GroupCompanyLimit.value}),无法创建新群聊`,
|
||||
})
|
||||
}
|
||||
if (MemberExceeded.value) {
|
||||
tips.push({
|
||||
type: 'warning',
|
||||
text: `群成员数(含自己共 ${MemberTotal.value} 人)超过单群上限 ${GroupMemberLimit.value} 人`,
|
||||
})
|
||||
}
|
||||
if (CompanyMemberExceeded.value) {
|
||||
tips.push({
|
||||
type: 'warning',
|
||||
text: `将超出公司成员配额(已用 ${CompanyMemberUsed.value}/${CompanyMemberLimit.value})`,
|
||||
})
|
||||
}
|
||||
return tips
|
||||
})
|
||||
|
||||
/** 是否可提交 */
|
||||
const CanSubmit = computed(
|
||||
() =>
|
||||
!!Title.value.trim() &&
|
||||
SelectedCount.value > 0 &&
|
||||
!GroupUserExceeded.value &&
|
||||
!GroupCompanyExceeded.value &&
|
||||
!MemberExceeded.value &&
|
||||
!Submitting.value,
|
||||
)
|
||||
|
||||
/** 成员显示名 */
|
||||
function MemberName(member: TalkMemberBrief): string {
|
||||
return member.DisplayName || member.UserName || ''
|
||||
}
|
||||
|
||||
/** 排除项集合 */
|
||||
function ExcludedSet(): Set<string> {
|
||||
return new Set(Props.excludeUuids || [])
|
||||
}
|
||||
|
||||
/** 搜索成员(空关键字也查询一次,便于直接浏览) */
|
||||
async function SearchMembers(keyword: string): Promise<void> {
|
||||
Searching.value = true
|
||||
try {
|
||||
const result = await searchTalkMembers(keyword)
|
||||
const excluded = ExcludedSet()
|
||||
Options.value = (result?.Users || []).filter((u) => !excluded.has(u.UserUUID))
|
||||
} catch {
|
||||
Options.value = []
|
||||
} finally {
|
||||
Searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 输入防抖搜索(300ms) */
|
||||
function OnSearchInput(keyword: string): void {
|
||||
if (SearchTimer) clearTimeout(SearchTimer)
|
||||
SearchTimer = setTimeout(() => {
|
||||
void SearchMembers(keyword)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/** 重置表单并加载候选 */
|
||||
function ResetAndLoad(): void {
|
||||
Title.value = ''
|
||||
SelectedUUIDs.value = []
|
||||
Options.value = []
|
||||
void SearchMembers('')
|
||||
}
|
||||
|
||||
/** 关闭对话框 */
|
||||
function Close(): void {
|
||||
Emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
/** 提交创建 */
|
||||
async function Submit(): Promise<void> {
|
||||
if (!Title.value.trim()) {
|
||||
ElMessage.warning('请输入群名称')
|
||||
return
|
||||
}
|
||||
if (SelectedCount.value === 0) {
|
||||
ElMessage.warning('请至少选择一名成员')
|
||||
return
|
||||
}
|
||||
if (MemberExceeded.value || GroupUserExceeded.value || GroupCompanyExceeded.value) return
|
||||
Submitting.value = true
|
||||
try {
|
||||
const result = await createTalkConversation({
|
||||
ConversationType: TalkConversationType.Group,
|
||||
Title: Title.value.trim(),
|
||||
MemberUUIDs: SelectedUUIDs.value,
|
||||
})
|
||||
ElMessage.success('群聊创建成功')
|
||||
Emit('created', result.ConversationUUID)
|
||||
Close()
|
||||
} catch {
|
||||
// 业务错误已由响应拦截器统一提示
|
||||
} finally {
|
||||
Submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => Props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) ResetAndLoad()
|
||||
else if (SearchTimer) clearTimeout(SearchTimer)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="新建群聊"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="Emit('update:modelValue', $event)"
|
||||
@closed="ResetAndLoad"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="群名称" required>
|
||||
<el-input v-model="Title" maxlength="40" show-word-limit placeholder="请输入群名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="`选择成员(已选 ${SelectedCount} 人,含自己共 ${MemberTotal} 人)`" required>
|
||||
<el-select
|
||||
v-model="SelectedUUIDs"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
:remote-method="OnSearchInput"
|
||||
:loading="Searching"
|
||||
placeholder="输入姓名搜索并选择成员"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="member in Options"
|
||||
:key="member.UserUUID"
|
||||
:label="MemberName(member)"
|
||||
:value="member.UserUUID"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="GroupMemberLimit > 0" class="quota-hint">
|
||||
单群成员上限 {{ GroupMemberLimit }} 人(含自己)
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 配额占用:Usage vs Quotas -->
|
||||
<el-form-item label="配额占用">
|
||||
<div class="quota-grid">
|
||||
<span>个人群聊</span>
|
||||
<span>{{ GroupUserUsed }} / {{ GroupUserLimit }}</span>
|
||||
<span>公司群聊</span>
|
||||
<span>{{ GroupCompanyUsed }} / {{ GroupCompanyLimit }}</span>
|
||||
<span>公司成员</span>
|
||||
<span>{{ CompanyMemberUsed }} / {{ CompanyMemberLimit }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 超限原因提示 -->
|
||||
<el-alert
|
||||
v-for="(tip, index) in ExceedTips"
|
||||
:key="index"
|
||||
:type="tip.type"
|
||||
:title="tip.text"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="quota-alert"
|
||||
/>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="Close">取消</el-button>
|
||||
<el-button type="primary" :loading="Submitting" :disabled="!CanSubmit" @click="Submit">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.quota-hint {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.quota-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 16px;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
span:nth-child(odd) {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.quota-alert {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user