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:
TermiNexus committed 2026-09-26 23:46:41 +08:00
1 parent e79e3bb28b
commit eb5b51a474
9 files changed
+2308

No files matched your search

+86
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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`
}
+8
View File
@@ -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: '枢机协律-商店',
+7
View File
@@ -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',
+208
View File
@@ -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
}
+292
View File
@@ -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
+6
View File
@@ -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: {