代码更新

This commit is contained in:
乘风
2026-02-04 11:49:25 +08:00
parent b0dfb73d78
commit aac4fd65be
719 changed files with 880 additions and 48719 deletions

View File

View File

@@ -41,6 +41,5 @@
"typescript.suggest.autoImports": true,
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.detectIndentation": false,
"git.ignoreLimitWarning": true
"editor.detectIndentation": false
}

View File

@@ -1,21 +0,0 @@
# EditorConfig 帮助不同编辑器和 IDE 维护一致的编码风格
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{json,yml,yaml}]
indent_size = 2
[*.vue]
indent_size = 2

View File

@@ -1,19 +0,0 @@
node_modules
dist
dist-ssr
*.local
# Build outputs
*.min.js
*.min.css
# Config files
*.config.js
*.config.cjs
*.config.mjs
# Static assets
public
# Logs
*.log

View File

@@ -1,316 +0,0 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"VNode": true,
"WritableComputedRef": true,
"acceptHMRUpdate": true,
"asyncComputed": true,
"autoResetRef": true,
"computed": true,
"computedAsync": true,
"computedEager": true,
"computedInject": true,
"computedWithControl": true,
"controlledComputed": true,
"controlledRef": true,
"createApp": true,
"createEventHook": true,
"createGlobalState": true,
"createInjectionState": true,
"createPinia": true,
"createReactiveFn": true,
"createReusableTemplate": true,
"createSharedComposable": true,
"createTemplatePromise": true,
"createUnrefFn": true,
"customRef": true,
"debouncedRef": true,
"debouncedWatch": true,
"defineAsyncComponent": true,
"defineComponent": true,
"defineStore": true,
"eagerComputed": true,
"effectScope": true,
"extendRef": true,
"getActivePinia": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"ignorableWatch": true,
"inject": true,
"injectLocal": true,
"isDefined": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"makeDestructurable": true,
"mapActions": true,
"mapGetters": true,
"mapState": true,
"mapStores": true,
"mapWritableState": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeRouteLeave": true,
"onBeforeRouteUpdate": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onStartTyping": true,
"onUnmounted": true,
"onUpdated": true,
"onWatcherCleanup": true,
"pausableWatch": true,
"provide": true,
"provideLocal": true,
"reactify": true,
"reactifyObject": true,
"reactive": true,
"reactiveComputed": true,
"reactiveOmit": true,
"reactivePick": true,
"readonly": true,
"ref": true,
"refAutoReset": true,
"refDebounced": true,
"refDefault": true,
"refThrottled": true,
"refWithControl": true,
"resolveComponent": true,
"resolveRef": true,
"resolveUnref": true,
"setActivePinia": true,
"setMapStoreSuffix": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"storeToRefs": true,
"syncRef": true,
"syncRefs": true,
"templateRef": true,
"throttledRef": true,
"throttledWatch": true,
"toRaw": true,
"toReactive": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"tryOnBeforeMount": true,
"tryOnBeforeUnmount": true,
"tryOnMounted": true,
"tryOnScopeDispose": true,
"tryOnUnmounted": true,
"unref": true,
"unrefElement": true,
"until": true,
"useActiveElement": true,
"useAnimate": true,
"useArrayDifference": true,
"useArrayEvery": true,
"useArrayFilter": true,
"useArrayFind": true,
"useArrayFindIndex": true,
"useArrayFindLast": true,
"useArrayIncludes": true,
"useArrayJoin": true,
"useArrayMap": true,
"useArrayReduce": true,
"useArraySome": true,
"useArrayUnique": true,
"useAsyncQueue": true,
"useAsyncState": true,
"useAttrs": true,
"useBase64": true,
"useBattery": true,
"useBluetooth": true,
"useBreakpoints": true,
"useBroadcastChannel": true,
"useBrowserLocation": true,
"useCached": true,
"useClipboard": true,
"useClipboardItems": true,
"useCloned": true,
"useColorMode": true,
"useConfirmDialog": true,
"useCounter": true,
"useCssModule": true,
"useCssVar": true,
"useCssVars": true,
"useCurrentElement": true,
"useCycleList": true,
"useDark": true,
"useDateFormat": true,
"useDebounce": true,
"useDebounceFn": true,
"useDebouncedRefHistory": true,
"useDeviceMotion": true,
"useDeviceOrientation": true,
"useDevicePixelRatio": true,
"useDevicesList": true,
"useDisplayMedia": true,
"useDocumentVisibility": true,
"useDraggable": true,
"useDropZone": true,
"useElementBounding": true,
"useElementByPoint": true,
"useElementHover": true,
"useElementSize": true,
"useElementVisibility": true,
"useEventBus": true,
"useEventListener": true,
"useEventSource": true,
"useEyeDropper": true,
"useFavicon": true,
"useFetch": true,
"useFileDialog": true,
"useFileSystemAccess": true,
"useFocus": true,
"useFocusWithin": true,
"useFps": true,
"useFullscreen": true,
"useGamepad": true,
"useGeolocation": true,
"useId": true,
"useIdle": true,
"useImage": true,
"useInfiniteQuery": true,
"useInfiniteScroll": true,
"useIntersectionObserver": true,
"useInterval": true,
"useIntervalFn": true,
"useKeyModifier": true,
"useLastChanged": true,
"useLink": true,
"useLocalStorage": true,
"useMagicKeys": true,
"useManualRefHistory": true,
"useMediaControls": true,
"useMediaQuery": true,
"useMemoize": true,
"useMemory": true,
"useModel": true,
"useMounted": true,
"useMouse": true,
"useMouseInElement": true,
"useMousePressed": true,
"useMutation": true,
"useMutationObserver": true,
"useNavigatorLanguage": true,
"useNetwork": true,
"useNow": true,
"useObjectUrl": true,
"useOffsetPagination": true,
"useOnline": true,
"usePageLeave": true,
"useParallax": true,
"useParentElement": true,
"usePerformanceObserver": true,
"usePermission": true,
"usePointer": true,
"usePointerLock": true,
"usePointerSwipe": true,
"usePreferredColorScheme": true,
"usePreferredContrast": true,
"usePreferredDark": true,
"usePreferredLanguages": true,
"usePreferredReducedMotion": true,
"usePrevious": true,
"useQuery": true,
"useQueryClient": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useRoute": true,
"useRouter": true,
"useScreenOrientation": true,
"useScreenSafeArea": true,
"useScriptTag": true,
"useScroll": true,
"useScrollLock": true,
"useSessionStorage": true,
"useShare": true,
"useSlots": true,
"useSorted": true,
"useSpeechRecognition": true,
"useSpeechSynthesis": true,
"useStepper": true,
"useStorage": true,
"useStorageAsync": true,
"useStyleTag": true,
"useSupported": true,
"useSwipe": true,
"useTemplateRef": true,
"useTemplateRefsList": true,
"useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,
"useTimestamp": true,
"useTitle": true,
"useToNumber": true,
"useToString": true,
"useToggle": true,
"useTransition": true,
"useUrlSearchParams": true,
"useUserMedia": true,
"useVModel": true,
"useVModels": true,
"useVibrate": true,
"useVirtualList": true,
"useWakeLock": true,
"useWebNotification": true,
"useWebSocket": true,
"useWebWorker": true,
"useWebWorkerFn": true,
"useWindowFocus": true,
"useWindowScroll": true,
"useWindowSize": true,
"watch": true,
"watchArray": true,
"watchAtMost": true,
"watchDebounced": true,
"watchDeep": true,
"watchEffect": true,
"watchIgnorable": true,
"watchImmediate": true,
"watchOnce": true,
"watchPausable": true,
"watchPostEffect": true,
"watchSyncEffect": true,
"watchThrottled": true,
"watchTriggerable": true,
"watchWithFilter": true,
"whenever": true
}
}

View File

@@ -1,33 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.production.local
.env.development.local
# Build outputs
*.tsbuildinfo

View File

@@ -1,4 +0,0 @@
# npm 配置
registry=https://registry.npmmirror.com
save-exact=false
engine-strict=false

View File

@@ -1,30 +0,0 @@
# Dependencies
node_modules
package-lock.json
pnpm-lock.yaml
yarn.lock
# Build outputs
dist
dist-ssr
*.local
# Logs
*.log
# Editor
.vscode
.idea
# Cache
.cache
.parcel-cache
# Environment
.env
.env.local
.env.production.local
# Other
*.min.js
*.min.css

View File

@@ -1,10 +0,0 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "es5",
"arrowParens": "always",
"endOfLine": "lf"
}

View File

@@ -1,8 +0,0 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss"
]
}

View File

@@ -1,183 +0,0 @@
# API接口修正说明
## 🔧 修正内容
根据旧项目的实际API调用已修正以下接口
### 1. 好友列表接口
**旧项目实现**
```typescript
// old/src/pages/pc/ckbox/weChat/api.ts
export function getContactList(params, options?: ListRequestOptions) {
return request(
"/v1/kefu/wechatFriend/list",
params,
"GET",
undefined,
options?.debounceGap,
);
}
```
**新项目修正**
```typescript
// TouchVueThree/src/api/modules/wechat.ts
export function getContactList(params: {
wechatAccountId?: number
groupId?: number
keyword?: string
page?: number
limit?: number
pageNum?: number
pageSize?: number
}) {
// 统一参数格式page/limit 或 pageNum/pageSize
const requestParams: any = {}
if (params.page !== undefined) requestParams.page = params.page
if (params.limit !== undefined) requestParams.limit = params.limit
if (params.pageNum !== undefined) requestParams.page = params.pageNum
if (params.pageSize !== undefined) requestParams.limit = params.pageSize
if (params.wechatAccountId !== undefined) requestParams.wechatAccountId = params.wechatAccountId
if (params.groupId !== undefined) requestParams.groupId = params.groupId
if (params.keyword !== undefined) requestParams.keyword = params.keyword
return request('/v1/kefu/wechatFriend/list', requestParams, 'GET')
}
```
**关键修正**
- ✅ 使用 **GET** 方法不是POST
- ✅ 参数使用 `page``limit`(旧项目标准)
- ✅ 支持 `pageNum/pageSize` 兼容格式
### 2. 群列表接口
**旧项目实现**
```typescript
// old/src/pages/pc/ckbox/weChat/api.ts
export function getGroupList(params, options?: ListRequestOptions) {
return request(
"/v1/kefu/wechatChatroom/list",
params,
"GET",
undefined,
options?.debounceGap,
);
}
```
**新项目修正**
```typescript
export function getGroupList(params: {
wechatAccountId?: number
groupId?: number
keyword?: string
page?: number
limit?: number
pageNum?: number
pageSize?: number
}) {
// 统一参数格式
const requestParams: any = {}
if (params.page !== undefined) requestParams.page = params.page
if (params.limit !== undefined) requestParams.limit = params.limit
// ... 其他参数处理
return request('/v1/kefu/wechatChatroom/list', requestParams, 'GET')
}
```
**关键修正**
- ✅ 使用 **GET** 方法不是POST
- ✅ 路径正确:`/v1/kefu/wechatChatroom/list`
### 3. 分组列表接口
**旧项目实现**
```typescript
// old/src/pages/pc/ckbox/weChat/api.ts
export function getLabelsListByGroup(params) {
return request("/v1/kefu/wechatGroup/list", params, "GET");
}
```
**新项目修正**
```typescript
export function getContactGroups(accountId?: number) {
const params: any = {}
if (accountId !== undefined && accountId !== 0) {
params.wechatAccountId = accountId
}
return request('/v1/kefu/wechatGroup/list', params, 'GET')
}
export function getLabelsListByGroup(params?: { wechatAccountId?: number }) {
return request('/v1/kefu/wechatGroup/list', params || {}, 'GET')
}
```
**关键修正**
- ✅ 使用 **GET** 方法
- ✅ 添加 `getLabelsListByGroup` 别名函数
### 4. 删除分组接口
**旧项目实现**
```typescript
// old/src/pages/pc/ckbox/weChat/components/SidebarMenu/WechatFriends/api.ts
export function deleteGroup(id) {
return request("/v1/kefu/wechatGroup/delete?id=" + id, "DELETE");
}
```
**新项目修正**
```typescript
export function deleteGroup(id: number) {
return request(`/v1/kefu/wechatGroup/delete?id=${id}`, null, 'DELETE')
}
```
**关键修正**
- ✅ 使用查询参数 `?id=${id}`(不是路径参数)
## 📋 API接口对照表
| 功能 | 旧项目路径 | 新项目路径 | 方法 | 状态 |
|------|-----------|-----------|------|------|
| 好友列表 | `/v1/kefu/wechatFriend/list` | `/v1/kefu/wechatFriend/list` | GET | ✅ 已修正 |
| 群列表 | `/v1/kefu/wechatChatroom/list` | `/v1/kefu/wechatChatroom/list` | GET | ✅ 已修正 |
| 分组列表 | `/v1/kefu/wechatGroup/list` | `/v1/kefu/wechatGroup/list` | GET | ✅ 已修正 |
| 新增分组 | `/v1/kefu/wechatGroup/add` | `/v1/kefu/wechatGroup/add` | POST | ✅ 正确 |
| 更新分组 | `/v1/kefu/wechatGroup/update` | `/v1/kefu/wechatGroup/update` | POST | ✅ 正确 |
| 删除分组 | `/v1/kefu/wechatGroup/delete?id=${id}` | `/v1/kefu/wechatGroup/delete?id=${id}` | DELETE | ✅ 已修正 |
| 移动分组 | `/v1/kefu/wechatGroup/move` | `/v1/kefu/wechatGroup/move` | POST | ✅ 正确 |
## 🔍 参数格式说明
### 好友/群列表参数
```typescript
{
page?: number // 页码(旧项目标准)
limit?: number // 每页数量(旧项目标准)
wechatAccountId?: number // 账号ID0表示全部
groupId?: number // 分组ID0表示未分组/默认分组)
keyword?: string // 搜索关键词
}
```
### 分组列表参数
```typescript
{
wechatAccountId?: number // 账号ID可选
page?: number // 页码(可选)
limit?: number // 每页数量(可选)
}
```
## ✅ 修正完成
所有API接口已按照旧项目的实际实现进行修正确保
1. ✅ HTTP方法正确GET/POST/DELETE
2. ✅ 路径正确
3. ✅ 参数格式正确
4. ✅ 参数名称正确page/limit vs pageNum/pageSize

View File

@@ -1,280 +0,0 @@
# API 接口说明
## ⚠️ 重要:必需的 API 接口
改造后的聊天系统需要以下 API 接口支持。如果后端接口路径不同,请修改 `src/api/modules/wechat.ts` 中的对应函数。
---
## 🔴 必需接口(高优先级)
### 1. 获取好友详情
**用途**: 收到陌生好友消息时,自动获取好友完整信息并创建会话
**当前实现**:
```typescript
// src/api/modules/wechat.ts
export function getFriendDetail(params: { friendId: number }) {
return request(`/v1/kefu/wechatFriend/detail/${params.friendId}`, {}, 'GET')
}
```
**如果接口路径不同,请修改为**:
```typescript
// 示例1: 使用 POST 请求
export function getFriendDetail(params: { friendId: number }) {
return request('/v1/kefu/wechatFriend/detail', params, 'POST')
}
// 示例2: 使用 request2
export function getFriendDetail(params: { friendId: number }) {
return request2('/api/wechatFriend/detail', params, 'GET')
}
// 示例3: 使用不同的路径
export function getFriendDetail(params: { friendId: number }) {
return request(`/api/friend/${params.friendId}`, {}, 'GET')
}
```
**返回数据格式要求**:
```typescript
{
id: number // 好友ID
nickname: string // 昵称
conRemark?: string // 备注名(可选)
avatar: string // 头像URL
wxid?: string // 微信ID可选
wechatAccountId: number // 所属客服账号
// ... 其他字段
}
```
---
### 2. 获取群聊详情
**用途**: 收到陌生群聊消息时,自动获取群聊完整信息并创建会话
**当前实现**:
```typescript
// src/api/modules/wechat.ts
export function getGroupDetail(params: { groupId: number }) {
return request(`/v1/kefu/wechatChatroom/detail/${params.groupId}`, {}, 'GET')
}
```
**如果接口路径不同,请修改为**:
```typescript
// 示例1: 使用 POST 请求
export function getGroupDetail(params: { groupId: number }) {
return request('/v1/kefu/wechatChatroom/detail', params, 'POST')
}
// 示例2: 使用 request2
export function getGroupDetail(params: { groupId: number }) {
return request2('/api/wechatChatroom/detail', params, 'GET')
}
// 示例3: 使用不同的路径
export function getGroupDetail(params: { groupId: number }) {
return request(`/api/group/${params.groupId}`, {}, 'GET')
}
```
**返回数据格式要求**:
```typescript
{
id: number // 群聊ID
nickname: string // 群名称
avatar: string // 群头像
chatroomId?: string // 群聊ID可选
memberCount?: number // 成员数(可选)
wechatAccountId: number // 所属客服账号
// ... 其他字段
}
```
---
## 🟡 可选接口(中优先级)
### 3. 增量同步消息
**用途**: WebSocket 断线重连后,同步断线期间遗漏的消息
**当前未实现**,如果后端提供此接口,请添加:
```typescript
// src/api/modules/wechat.ts
/**
* 获取指定时间之后的消息(增量同步)
*/
export function getMessagesSince(params: {
wechatAccountId: number
since: number // 时间戳(毫秒)
limit?: number // 数量限制(可选)
}) {
return request('/v1/kefu/message/since', params, 'GET')
}
```
**返回数据格式**:
```typescript
{
list: Message[] // 消息列表
total: number // 总数
}
```
**使用位置**: `src/composables/business/wechat/useWebSocket.ts` 中的 `syncMissedMessages()` 函数
---
## 📝 接口调用时机
### getFriendDetail
**调用时机**:
1. WebSocket 收到陌生好友的新消息
2. 会话不存在时自动调用
3. 创建临时会话后,后台重试获取详情
**调用位置**: `src/utils/dbManagers/SessionManager.ts`
```typescript
// 自动调用,无需手动处理
const contactInfo = await getFriendDetail({ friendId: sessionId })
```
### getGroupDetail
**调用时机**:
1. WebSocket 收到陌生群聊的新消息
2. 会话不存在时自动调用
3. 创建临时会话后,后台重试获取详情
**调用位置**: `src/utils/dbManagers/SessionManager.ts`
```typescript
// 自动调用,无需手动处理
const contactInfo = await getGroupDetail({ groupId: sessionId })
```
---
## 🔧 如何修改接口路径
### 步骤1: 找到函数定义
打开 `src/api/modules/wechat.ts`,找到对应的函数:
```typescript
export function getFriendDetail(params: { friendId: number }) {
return request(`/v1/kefu/wechatFriend/detail/${params.friendId}`, {}, 'GET')
}
```
### 步骤2: 修改路径和请求方式
根据后端实际接口修改:
```typescript
// 如果后端接口是 POST 请求
export function getFriendDetail(params: { friendId: number }) {
return request('/v1/kefu/wechatFriend/detail', params, 'POST')
}
// 如果使用 request2
export function getFriendDetail(params: { friendId: number }) {
return request2('/api/wechatFriend/detail', params, 'GET')
}
```
### 步骤3: 确保返回数据格式匹配
确保后端返回的数据包含以下字段(至少):
- `id`: 好友/群聊ID
- `nickname`: 昵称
- `avatar`: 头像URL
- `wechatAccountId`: 所属客服账号
如果字段名不同,需要修改 `SessionManager.ts` 中的数据映射。
---
## 🐛 故障排查
### 问题1: 接口返回 404
**原因**: 接口路径不正确
**解决**: 修改 `src/api/modules/wechat.ts` 中的接口路径
### 问题2: 接口返回数据格式不匹配
**原因**: 后端返回的字段名与预期不同
**解决**: 修改 `SessionManager.ts` 中的数据映射:
```typescript
// 在 createSessionFromMessage() 函数中
const newSession: ChatSession = {
// 如果后端返回的是 name 而不是 nickname
nickname: contactInfo.name || contactInfo.nickname,
// 如果后端返回的是 remark 而不是 conRemark
conRemark: contactInfo.remark || contactInfo.conRemark,
// ...
}
```
### 问题3: 接口需要额外参数
**原因**: 后端接口需要更多参数(如 wechatAccountId
**解决**: 修改函数签名和调用:
```typescript
// 修改函数签名
export function getFriendDetail(params: {
friendId: number
wechatAccountId?: number // 添加可选参数
}) {
return request('/v1/kefu/wechatFriend/detail', params, 'POST')
}
// 在 SessionManager.ts 中调用时传入
const contactInfo = await getFriendDetail({
friendId: sessionId,
wechatAccountId: wechatAccountId
})
```
---
## ✅ 测试清单
修改接口后,请测试以下场景:
- [ ] 收到陌生好友消息时,能自动获取详情并创建会话
- [ ] 收到陌生群聊消息时,能自动获取详情并创建会话
- [ ] 接口失败时,能创建临时会话(降级方案)
- [ ] 后台重试能成功更新会话详情
- [ ] 返回的数据能正确映射到会话对象
---
## 📞 需要帮助?
如果遇到问题:
1. 检查浏览器控制台的错误信息
2. 检查网络请求的 URL 和参数
3. 检查后端返回的数据格式
4. 参考 [聊天系统改造方案.md](./聊天系统改造方案.md) 中的接口说明
---
**最后更新**: 2026-01-13

View File

@@ -1,27 +0,0 @@
# 更新日志
所有重要的项目变更都会记录在此文件中。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)
版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
## [1.0.0] - 2024-01-XX
### 新增
- 初始化项目
- 集成 Vite + Vue 3 + Tailwind CSS
- 创建基础项目结构
- 添加聊天模块ChatList, ChatWindow, ChatDetail
- 添加账号管理页面Accounts
- 添加数据看板页面Analytics
- 创建全局导航栏组件Sidebar
- 创建 AI 算力展示组件AICreditPill
- 配置 ESLint 和 Prettier
- 添加环境变量支持
### 配置
- Vite 构建配置
- Tailwind CSS 主题配置
- PostCSS 配置
- 路径别名配置(@ → src
- 编辑器配置文件

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 触客宝 Pro
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,323 +0,0 @@
# TouchVueThree 项目目录结构说明
> **PC 端微信客服系统 - Vue3 + TypeScript + Element Plus**
## 📁 目录结构
```
TouchVueThree/
├── public/ # 静态资源(不会被编译)
│ ├── favicon.ico
│ └── assets/ # 图片、表情包等静态资源
├── src/
│ ├── api/ # API 接口层
│ │ ├── request.ts # Axios 封装(拦截器、错误处理)
│ │ ├── interceptors.ts # 请求/响应拦截器
│ │ └── modules/ # 按业务模块划分的接口
│ │ ├── user.ts # 用户相关接口
│ │ ├── wechat.ts # 微信相关接口
│ │ ├── ai.ts # AI 相关接口
│ │ └── common.ts # 通用接口(文件上传等)
│ │
│ ├── assets/ # 资源文件
│ │ ├── images/ # 图片资源
│ │ ├── icons/ # 图标资源
│ │ └── styles/ # 样式文件
│ │ ├── variables.scss # 全局变量(颜色、字体)
│ │ ├── mixins.scss # SCSS 混入
│ │ ├── reset.scss # 样式重置
│ │ └── global.scss # 全局样式
│ │
│ ├── components/ # 公共组件
│ │ ├── common/ # 通用组件(与业务无关)
│ │ │ ├── Button/ # 自定义按钮
│ │ │ ├── Dialog/ # 自定义对话框
│ │ │ ├── Loading/ # 加载组件
│ │ │ ├── Empty/ # 空状态
│ │ │ └── ErrorBoundary/ # 错误边界
│ │ │
│ │ └── business/ # 业务组件(微信客服相关)
│ │ ├── ChatWindow/ # 聊天窗口
│ │ ├── ContactList/ # 联系人列表
│ │ ├── SessionList/ # 会话列表
│ │ ├── CustomerList/ # 客服账号列表
│ │ ├── ProfileCard/ # 客户画像卡片
│ │ ├── EmojiPicker/ # 表情选择器
│ │ ├── FileUpload/ # 文件上传
│ │ └── QuickReply/ # 快捷回复
│ │
│ ├── composables/ # 组合式函数Composition API
│ │ ├── core/ # 核心功能
│ │ │ ├── useAuth.ts # 认证登录
│ │ │ ├── useWebSocket.ts # WebSocket 管理
│ │ │ ├── useEventBus.ts # 事件总线
│ │ │ └── useRequest.ts # 请求封装
│ │ │
│ │ └── business/ # 业务功能
│ │ ├── useMessages.ts # 消息管理
│ │ ├── useContacts.ts # 联系人管理
│ │ ├── useSessions.ts # 会话管理
│ │ ├── useAi.ts # AI 功能
│ │ └── useUpload.ts # 文件上传
│ │
│ ├── directives/ # 自定义指令
│ │ ├── vLoading.ts # 加载指令
│ │ ├── vLazyLoad.ts # 懒加载指令
│ │ └── vPermission.ts # 权限指令
│ │
│ ├── layouts/ # 页面布局
│ │ ├── DefaultLayout.vue # 默认布局
│ │ └── ChatLayout.vue # 聊天布局(三栏)
│ │
│ ├── router/ # 路由配置
│ │ ├── index.ts # 路由主文件
│ │ ├── guards.ts # 路由守卫(权限控制)
│ │ └── routes.ts # 路由配置
│ │
│ ├── stores/ # Pinia 状态管理
│ │ ├── index.ts # Store 统一出口
│ │ └── modules/ # Store 模块
│ │ ├── user.ts # 用户状态
│ │ ├── app.ts # 应用全局状态
│ │ ├── websocket.ts # WebSocket 状态
│ │ └── wechat/ # 微信模块
│ │ ├── contacts.ts # 联系人
│ │ ├── messages.ts # 消息
│ │ ├── sessions.ts # 会话
│ │ └── ai.ts # AI
│ │
│ ├── types/ # TypeScript 类型定义
│ │ ├── global.d.ts # 全局类型
│ │ ├── api.d.ts # API 类型
│ │ ├── user.ts # 用户类型
│ │ ├── wechat.ts # 微信类型
│ │ └── websocket.ts # WebSocket 类型
│ │
│ ├── utils/ # 工具函数
│ │ ├── common.ts # 通用工具
│ │ ├── date.ts # 日期处理
│ │ ├── format.ts # 格式化
│ │ ├── validator.ts # 验证
│ │ ├── storage.ts # 本地存储
│ │ ├── event-bus.ts # 事件总线
│ │ └── sentry/ # 错误监控
│ │ └── index.ts
│ │
│ ├── views/ # 页面组件
│ │ ├── Login/ # 登录页
│ │ │ └── index.vue
│ │ │
│ │ ├── Chat/ # 聊天主页(核心功能)
│ │ │ ├── index.vue
│ │ │ └── components/ # 聊天页专用组件
│ │ │ ├── Sidebar.vue # 左侧边栏
│ │ │ ├── Main.vue # 中间聊天区
│ │ │ └── Panel.vue # 右侧面板
│ │ │
│ │ ├── Dashboard/ # 数据看板
│ │ │ └── index.vue
│ │ │
│ │ ├── Settings/ # 系统设置
│ │ │ └── index.vue
│ │ │
│ │ ├── PowerCenter/ # 能力中心
│ │ │ ├── CustomerManagement/ # 客户管理
│ │ │ ├── ContentManagement/ # 内容管理
│ │ │ ├── DataStatistics/ # 数据统计
│ │ │ └── AiTraining/ # AI 训练
│ │ │
│ │ └── 404/ # 404 页面
│ │ └── index.vue
│ │
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── .eslintrc.cjs # ESLint 配置
├── .prettierrc # Prettier 配置
├── tsconfig.json # TypeScript 配置
├── vite.config.ts # Vite 配置
└── package.json # 项目配置
```
## 🎯 命名规范
### 文件命名
- **组件文件**PascalCase大驼峰
- `ChatWindow.vue`, `MessageList.vue`
- **工具文件**camelCase小驼峰
- `common.ts`, `useAuth.ts`
- **类型文件**camelCase 或 kebab-case
- `user.ts`, `api.d.ts`
### 目录命名
- **kebab-case**(短横线)或 **camelCase**
- `components/business`, `composables/core`
### 组件命名规则
```typescript
// ✅ 推荐
<script setup lang="ts" name="ChatWindow">
// 组件逻辑
</script>
// ❌ 避免
<script setup lang="ts">
// 没有 name 属性
</script>
```
## 🔧 开发规范
### 1. Composables 使用规范
```typescript
// composables/core/useAuth.ts
import { ref, computed } from 'vue'
import { useUserStore } from '@/stores'
export function useAuth() {
const userStore = useUserStore()
const isLoggedIn = computed(() => userStore.isLoggedIn)
const login = async (credentials) => {
// 登录逻辑
}
return {
isLoggedIn,
login
}
}
```
### 2. Store 使用规范
```typescript
// stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// State
const user = ref(null)
const token = ref('')
// Getters
const isLoggedIn = computed(() => !!token.value)
// Actions
const setUser = (userData) => {
user.value = userData
}
return { user, token, isLoggedIn, setUser }
}, {
persist: {
key: 'user-store',
paths: ['user', 'token']
}
})
```
### 3. API 调用规范
```typescript
// api/modules/user.ts
import request from '../request'
export const loginApi = (data: LoginParams) => {
return request('/auth/login', data, 'POST')
}
export const getUserInfoApi = () => {
return request('/user/info', {}, 'GET')
}
```
### 4. 组件引入规范
```vue
<script setup lang="ts">
// 1. Vue API
import { ref, computed, onMounted } from 'vue'
// 2. 第三方库
import { ElMessage } from 'element-plus'
// 3. Store
import { useUserStore } from '@/stores'
// 4. Composables
import { useAuth } from '@/composables/core/useAuth'
// 5. 类型
import type { User } from '@/types/user'
// 6. 组件
import ChatWindow from '@/components/business/ChatWindow/index.vue'
</script>
```
## 📝 注释规范
```typescript
/**
* 用户登录
* @param credentials 登录凭证
* @returns Promise<UserInfo>
*/
export async function login(credentials: LoginParams): Promise<UserInfo> {
// 实现逻辑
}
```
## 🚀 启动命令
```bash
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 类型检查
pnpm type-check
# 代码检查
pnpm lint
# 格式化代码
pnpm format
# 构建生产版本
pnpm build
# 预览生产版本
pnpm preview
# 打包分析
pnpm analyze
```
## 📌 下一步计划
1. ✅ 目录结构已创建
2. ✅ package.json 已优化
3. ⏳ 安装依赖:`pnpm install`
4. ⏳ 创建基础配置文件
5. ⏳ 开发核心功能
---
**更新时间**: 2026-01-12
**版本**: 2.0.0
**技术栈**: Vue 3.4 + TypeScript 5.4 + Element Plus 2.5 + Pinia 2.1

View File

@@ -1,402 +0,0 @@
# TouchVueThree 快速开始指南
## 🚀 项目已完成配置
### ✅ 已完成的工作
1. **目录结构创建**
- 完整的 PC 端目录结构
- 模块化的代码组织
2. **package.json 优化**
- 移除移动端相关依赖
- 添加 PC 端必需依赖
- 优化打包配置
3. **Vite 配置优化**
- 自动导入配置
- 路径别名配置
- 打包优化配置
- Gzip 压缩
4. **样式系统**
- variables.scss (全局变量)
- mixins.scss (混入工具)
- reset.scss (样式重置)
- global.scss (全局样式)
---
## 📦 第一步:安装依赖
```bash
# 进入项目目录
cd TouchVueThree
# 删除旧的 node_modules 和 lock 文件(可选,但推荐)
rm -rf node_modules pnpm-lock.yaml
# 安装所有依赖
pnpm install
```
### 依赖说明
**核心框架**:
- `vue@^3.4.21` - Vue 3 框架
- `vue-router@^4.2.5` - 路由管理
- `pinia@^2.1.7` - 状态管理
- `pinia-plugin-persistedstate@^3.2.1` - Pinia 持久化
**UI 组件库**:
- `element-plus@^2.5.6` - PC 端 UI 组件
- `@element-plus/icons-vue@^2.3.1` - Element Plus 图标
**数据请求**:
- `axios@^1.6.7` - HTTP 客户端
- `@tanstack/vue-query@^5.20.0` - 数据请求管理(自动缓存、重试)
**工具库**:
- `@vueuse/core@^10.7.2` - Vue 组合式工具集
- `dayjs@^1.11.13` - 日期处理
- `lodash-es@^4.17.21` - 工具函数库
- `mitt@^3.0.1` - 事件总线
- `nanoid@^5.0.4` - ID 生成器
**图表**:
- `echarts@^5.6.0` - 图表库
- `vue-echarts@^6.6.8` - Vue ECharts 封装
**监控**:
- `@sentry/vue@^7.100.0` - 错误监控
---
## 🔧 第二步:创建环境变量文件
创建 `.env.development` 文件:
```env
# API 基础地址
VITE_API_BASE_URL=http://localhost:3000/api
# WebSocket 地址
VITE_API_WS_URL=ws://localhost:3000/ws
# 应用标题
VITE_APP_TITLE=触客宝 - 开发环境
# Sentry DSN
VITE_SENTRY_DSN=
# 是否启用 Mock 数据
VITE_USE_MOCK=false
```
创建 `.env.production` 文件:
```env
# API 基础地址
VITE_API_BASE_URL=https://api.touchkebao.com/api
# WebSocket 地址
VITE_API_WS_URL=wss://api.touchkebao.com/ws
# 应用标题
VITE_APP_TITLE=触客宝
# Sentry DSN
VITE_SENTRY_DSN=your_sentry_dsn_here
# 是否启用 Mock 数据
VITE_USE_MOCK=false
```
---
## 🎯 第三步:启动开发服务器
```bash
# 启动开发服务器
pnpm dev
# 项目将在 http://localhost:8888 启动
```
---
## 📂 第四步:理解项目结构
```
src/
├── api/ # API 接口(按模块划分)
├── assets/ # 静态资源和样式
├── components/ # 公共组件
│ ├── common/ # 通用组件
│ └── business/ # 业务组件
├── composables/ # 组合式函数
│ ├── core/ # 核心功能
│ └── business/ # 业务功能
├── directives/ # 自定义指令
├── layouts/ # 布局组件
├── router/ # 路由配置
├── stores/ # Pinia 状态管理
│ └── modules/ # Store 模块
├── types/ # TypeScript 类型
├── utils/ # 工具函数
└── views/ # 页面组件
```
---
## 🔨 开发指南
### 1. 创建新页面
```vue
<!-- src/views/Example/index.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const message = ref('Hello Vue3!')
</script>
<template>
<div class="example-page">
<h1>{{ message }}</h1>
</div>
</template>
<style scoped lang="scss">
.example-page {
padding: 20px;
}
</style>
```
### 2. 创建 Store
```typescript
// src/stores/modules/example.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useExampleStore = defineStore('example', () => {
// State
const count = ref(0)
// Getters
const doubleCount = computed(() => count.value * 2)
// Actions
const increment = () => {
count.value++
}
return { count, doubleCount, increment }
}, {
persist: {
key: 'example-store',
paths: ['count']
}
})
```
### 3. 创建 API 接口
```typescript
// src/api/modules/example.ts
import request from '../request'
export const getListApi = (params: any) => {
return request('/list', params, 'GET')
}
export const createItemApi = (data: any) => {
return request('/item', data, 'POST')
}
```
### 4. 创建 Composable
```typescript
// src/composables/business/useExample.ts
import { ref } from 'vue'
import { getListApi } from '@/api/modules/example'
export function useExample() {
const loading = ref(false)
const list = ref([])
const fetchList = async () => {
loading.value = true
try {
list.value = await getListApi({})
} finally {
loading.value = false
}
}
return {
loading,
list,
fetchList
}
}
```
---
## 📝 常用命令
```bash
# 开发服务器
pnpm dev
# 类型检查
pnpm type-check
# 代码检查
pnpm lint
# 代码格式化
pnpm format
# 构建生产版本
pnpm build
# 预览生产版本
pnpm preview
# 打包分析
pnpm analyze
```
---
## 🎨 样式使用示例
```vue
<template>
<div class="example">
<!-- 使用全局工具类 -->
<div class="flex-between mb-md">
<span class="text-primary font-bold">标题</span>
<span class="text-secondary">副标题</span>
</div>
<!-- 使用 SCSS 变量 -->
<div class="custom-box">内容</div>
</div>
</template>
<style scoped lang="scss">
.custom-box {
padding: $spacing-lg;
background: $bg-color;
border-radius: $border-radius-large;
box-shadow: $box-shadow-base;
}
</style>
```
---
## 🔍 调试技巧
### 1. Vue DevTools
安装 Vue DevTools 浏览器扩展,用于调试 Vue 组件和 Pinia Store。
### 2. 自动导入类型提示
项目已配置自动导入,会自动生成类型文件:
- `src/auto-imports.d.ts` - Vue API 类型
- `src/components.d.ts` - 组件类型
如果类型提示不生效,重启 VSCode 或运行:
```bash
pnpm dev
```
### 3. ESLint 自动修复
```bash
pnpm lint
```
---
## ⚠️ 注意事项
1. **不要手动修改自动生成的文件**
- `src/auto-imports.d.ts`
- `src/components.d.ts`
- `.eslintrc-auto-import.json`
2. **使用 SCSS 变量**
全局变量已自动导入,可直接使用 `$primary-color`
3. **路径别名**
项目已配置路径别名,推荐使用:
```typescript
import { xxx } from '@/xxx' // src/
import { xxx } from '@api/xxx' // src/api/
import { xxx } from '@components/xxx' // src/components/
import { xxx } from '@stores/xxx' // src/stores/
```
4. **TypeScript 严格模式**
项目启用了 TypeScript 严格模式,请注意类型定义
---
## 🆘 常见问题
### Q: pnpm install 失败?
A: 尝试:
```bash
# 清理缓存
pnpm store prune
# 重新安装
pnpm install
```
### Q: 类型提示不生效?
A:
1. 重启 VSCode
2. 运行 `pnpm dev` 生成类型文件
3. 检查 `tsconfig.json` 配置
### Q: 自动导入不生效?
A:
1. 检查 `vite.config.ts` 中的 AutoImport 配置
2. 重启开发服务器
3. 检查 `.eslintrc-auto-import.json` 是否生成
---
## 📚 相关文档
- [Vue 3 官方文档](https://cn.vuejs.org/)
- [Element Plus 官方文档](https://element-plus.org/zh-CN/)
- [Pinia 官方文档](https://pinia.vuejs.org/zh/)
- [VueUse 官方文档](https://vueuse.org/)
- [TanStack Query 官方文档](https://tanstack.com/query/latest)
---
## 🎉 开始开发
现在您可以开始开发了!建议按以下顺序:
1. ✅ 安装依赖
2. ✅ 创建环境变量文件
3. ✅ 启动开发服务器
4. 🔨 开始编写代码
祝您开发愉快! 🚀

View File

@@ -1,311 +0,0 @@
# 🚀 聊天系统改造版 - 快速开始
> 5分钟快速了解如何使用新架构
---
## 📦 改造内容
### 核心变化
| 改造点 | 旧方式 | 新方式 | 优势 |
|--------|--------|--------|------|
| 数据加载 | 定时轮询 | 订阅机制 | 网络请求减少95% |
| 首屏显示 | 等待API | 缓存优先 | 加载速度提升85% |
| 陌生消息 | 无法显示 | 自动创建 | 不丢消息 |
| 数据隔离 | 单库混存 | 一号一库 | 彻底隔离 |
---
## 🎯 使用方法
### 1⃣ 登录时(自动初始化数据库)
```typescript
// src/stores/modules/user.ts
// ✅ 已自动集成,无需修改
const login = async (params) => {
const response = await loginAPI(params)
// ⭐ 自动初始化数据库(一号一库)
await databaseManager.ensureDatabase(response.member.id)
setUser(response.member)
router.push('/chat')
}
```
### 2⃣ 聊天页面(初始化会话列表)
```vue
<!-- src/views/Chat/index.vue -->
<script setup lang="ts">
import { useSessionStore } from '@/stores/modules/wechat/useSessionStore'
const sessionStore = useSessionStore()
onMounted(async () => {
// ⭐ 初始化会话(缓存优先 + 后台同步)
await sessionStore.init(accountId.value)
})
onUnmounted(() => {
// ⭐ 清理订阅
sessionStore.cleanup()
})
</script>
<template>
<!-- 会话列表自动更新无需手动刷新 -->
<div v-for="session in sessionStore.sortedSessions" :key="session.id">
{{ session.nickname }}: {{ session.content }}
</div>
</template>
```
### 3⃣ WebSocket自动更新会话
```typescript
// src/composables/business/wechat/useWebSocket.ts
// ✅ 已自动集成,无需修改
// WebSocket 收到新消息时:
// 1. 自动更新 IndexedDB
// 2. 自动创建会话(如果不存在)
// 3. 自动触发 UI 更新
// 4. 自动保存消息记录
// 你只需要:连接 WebSocket
const { connect } = useWebSocket()
connect({ accountId, accessToken })
```
### 4⃣ 切换账户
```typescript
// 切换账户时,自动切换数据库
await sessionStore.switchAccount(newAccountId)
// 内部自动完成:
// 1. 切换数据库
// 2. 读取缓存
// 3. 后台同步
```
### 5⃣ 退出登录
```typescript
// src/stores/modules/user.ts
// ✅ 已自动集成,无需修改
const logout = async () => {
// ⭐ 自动关闭数据库
await databaseManager.closeCurrentDatabase()
clearUser()
router.push('/login')
}
```
---
## 🎨 UI 自动更新
### 订阅机制(替代轮询)
```typescript
// ❌ 旧方式:定时器轮询
setInterval(() => {
loadSessions() // 每3秒请求一次浪费资源
}, 3000)
// ✅ 新方式:订阅机制
SessionManager.onUpdate((sessions) => {
// 数据变更时自动调用,无需轮询
this.sessions = sessions
})
```
### 数据流向
```
WebSocket 收到消息
更新 IndexedDB
SessionManager 触发回调
Store 自动更新
UI 自动刷新
```
---
## 🔧 必需的 API 接口
### 1. 获取好友详情(重要!)
```typescript
// 接口GET /api/friend/detail?friendId=123
// 调用时机:收到陌生好友消息时
interface FriendDetail {
id: number
nickname: string
conRemark?: string
avatar: string
wxid: string
wechatAccountId: number
}
```
### 2. 获取群聊详情(重要!)
```typescript
// 接口GET /api/group/detail?groupId=456
// 调用时机:收到陌生群聊消息时
interface GroupDetail {
id: number
nickname: string
avatar: string
chatroomId: string
memberCount: number
wechatAccountId: number
}
```
### 3. 获取会话列表
```typescript
// 接口GET /api/session/list?page=1&limit=200&wechatAccountId=1
// 调用时机:登录、切换账户
interface SessionListResponse {
list: Session[]
total: number
}
```
---
## ⚠️ 重要规则
### ✅ 必须遵守
```typescript
// 1. 使用 db() 函数(带括号)
await db().sessions.toArray() // ✅ 正确
await db.sessions.toArray() // ❌ 错误
// 2. 不要使用定时器轮询
setInterval(() => loadSessions(), 3000) // ❌ 错误
// 3. 使用订阅机制
SessionManager.onUpdate(() => {}) // ✅ 正确
// 4. 组件卸载时清理
onUnmounted(() => {
sessionStore.cleanup()
})
```
---
## 📊 性能提升
| 指标 | 改造前 | 改造后 | 提升 |
|------|--------|--------|------|
| 首屏加载 | 1-3s | <200ms | **85%** |
| 网络请求 | 1200次/小时 | <50次/小时 | **95%** 🔽 |
| 服务器负载 | | | **95%** 🔽 |
| 离线能力 | | 完整缓存 | **100%** 📱 |
---
## 🐛 常见问题
### Q1: 数据库初始化失败?
```typescript
// 错误Database not initialized
// 原因:登录时未初始化
// 解决:已自动集成到 user.ts无需修改
```
### Q2: 会话列表不更新?
```typescript
// 原因:未调用 init()
// 解决:在聊天页面 onMounted 中调用
await sessionStore.init(accountId)
```
### Q3: 陌生好友消息不显示?
```typescript
// 原因:后端未提供 getFriendDetail 接口
// 解决:实现接口(参考上方接口说明)
```
### Q4: 切换账户数据混乱?
```typescript
// 原因:未调用 switchAccount()
// 解决:切换时调用
await sessionStore.switchAccount(newAccountId)
```
---
## 🎯 验收清单
### 功能验收
- [x] 登录后会话列表秒开<200ms
- [x] WebSocket 消息自动更新会话
- [x] 陌生好友消息自动显示
- [x] 切换账户数据正确隔离
- [x] 退出登录数据清空
- [x] 离线可查看缓存
### 性能验收
- [x] 首屏加载 < 200ms
- [x] 切换会话 < 100ms
- [x] 网络请求减少 95%+
- [x] 无定时器轮询
---
## 📚 详细文档
- [聊天系统改造方案.md](./聊天系统改造方案.md) - 完整技术方案3900行
- [聊天系统改造实施说明.md](./聊天系统改造实施说明.md) - 实施说明
---
## 🎉 完成!
改造已完成核心功能已集成到以下文件
```
TouchVueThree/
├── src/
│ ├── utils/
│ │ ├── db.ts ← 数据库管理器
│ │ └── dbManagers/
│ │ ├── SessionManager.ts ← 会话管理器
│ │ └── MessageManager.ts ← 消息管理器
│ ├── stores/modules/
│ │ ├── user.ts ← 用户 Store已集成
│ │ └── wechat/
│ │ └── useSessionStore.ts ← 会话 Store已重构
│ └── composables/business/wechat/
│ └── useWebSocket.ts ← WebSocket已重构
```
**开始使用吧!** 🚀

View File

@@ -1,122 +0,0 @@
# 触客宝 Pro - Vue 3 版本
基于 Vite + Vue 3 + Tailwind CSS 构建的现代化工作台应用。
## 技术栈
- **Vue 3** - 渐进式 JavaScript 框架
- **Vite** - 下一代前端构建工具
- **Tailwind CSS** - 实用优先的 CSS 框架
- **ESLint** - 代码质量检查
- **Prettier** - 代码格式化
## 项目结构
```
TouchVueThree/
├── src/
│ ├── assets/ # 静态资源
│ │ └── style.css # 全局样式Tailwind 指令)
│ ├── components/ # 公共组件
│ │ ├── Sidebar.vue # 全局左侧导航栏
│ │ └── AICreditPill.vue # AI算力展示胶囊
│ ├── views/ # 页面视图
│ │ ├── Chat/ # 聊天模块
│ │ ├── Accounts.vue # 账号管理
│ │ └── Analytics.vue # 数据看板
│ ├── App.vue # 根组件
│ └── main.js # 应用入口
├── public/ # 公共静态资源
├── index.html # HTML 入口
└── vite.config.js # Vite 配置
```
## 开发指南
### 安装依赖
```bash
npm install
# 或
pnpm install
# 或
yarn install
```
### 启动开发服务器
```bash
npm run dev
# 或
pnpm dev
# 或
yarn dev
```
开发服务器将在 `http://localhost:5173` 启动。
### 构建生产版本
```bash
npm run build
# 或
pnpm build
# 或
yarn build
```
构建产物将输出到 `dist/` 目录。
### 预览生产构建
```bash
npm run preview
# 或
pnpm preview
# 或
yarn preview
```
### 代码检查
```bash
# ESLint 检查并自动修复
npm run lint
# Prettier 格式化代码
npm run format
```
## 环境变量
复制 `.env.example``.env` 并根据需要配置环境变量。
```bash
cp .env.example .env
```
## 功能模块
### 1. 聊天模块 (Chat)
- **ChatList** - 消息列表
- **ChatWindow** - 聊天窗口
- **ChatDetail** - CRM 资料详情
### 2. 账号管理 (Accounts)
- 微信账号管理
- 账号状态监控
### 3. 数据看板 (Analytics)
- 业务数据统计
- 可视化图表展示
## 开发规范
- 使用 Vue 3 Composition API
- 组件采用 `<script setup>` 语法
- 样式使用 Tailwind CSS 工具类
- 代码提交前运行 lint 和 format
## 许可证
MIT

View File

@@ -1,233 +0,0 @@
# WebSocket 使用指南
> 新人快速上手指南5 分钟学会使用 WebSocket
## 🚀 快速开始
### 场景 1在组件中接收消息
```vue
<script setup lang="ts">
import { useMessageSubscription } from '@/composables/business/wechat/useMessageSubscription'
// 1⃣ 引入订阅方法
const { onNewMessage } = useMessageSubscription()
// 2⃣ 订阅新消息
onNewMessage((message) => {
console.log('收到新消息:', message)
// 做你想做的事,比如更新 UI
})
</script>
```
**就这么简单!** 不需要关心 WebSocket 连接、断开、重连等细节。
---
### 场景 2发送消息
```typescript
import { useWebSocket } from '@/composables/business/wechat/useWebSocket'
const { sendCommand } = useWebSocket()
// 发送文本消息
sendCommand('CmdSendTextMsg', {
content: '你好',
targetId: 123,
targetType: 'friend',
})
```
---
### 场景 3在 Store 中使用
```typescript
// stores/modules/wechat/useMessageStore.ts
import { defineStore } from 'pinia'
import { useMessageSubscription } from '@/composables/business/wechat/useMessageSubscription'
export const useMessageStore = defineStore('message', () => {
const messages = ref<Message[]>([])
// 订阅消息(只需要写一次)
const { onNewMessage } = useMessageSubscription()
onNewMessage((message) => {
messages.value.push(message)
})
return {
messages,
}
})
```
---
## 📚 常见问题
### Q1: 如何知道有哪些消息类型可以订阅?
**A:** 查看 `useMessageSubscription.ts` 中的方法:
```typescript
// 可用的订阅方法
onNewMessage // 新消息
onMessageUpdate // 消息更新
onMessageRecall // 消息撤回
onSessionUpdate // 会话更新
onAccountStatus // 账号状态
onSystemNotification // 系统通知
```
---
### Q2: 如何取消订阅?
**A:** 订阅方法返回取消函数
```typescript
const unsubscribe = onNewMessage((msg) => {
console.log(msg)
})
// 不需要时取消
unsubscribe()
// 或者在组件卸载时自动取消(推荐)
onUnmounted(() => {
unsubscribe()
})
```
---
### Q3: 消息没有收到怎么办?
**A:** 开发环境会自动打印调试日志
```typescript
// 控制台会显示:
[MessageSubscription] subscribe: message:new, 当前订阅数: 1
[MessageSubscription] emit: message:new, 订阅者数: 1
// 检查:
1. 订阅数是否 > 0
2. emit 是否被调用
3. WebSocket 是否已连接
```
---
### Q4: 为什么不直接调用 Store 方法?
**A:** 对比一下:
```typescript
// ❌ 旧方式:强耦合
import { useMessageStore } from '@/stores/modules/wechat'
const messageStore = useMessageStore()
messageStore.addMessage(msg) // 组件依赖具体的 Store
// ✅ 新方式:解耦
const { onNewMessage } = useMessageSubscription()
onNewMessage((msg) => {
// 组件不关心谁处理消息
// Store 自己会订阅并处理
})
```
**好处:**
- 组件不需要导入 Store
- Store 可以随时更换,不影响组件
- 测试更简单
---
## 🎯 最佳实践
### ✅ DO推荐做法
```typescript
// 1. 在 Store 中统一订阅
// stores/modules/wechat/useMessageStore.ts
setupMessageSubscription({
onNewMessage: (msg) => {
/* 处理 */
},
onMessageUpdate: ({ id, changes }) => {
/* 处理 */
},
})
// 2. 组件中只读取 Store 数据
const messageStore = useMessageStore()
const messages = computed(() => messageStore.messages)
```
### ❌ DON'T不推荐
```typescript
// 1. 不要在多个组件中重复订阅
// 每个消息类型应该只在一个地方订阅Store
// 2. 不要在订阅回调中做耗时操作
onNewMessage(async (msg) => {
await heavyComputation() // ❌ 会阻塞后续消息
})
// 改为异步处理
onNewMessage((msg) => {
heavyComputation().catch(console.error) // ✅ 不阻塞
})
```
---
## 🔧 调试技巧
### 1. 查看订阅统计
```typescript
const { getStats } = useMessageSubscription()
console.log(getStats())
// 输出:
// {
// 'message:new': 2, // 2 个订阅者
// 'message:update': 1, // 1 个订阅者
// 'session:update': 3 // 3 个订阅者
// }
```
### 2. 使用 Vue Devtools
- 安装 Vue Devtools 扩展
- 查看 Pinia Store 状态
- 实时观察消息变化
---
## 📖 进阶阅读
- [WebSocket架构优化方案.md](./WebSocket架构优化方案.md) - 了解架构设计思路
- [发布订阅模式](https://refactoring.guru/design-patterns/observer) - 理解设计模式
- [Vue Composables](https://vuejs.org/guide/reusability/composables.html) - 学习 Composable 用法
---
## 💡 记住这些要点
1. **组件订阅消息**`onNewMessage((msg) => {})`
2. **发送消息**`sendCommand('CmdXxx', data)`
3. **Store 统一管理** → 用 `setupMessageSubscription`
4. **开发环境有日志** → 查看控制台
5. **有问题看文档** → 本文档 + 源码注释
---
就这么简单!你已经学会了 90% 的 WebSocket 使用方法 🎉

View File

@@ -1,562 +0,0 @@
# WebSocket 架构优化方案
## 📊 旧项目问题分析
### 1. **代码组织混乱**
#### 问题表现:
```typescript
// ❌ 旧项目websocket.ts (615行)
// - WebSocket 连接管理
// - 消息发送/接收
// - 心跳/重连逻辑
// - 客服状态查询
// - 持久化存储逻辑
// 所有功能杂糅在一起,职责不清晰
// ❌ 旧项目msgManage.ts (469行+)
// - 40+ 个消息类型处理函数
// - 直接调用多个 StoreweChat, customer, contact等
// - 业务逻辑和消息处理混合
// - 循环依赖严重
```
**核心问题:**
1. **单一文件过大**websocket.ts 615行msgManage.ts 469行+
2. **职责不清**:连接管理、消息处理、业务逻辑混在一起
3. **循环依赖**Store 之间相互调用msgManage 调用多个 Store
4. **难以维护**:新增消息类型需要修改核心文件
5. **测试困难**:耦合严重,无法单独测试
### 2. **消息处理机制复杂**
```typescript
// ❌ 旧项目消息处理流程
WebSocket.onmessage
msgManageCore(data)
messageHandlers[cmdType](message)
直接调用 useWeChatStore.addMessage()
直接调用 useCustomerStore.updateCustomerList()
直接调用 ContactManager.updateContact()
直接调用 db.xxx.put()
手动触发 window.dispatchEvent()
```
**核心问题:**
1. **强耦合**:消息处理直接依赖具体 Store
2. **难以扩展**:新增消息处理逻辑需要修改核心代码
3. **状态管理混乱**:同时操作 Store、IndexedDB、DOM 事件
4. **缺少中间层**:没有统一的消息分发和状态同步机制
### 3. **性能问题**
```typescript
// ❌ 旧项目性能瓶颈
CmdNewMessage: async (message) => {
// 1. 同步写入 Store阻塞主线程
receivedMsg(msgData)
// 2. 同步写入 IndexedDB阻塞主线程
await MessageManager.saveMessage(msgData)
await ContactManager.updateContact(...)
// 3. 同步更新缓存(阻塞主线程)
await sessionListCache.set(...)
// 4. 触发 UI 更新(可能导致多次渲染)
window.dispatchEvent(new CustomEvent(...))
}
```
**核心问题:**
1. **阻塞式写入**:所有操作都是同步的
2. **重复更新**:多次触发 UI 重新渲染
3. **缺少批处理**:高频消息会导致性能问题
4. **缺少优先级**:所有消息同等处理
---
## ✅ 新项目优化方案
### 架构设计原则
1. **关注点分离Separation of Concerns**
2. **单一职责Single Responsibility**
3. **依赖倒置Dependency Inversion**
4. **发布订阅Pub/Sub Pattern**
---
## 📁 文件组织结构
### 1. **分层架构**
```
TouchVueThree/src/composables/business/wechat/
├── useWebSocket.ts # WebSocket 连接管理(核心)
├── useMessageSubscription.ts # 消息订阅中心(核心)
├── handlers/ # 消息处理器(模块化)
│ ├── index.ts # 处理器注册中心
│ ├── messageHandlers.ts # 聊天消息处理
│ ├── accountHandlers.ts # 账号状态处理
│ ├── sessionHandlers.ts # 会话相关处理
│ └── systemHandlers.ts # 系统通知处理
├── middleware/ # 中间件(可选)
│ ├── messageQueue.ts # 消息队列(批处理)
│ ├── messageDedup.ts # 消息去重
│ └── rateLimiter.ts # 流量控制
└── utils/ # 工具函数
├── reconnection.ts # 重连策略
├── heartbeat.ts # 心跳管理
└── serialization.ts # 序列化/反序列化
```
---
## 🎯 核心模块职责
### 1. **useWebSocket.ts - 连接管理器**
**职责:** 只负责 WebSocket 生命周期管理
```typescript
// ✅ 新项目优化后
export function useWebSocket() {
return {
// 连接管理
connect,
disconnect,
reconnect,
// 状态
status,
isConnected,
// 基础通信
send,
sendCommand,
// 高级特性
syncMissedMessages, // 增量同步
}
}
```
**优点:**
- 职责单一,只管连接
- 不依赖具体业务逻辑
- 易于测试和复用
---
### 2. **useMessageSubscription.ts - 消息订阅中心**
**职责:** 消息事件的发布/订阅
```typescript
// ✅ 发布订阅模式
export function useMessageSubscription() {
const messageEmitter = mitt<{
'message:new': Message
'message:update': { id: number; changes: Partial<Message> }
'session:update': Session
'account:status': AccountStatus
}>()
return {
// 订阅(组件层面)
onNewMessage: (handler) => messageEmitter.on('message:new', handler),
onMessageUpdate: (handler) => messageEmitter.on('message:update', handler),
// 发布WebSocket 层面)
emitNewMessage: (msg) => messageEmitter.emit('message:new', msg),
emitMessageUpdate: (id, changes) => messageEmitter.emit('message:update', { id, changes }),
}
}
```
**优点:**
- 解耦发送方和接收方
- 组件可按需订阅
- 支持多个订阅者
- 避免循环依赖
---
### 3. **handlers/ - 模块化处理器**
**职责:** 按业务域划分消息处理逻辑
```typescript
// ✅ handlers/messageHandlers.ts
export const messageHandlers = {
// 新消息
[WS_CMD_TYPE.RECEIVE_MESSAGE]: async (wsMessage) => {
// 1. 数据验证
if (!validate(wsMessage.data)) return
// 2. 更新 IndexedDB异步不阻塞
SessionManager.updateOnNewMessage(...).catch(console.error)
// 3. 发布事件(通知订阅者)
emitNewMessage(wsMessage.data)
},
// 消息状态
[WS_CMD_TYPE.MESSAGE_STATUS]: (wsMessage) => {
emitMessageUpdate(wsMessage.data.id, { status: wsMessage.data.status })
},
}
// ✅ handlers/accountHandlers.ts
export const accountHandlers = {
// 账号状态
[WS_CMD_TYPE.ACCOUNT_STATUS]: (wsMessage) => {
// 处理账号相关逻辑
},
}
// ✅ handlers/index.ts - 注册中心
import { messageHandlers } from './messageHandlers'
import { accountHandlers } from './accountHandlers'
export const createMessageHandler = () => {
const handlers = {
...messageHandlers,
...accountHandlers,
// 易于扩展
}
return (cmdType: string, message: WebSocketMessage) => {
const handler = handlers[cmdType]
if (handler) {
handler(message).catch(console.error)
} else {
console.warn('未注册的消息类型:', cmdType)
}
}
}
```
**优点:**
- **模块化**:按业务域拆分,单个文件 < 200
- **可扩展**新增消息类型只需新增文件
- **可测试**每个 handler 独立测试
- **可维护**修改不影响其他模块
---
## 🔧 具体优化点
### 1. **异步非阻塞处理**
```typescript
// ❌ 旧项目:阻塞式
CmdNewMessage: async (message) => {
await db.messages.put(message) // 阻塞
await updateSession(...) // 阻塞
window.dispatchEvent(...) // 同步
}
// ✅ 新项目:异步非阻塞
handleNewMessage: async (wsMessage) => {
// 1. 立即发布事件UI 优先)
emitNewMessage(wsMessage.data)
// 2. 异步更新数据库(不阻塞 UI
SessionManager.updateOnNewMessage(...)
.catch(console.error) // 静默失败
// 3. UI 自动订阅并响应
// Store 中onNewMessage((msg) => { /* 更新 UI */ })
}
```
---
### 2. **消息去重与批处理**
```typescript
// ✅ middleware/messageQueue.ts
export class MessageQueue {
private queue: WebSocketMessage[] = []
private dedup = new Set<string>()
private timer: NodeJS.Timeout | null = null
// 添加消息(去重 + 批处理)
enqueue(message: WebSocketMessage) {
const key = this.getDeduKey(message)
if (this.dedup.has(key)) return // 去重
this.dedup.add(key)
this.queue.push(message)
// 批处理100ms 内的消息一起处理
this.scheduleFlush()
}
private scheduleFlush() {
if (this.timer) return
this.timer = setTimeout(() => {
this.flush()
}, 100)
}
private flush() {
const batch = this.queue.splice(0, this.queue.length)
this.dedup.clear()
this.timer = null
// 批量处理
batch.forEach(msg => this.handler(msg))
}
}
```
---
### 3. **智能重连策略**
```typescript
// ✅ utils/reconnection.ts
export class ReconnectionManager {
private attempts = 0
private maxAttempts = 5
private baseDelay = 1000
// 指数退避 + 抖动
getNextDelay(): number {
const exponential = this.baseDelay * Math.pow(2, this.attempts)
const jitter = Math.random() * 1000 // 0-1秒随机抖动
return Math.min(exponential + jitter, 30000) // 最大30秒
}
// 重置策略
reset() {
this.attempts = 0
}
// 增加计数
increment() {
this.attempts++
}
// 是否应该重连
shouldReconnect(): boolean {
return this.attempts < this.maxAttempts
}
}
```
---
### 4. **心跳超时检测**
```typescript
// ✅ utils/heartbeat.ts
export class HeartbeatManager {
private timer: NodeJS.Timeout | null = null
private timeoutTimer: NodeJS.Timeout | null = null
start(onTimeout: () => void) {
this.stop()
// 每 30 秒发送心跳
this.timer = setInterval(() => {
sendCommand(WS_CMD_TYPE.HEARTBEAT)
// 5 秒内未收到响应 → 触发超时
this.timeoutTimer = setTimeout(() => {
console.warn('心跳超时,触发重连')
onTimeout()
}, 5000)
}, 30000)
}
// 收到心跳响应
onResponse() {
if (this.timeoutTimer) {
clearTimeout(this.timeoutTimer)
this.timeoutTimer = null
}
}
stop() {
if (this.timer) clearInterval(this.timer)
if (this.timeoutTimer) clearTimeout(this.timeoutTimer)
}
}
```
---
## 🎨 使用示例
### 1. **在组件中使用**
```vue
<script setup lang="ts">
import { useWebSocket } from '@/composables/business/wechat/useWebSocket'
import { useMessageSubscription } from '@/composables/business/wechat/useMessageSubscription'
import { useMessageStore } from '@/stores/modules/wechat'
const { connect, disconnect, sendCommand, isConnected } = useWebSocket()
const { onNewMessage, onMessageUpdate } = useMessageSubscription()
const messageStore = useMessageStore()
// 订阅新消息
onNewMessage((message) => {
// 更新 Store
messageStore.addMessage(message)
// 播放提示音
playSound()
// 显示通知
showNotification(message)
})
// 订阅消息状态更新
onMessageUpdate(({ id, changes }) => {
messageStore.updateMessageStatus(id, changes.status)
})
// 连接
onMounted(() => {
connect({
accessToken: token,
accountId: currentAccountId,
})
})
// 断开
onUnmounted(() => {
disconnect()
})
</script>
```
---
### 2. **在 Store 中使用**
```typescript
// ✅ stores/modules/wechat/useMessageStore.ts
import { defineStore } from 'pinia'
import { useMessageSubscription } from '@/composables/business/wechat/useMessageSubscription'
export const useMessageStore = defineStore('message', () => {
const messages = ref<Message[]>([])
// 订阅消息事件
const { onNewMessage, onMessageUpdate } = useMessageSubscription()
onNewMessage((message) => {
// 添加到消息列表
messages.value.push(message)
// 更新未读数
increaseUnreadCount(message.sessionId)
})
onMessageUpdate(({ id, changes }) => {
const msg = messages.value.find(m => m.id === id)
if (msg) {
Object.assign(msg, changes)
}
})
return {
messages,
// ...
}
})
```
---
## 📊 对比总结
| 维度 | 旧项目 | 新项目 |
|------|--------|--------|
| **文件组织** | 单文件 600+ | 模块化单文件 < 200 |
| **职责划分** | 混乱多职责耦合 | 清晰单一职责 |
| **消息处理** | 直接调用 Store | 发布订阅解耦 |
| **性能** | 阻塞式同步处理 | 异步非阻塞 + 批处理 |
| **扩展性** | 需修改核心代码 | 新增文件即可 |
| **测试性** | 强耦合 | 模块独立 |
| **维护性** | 牵一发动全身 | 修改影响范围小 |
| **重连策略** | 简单固定延迟 | 指数退避 + 抖动 |
| **心跳机制** | 基础心跳 | 心跳 + 超时检测 |
| **增量同步** | | 支持断线重连后同步 |
---
## 🚀 迁移建议
### 阶段 1核心基础已完成
- 创建 `useWebSocket.ts` - 连接管理
- 创建 `useMessageSubscription.ts` - 订阅中心
- 基本消息处理逻辑
### 阶段 2模块化拆分进行中
- [ ] 创建 `handlers/` 目录
- [ ] 拆分消息处理器按业务域
- [ ] 实现消息队列和批处理
### 阶段 3高级特性规划中
- [ ] 消息去重中间件
- [ ] 智能重连策略
- [ ] 增量同步接口对接
- [ ] 性能监控和日志
### 阶段 4测试和优化
- [ ] 单元测试覆盖
- [ ] 集成测试
- [ ] 性能优化
- [ ] 文档完善
---
## 💡 最佳实践
### 1. **组件使用 WebSocket**
```typescript
// ✅ 推荐:使用订阅模式
const { onNewMessage } = useMessageSubscription()
onNewMessage((msg) => { /* 处理 */ })
// ❌ 不推荐:直接调用 WebSocket
useWebSocket().sendCommand(...)
```
### 2. **Store 使用订阅**
```typescript
// ✅ 推荐:在 Store 中统一订阅
defineStore('message', () => {
const { onNewMessage } = useMessageSubscription()
onNewMessage((msg) => { /* 更新 Store */ })
})
// ❌ 不推荐:组件分散订阅
```
### 3. **错误处理**
```typescript
// ✅ 推荐:优雅降级
handleNewMessage(msg).catch((error) => {
console.error('消息处理失败:', error)
// 不阻断后续消息
})
// ❌ 不推荐:抛出错误
await handleNewMessage(msg) // 可能阻塞
```
---
## 📚 参考资料
- WebSocket 规范https://datatracker.ietf.org/doc/html/rfc6455
- 发布订阅模式https://refactoring.guru/design-patterns/observer
- 消息队列最佳实践https://martinfowler.com/articles/201701-event-driven.html

View File

@@ -1,452 +0,0 @@
# WebSocket 架构实施完成报告
> 📅 完成时间2026-01-14
>
> 🎯 目标:基于新架构重构 WebSocket 消息处理系统
---
## ✅ 完成内容
### 1. 核心架构文件
#### 📁 handlers/ 目录(模块化处理器)
```
src/composables/business/wechat/handlers/
├── index.ts # 处理器注册中心
├── messageHandlers.ts # 聊天消息处理
├── accountHandlers.ts # 账号状态处理
├── sessionHandlers.ts # 会话相关处理
└── systemHandlers.ts # 系统通知处理
```
**主要功能:**
- ✅ 消息处理器的统一注册和管理
- ✅ 支持动态注册/注销处理器
- ✅ 完善的错误处理和日志
- ✅ 开发环境调试工具
---
### 2. 订阅系统增强
#### 📄 useMessageSubscription.ts
**新增功能:**
- ✅ 完整的事件类型定义(`MessageEvents`
- ✅ 调试工具:订阅统计、日志追踪
- ✅ 全局单例模式(`setupMessageSubscription`
- ✅ 自动生命周期管理
**改进点:**
```typescript
// 之前:简单的发布订阅
emitNewMessage(message)
// 现在:带调试信息的智能订阅
[MessageSubscription] emit: message:new, 订阅者数: 2
preview: {"id":123,"content":"你好"}...
```
---
### 3. WebSocket 核心简化
#### 📄 useWebSocket.ts
**重构内容:**
- ✅ 移除 200+ 行的消息处理逻辑
- ✅ 集成 `MessageHandlerManager`
- ✅ 统一的消息分发机制
**代码对比:**
```typescript
// ❌ 旧版switch-case 处理100+ 行)
switch (message.cmdType) {
case 'CmdReceiveMessage':
// 50 行处理逻辑
break
case 'CmdMessageStatus':
// 30 行处理逻辑
break
// ... 10+ 个 case
}
// ✅ 新版:一行搞定
await handlerManager.handle(cmdType, message)
```
---
### 4. Store 集成
#### 📄 useMessageStore.ts
**新增功能:**
- ✅ 使用 `setupMessageSubscription` 统一订阅
- ✅ 自动处理新消息、状态更新、撤回
- ✅ 完善的日志和错误处理
**代码示例:**
```typescript
setupMessageSubscription({
onNewMessage: (message) => {
addMessage(message)
},
onMessageUpdate: ({ id, changes }) => {
updateMessageStatus(id, changes.status)
},
onMessageRecall: ({ messageId, sessionId }) => {
// 处理撤回
},
})
```
---
### 5. 常量扩展
#### 📄 constants/wechat.ts
**扩展内容:**
- ✅ 40+ 个 WebSocket 命令类型
- ✅ 按功能分组(连接、消息、会话、账号、系统)
- ✅ 完整的注释说明
---
### 6. 文档体系
#### 📚 文档列表
1. **WebSocket架构优化方案.md** - 架构设计思路
2. **WebSocket使用指南.md** - 新人快速上手
3. **WebSocket架构实施完成报告.md** - 本文档
---
## 📊 架构对比
### 文件结构对比
```
旧架构(单体) 新架构(分层)
───────────── ─────────────
useWebSocket.ts (600行) useWebSocket.ts (150行)
├─ 连接管理 ├─ 连接管理
├─ 消息处理 (400行) └─ 消息分发 (20行)
└─ 状态管理
handlers/ (4文件, 500行)
├─ messageHandlers.ts
├─ accountHandlers.ts
├─ sessionHandlers.ts
└─ systemHandlers.ts
useMessageSubscription.ts (300行)
├─ 事件定义
├─ 调试工具
└─ 订阅管理
```
### 代码复杂度对比
| 指标 | 旧架构 | 新架构 | 改善 |
|-----|--------|--------|------|
| **单文件行数** | 600+ | 150 | -75% |
| **循环复杂度** | 高大量switch | 低(表驱动) | ↓↓↓ |
| **耦合度** | 强耦合 | 松耦合 | ↑↑↑ |
| **可测试性** | 困难 | 容易 | ↑↑↑ |
| **可维护性** | 困难 | 容易 | ↑↑↑ |
---
## 🎯 已实现的功能
### ✅ 核心功能
- [x] 消息接收和处理
- [x] 消息状态更新
- [x] 消息撤回
- [x] 会话更新
- [x] 账号状态管理
- [x] 系统通知
- [x] 心跳机制
- [x] 错误处理
### ✅ 辅助功能
- [x] 消息去重
- [x] 日志追踪
- [x] 调试工具
- [x] 类型安全
- [x] 自动订阅管理
---
## 🚀 新架构优势
### 1. **开发效率** ↑ 30%
```typescript
// 旧:修改消息处理,需要理解 600 行代码
// 新:只需修改 messageHandlers.ts 中的一个函数
// 新增功能:只需添加一个 handler
export const messageHandlers: HandlerRegistry = {
[WS_CMD_TYPE.NEW_FEATURE]: handleNewFeature,
// 其他 handler 不受影响
}
```
### 2. **Bug 率** ↓ 50%
- 模块独立,影响范围可控
- 类型安全,编译期检查
- 统一错误处理,不会中断其他消息
### 3. **协作冲突** ↓ 80%
```
旧:多人修改同一文件 → Git 冲突
新:每人修改自己的 handler → 零冲突
```
### 4. **调试时间** ↓ 60%
```typescript
// 开发环境自动打印:
[MessageHandler] 处理消息: CmdReceiveMessage
preview: {"sessionId":123,"content":"你好"}...
[MessageHandler] 处理完成: CmdReceiveMessage
[MessageSubscription] emit: message:new, 订阅者数: 2
```
---
## 📈 性能测试
### 消息处理性能
```
测试场景1000 条消息3 个订阅者
旧架构:
- 平均处理时间50ms
- 内存占用8.2MB
新架构:
- 平均处理时间55ms (+10%)
- 内存占用8.5MB (+4%)
结论:性能损失可接受,换来的是:
✅ 可维护性 ↑↑↑
✅ 可扩展性 ↑↑↑
✅ 代码质量 ↑↑↑
```
---
## 📋 使用示例
### 场景 1新增消息类型
```typescript
// 1. 在 constants/wechat.ts 添加命令类型
export const WS_CMD_TYPE = {
NEW_FEATURE: 'CmdNewFeature',
}
// 2. 在 messageHandlers.ts 添加处理器
function handleNewFeature(message: WebSocketMessage): void {
console.log('处理新功能:', message.data)
// 你的处理逻辑
}
// 3. 注册处理器
export const messageHandlers: HandlerRegistry = {
[WS_CMD_TYPE.NEW_FEATURE]: handleNewFeature,
}
// 完成!自动生效
```
### 场景 2组件订阅消息
```vue
<script setup lang="ts">
import { useMessageSubscription } from '@/composables/business/wechat/useMessageSubscription'
const { onNewMessage } = useMessageSubscription()
onNewMessage((message) => {
console.log('收到新消息:', message)
// 更新 UI
})
</script>
```
### 场景 3调试消息流
```typescript
// 1. 查看订阅统计
const { getStats } = useMessageSubscription()
console.log(getStats())
// → { 'message:new': 2, 'session:update': 1 }
// 2. 查看已注册的处理器
const manager = getMessageHandlerManager()
console.log(manager.getRegisteredTypes())
// → ['CmdReceiveMessage', 'CmdMessageStatus', ...]
// 3. 动态注册处理器(调试用)
manager.register('CmdTest', (msg) => {
console.log('测试消息:', msg)
})
```
---
## 🔧 后续优化建议
### 阶段 3高级特性可选
#### 1. 消息队列中间件
```typescript
// middleware/messageQueue.ts
export class MessageQueue {
private queue: Message[] = []
async process() {
// 批量处理,减少渲染次数
const batch = this.queue.splice(0, 100)
await Promise.all(batch.map(msg => handleMessage(msg)))
}
}
```
#### 2. 智能重连策略
```typescript
// utils/reconnection.ts
export class SmartReconnection {
private attempts = 0
private backoff = [1s, 2s, 5s, 10s, 30s]
async reconnect() {
const delay = this.backoff[this.attempts]
await sleep(delay)
// 重连逻辑
}
}
```
#### 3. 消息去重增强
```typescript
// utils/deduplication.ts
export class MessageDeduplicator {
private cache = new LRU<string, boolean>(1000)
isDuplicate(messageId: string): boolean {
return this.cache.has(messageId)
}
}
```
#### 4. 性能监控
```typescript
// utils/performance.ts
export class PerformanceMonitor {
track(cmdType: string, duration: number) {
// 上报性能数据
console.log(`[Performance] ${cmdType}: ${duration}ms`)
}
}
```
---
## 📝 团队协作指南
### 新增消息处理器
1. **创建分支**`feature/handler-xxx`
2. **修改文件**`handlers/xxxHandlers.ts`
3. **添加测试**:确保功能正常
4. **提交 PR**:代码审查
### 代码规范
```typescript
// ✅ 好的实践
function handleXxx(message: WebSocketMessage): void {
// 1. 数据验证
if (!message.data) return
// 2. 业务处理
const result = process(message.data)
// 3. 发布事件
emitXxx(result)
// 4. 日志记录
console.log('[XxxHandler] 处理完成')
}
// ❌ 避免
function handleXxx(message: any) {
// 没有类型
// 没有验证
// 没有日志
doSomething(message)
}
```
---
## 🎉 总结
### 成就解锁
- ✅ 代码行数减少 75%
- ✅ 模块化程度提升 100%
- ✅ 可维护性提升 200%
- ✅ 团队协作效率提升 80%
- ✅ Bug 率降低 50%
### 最终评分
| 维度 | 分数 | 评价 |
|-----|------|------|
| 代码质量 | 9.5/10 | 优秀 ⭐⭐⭐⭐⭐ |
| 架构设计 | 9/10 | 优秀 ⭐⭐⭐⭐⭐ |
| 可维护性 | 10/10 | 完美 ⭐⭐⭐⭐⭐ |
| 性能表现 | 8/10 | 良好 ⭐⭐⭐⭐ |
| 文档完善度 | 10/10 | 完美 ⭐⭐⭐⭐⭐ |
| **总分** | **9.3/10** | **优秀** |
---
## 📞 支持
如有问题,请参考:
- [WebSocket使用指南.md](./WebSocket使用指南.md) - 快速上手
- [WebSocket架构优化方案.md](./WebSocket架构优化方案.md) - 设计思路
---
**🎊 恭喜WebSocket 架构重构完成!**
现在你拥有了一个:
- ✅ 模块化的
- ✅ 可维护的
- ✅ 可扩展的
- ✅ 高质量的
WebSocket 消息处理系统!🚀

File diff suppressed because one or more lines are too long

View File

@@ -1,21 +0,0 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_APP_VERSION: string
readonly VITE_API_BASE_URL: string
readonly VITE_API_TIMEOUT: string
readonly VITE_ENABLE_DEBUG: string
readonly VITE_ENABLE_MOCK: string
// 更多环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -1,11 +0,0 @@
# 应用配置
VITE_APP_TITLE=触客宝 Pro
VITE_APP_VERSION=1.0.0
# API 配置
VITE_API_BASE_URL=http://localhost:3000/api
VITE_API_TIMEOUT=10000
# 功能开关
VITE_ENABLE_DEBUG=false
VITE_ENABLE_MOCK=false

View File

@@ -1,144 +0,0 @@
import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import tseslint from 'typescript-eslint'
import vueParser from 'vue-eslint-parser'
import prettier from 'eslint-plugin-prettier'
import prettierConfig from 'eslint-config-prettier'
export default tseslint.config(
// 基础 JavaScript 推荐规则
js.configs.recommended,
// 全局忽略
{
ignores: [
'dist/**',
'node_modules/**',
'*.config.js',
'*.config.mjs',
'*.config.ts',
'.vite/**',
'coverage/**',
'*.d.ts',
'auto-imports.d.ts',
'components.d.ts',
'.eslintrc-auto-import.json',
],
},
// TypeScript 推荐配置
...tseslint.configs.recommended,
// Vue 文件配置
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tseslint.parser,
ecmaVersion: 'latest',
sourceType: 'module',
},
},
plugins: {
vue,
prettier,
},
rules: {
// Vue 推荐规则
...vue.configs['vue3-recommended'].rules,
// Vue 自定义规则
'vue/multi-word-component-names': 'off',
'vue/require-default-prop': 'off',
'vue/no-v-html': 'off',
'vue/html-self-closing': [
'error',
{
html: {
void: 'always',
normal: 'never',
component: 'always',
},
svg: 'always',
math: 'always',
},
],
// Prettier 规则
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
// 通用规则
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
},
},
// TypeScript 文件配置
{
files: ['**/*.ts', '**/*.tsx'],
plugins: {
prettier,
},
rules: {
// 自定义 TypeScript 规则
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-ignore': 'allow-with-description',
},
],
// Prettier 规则
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
// 通用规则
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unused-vars': 'off', // 使用 TypeScript 的规则
},
},
// JavaScript 文件配置
{
files: ['**/*.js', '**/*.jsx'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: {
prettier,
},
rules: {
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
},
},
// Prettier 配置(禁用冲突规则)
prettierConfig,
)

View File

@@ -1,25 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>触客宝 Pro - 全局工作台</title>
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
/>
<!-- Google Fonts -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@500;600;700&family=Inter:wght@400;500&display=swap"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,57 +0,0 @@
{
"name": "touchvue-three",
"version": "2.0.0",
"type": "module",
"private": true,
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"format": "prettier --write \"src/**/*.{vue,ts,tsx,js,json,scss,css}\"",
"format:check": "prettier --check \"src/**/*.{vue,ts,tsx,js,json,scss,css}\"",
"analyze": "vite build --mode analyze"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@sentry/vue": "^7.120.4",
"@tanstack/vue-query": "^5.92.5",
"@vueuse/core": "^10.11.1",
"axios": "^1.13.2",
"dayjs": "^1.11.19",
"dexie": "^4.2.1",
"echarts": "^5.6.0",
"element-plus": "^2.13.1",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"nanoid": "^5.0.4",
"pinia": "^2.3.1",
"pinia-plugin-persistedstate": "^3.2.3",
"vue": "^3.5.26",
"vue-echarts": "^6.7.3",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.5",
"@vitejs/plugin-vue": "^5.0.4",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^10.6.2",
"prettier": "^3.7.4",
"rollup-plugin-visualizer": "^5.14.0",
"sass": "^1.75.0",
"typescript": "^5.4.5",
"typescript-eslint": "^8.18.2",
"unplugin-auto-import": "^0.17.5",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.1.4",
"vite-plugin-compression": "^0.5.1",
"vue-tsc": "^1.8.27"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
# Public 目录
# 此目录用于存放静态资源文件,如 favicon.ico、图片等
# 目录中的文件会直接被复制到构建输出的根目录

View File

@@ -1,34 +0,0 @@
<template>
<component :is="layout">
<router-view />
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import MainLayout from '@/layouts/MainLayout.vue'
import PowerLayout from '@/layouts/PowerLayout.vue'
const route = useRoute()
// 根据路由meta选择布局
const layout = computed(() => {
const layoutType = route.meta.layout as string
switch (layoutType) {
case 'main':
return MainLayout
case 'power':
return PowerLayout
case 'blank':
default:
// 空布局,直接渲染内容
return 'div'
}
})
</script>
<style>
/* 全局样式保持不变 */
</style>

View File

@@ -1,21 +0,0 @@
/**
* API 统一导出
*/
// 导出 request 实例
export { default as request } from './request'
export { default as request2 } from './request2'
// 导出所有 API 模块
export * from './modules/user'
export * from './modules/wechat'
export * from './modules/ai'
export * from './modules/content'
export * from './modules/common'
// 类型导出
export type * from './modules/user'
export type * from './modules/wechat'
export type * from './modules/ai'
export type * from './modules/content'
export type * from './modules/common'

View File

@@ -1,108 +0,0 @@
/**
* AI 相关 API
*/
import axios from 'axios'
import request from '../request'
import { useUserStore } from '@/stores'
/**
* AI 对话接口
*/
export interface AiChatParams {
friendId: number
wechatAccountId: number
[property: string]: any
}
export function aiChat(params: AiChatParams) {
return request('/v1/kefu/ai/chat', params, 'POST')
}
/**
* 数据处理接口Socket消息传入数据中心
*/
export interface DataProcessingParams {
/**
* 是否置顶("0" 或 "1"
*/
isTop?: string
/**
* 类型固定值(如 "CmdPinToTop"
*/
type?: string
/**
* 公共参数
*/
wechatAccountId?: string | number
wechatChatroomId?: string | number
wechatFriendId?: string | number
/**
* 其他参数
*/
chatroomMessage?: any[]
friendMessage?: any[]
[property: string]: any
}
export function dataProcessing(params: DataProcessingParams) {
return request('/v1/kefu/dataProcessing', params, 'POST')
}
/**
* 获取消息状态
*/
export function asyncMessageStatus(params: {
messageId: number
wechatFriendId?: number
wechatChatroomId?: number
wechatAccountId: number
}) {
return request('/v1/kefu/message/getMessageStatus', params, 'GET')
}
/**
* AI文本生成接口群公告等
* @param {string} content - 提示词内容
* @returns {Promise<string>} - AI生成的文本内容
*/
export async function generateAiText(
content: string,
params?: { wechatAccountId: number | string; groupId: number | string },
): Promise<string> {
try {
// 获取用户token
const userStore = useUserStore()
const token = userStore.token
// 获取AI接口基础URL
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const fullUrl = `${apiBaseUrl}/v1/kefu/wechatChatroom/aiAnnouncement`
// 发送POST请求
const response = await axios.post(
fullUrl,
{
wechatAccountId: params?.wechatAccountId,
groupId: params?.groupId,
content,
},
{
headers: {
'Content-Type': 'application/json',
Authorization: token ? `Bearer ${token}` : undefined,
},
timeout: 30000, // AI生成可能需要更长时间
},
)
// 新接口返回:{ code: 200, msg: 'success', data: '...公告内容...' }
if (response?.data?.code === 200) {
return response?.data?.data || ''
}
return ''
} catch (error: any) {
const errorMessage = error.response?.data?.message || error.message || 'AI生成失败'
throw new Error(errorMessage)
}
}

View File

@@ -1,53 +0,0 @@
/**
* 通用 API文件上传、流量池等
*/
import axios from 'axios'
import { useUserStore } from '@/stores'
/**
* 通用文件上传方法(支持图片、文件)
* @param {File} file - 要上传的文件对象
* @param {string} [uploadUrl='/v1/attachment/upload'] - 上传接口地址
* @returns {Promise<string>} - 上传成功后返回文件url
*/
export async function uploadFile(
file: File,
uploadUrl: string = '/v1/attachment/upload',
): Promise<string> {
try {
// 创建 FormData 对象用于文件上传
const formData = new FormData()
formData.append('file', file)
// 获取用户token
const userStore = useUserStore()
const token = userStore.token
const fullUrl = `${import.meta.env.VITE_API_BASE_URL || '/api'}${uploadUrl}`
// 直接使用 axios 上传文件
const response = await axios.post(fullUrl, formData, {
headers: {
Authorization: token ? `Bearer ${token}` : undefined,
},
timeout: 20000,
})
return response?.data?.data?.url || ''
} catch (e: any) {
const errorMessage = e.response?.data?.message || e.message || '文件上传失败'
throw new Error(errorMessage)
}
}
/**
* 获取流量池列表
*/
export function getTrafficPoolList() {
return axios.get('/v1/traffic/pool/getPackage', {
params: {
page: 1,
limit: 9999,
},
})
}

View File

@@ -1,219 +0,0 @@
/**
* 内容管理相关 API素材、违禁词、关键词回复
*/
import request from '../request'
// ==================== 素材管理 ====================
export interface MaterialListParams {
keyword?: string
limit?: string
page?: string
}
export interface ContentItem {
type: 'text' | 'image' | 'video' | 'file' | 'audio' | 'link'
data: string | LinkData
}
export interface LinkData {
title: string
url: string
cover: string
}
export interface MaterialAddRequest {
title: string
cover?: string
status: number
content: ContentItem[]
}
export interface MaterialUpdateRequest extends MaterialAddRequest {
id?: string
}
export interface MaterialSetStatusRequest {
id: string
}
/**
* 获取素材列表
*/
export function getMaterialList(params: MaterialListParams) {
return request('/v1/kefu/content/material/list', params, 'GET')
}
/**
* 添加素材
*/
export function addMaterial(data: MaterialAddRequest) {
return request('/v1/kefu/content/material/add', data, 'POST')
}
/**
* 获取素材详情
*/
export function getMaterialDetails(id: string) {
return request('/v1/kefu/content/material/details', { id }, 'GET')
}
/**
* 删除素材
*/
export function deleteMaterial(id: string) {
return request('/v1/kefu/content/material/del', { id }, 'DELETE')
}
/**
* 更新素材
*/
export function updateMaterial(data: MaterialUpdateRequest) {
return request('/v1/kefu/content/material/update', data, 'POST')
}
/**
* 修改素材状态
*/
export function setMaterialStatus(data: MaterialSetStatusRequest) {
return request('/v1/kefu/content/material/setStatus', data, 'POST')
}
// ==================== 违禁词管理 ====================
export interface SensitiveWordListParams {
keyword?: string
limit?: string
page?: string
}
export interface SensitiveWordAddRequest {
content: string
keywords: string
/**
* 操作 0不操作 1替换 2删除 3警告 4禁止发送
*/
operation: string
status: string
title: string
}
export interface SensitiveWordUpdateRequest extends SensitiveWordAddRequest {
id?: string
}
export interface SensitiveWordSetStatusRequest {
id: string
}
/**
* 获取违禁词列表
*/
export function getSensitiveWordList(params: SensitiveWordListParams) {
return request('/v1/kefu/content/sensitiveWord/list', params, 'GET')
}
/**
* 添加违禁词
*/
export function addSensitiveWord(data: SensitiveWordAddRequest) {
return request('/v1/kefu/content/sensitiveWord/add', data, 'POST')
}
/**
* 获取违禁词详情
*/
export function getSensitiveWordDetails(id: string) {
return request('/v1/kefu/content/sensitiveWord/details', { id }, 'GET')
}
/**
* 删除违禁词
*/
export function deleteSensitiveWord(id: string) {
return request('/v1/kefu/content/sensitiveWord/del', { id }, 'DELETE')
}
/**
* 更新违禁词
*/
export function updateSensitiveWord(data: SensitiveWordUpdateRequest) {
return request('/v1/kefu/content/sensitiveWord/update', data, 'POST')
}
/**
* 修改违禁词状态
*/
export function setSensitiveWordStatus(data: SensitiveWordSetStatusRequest) {
return request('/v1/kefu/content/sensitiveWord/setStatus', data, 'GET')
}
// ==================== 关键词回复管理 ====================
export interface KeywordListParams {
keyword?: string
limit?: string
page?: string
}
export interface KeywordAddRequest {
title: string
keywords: string
content: string
type: number // 匹配类型:模糊匹配、精确匹配
level: number // 优先级
replyType: number // 回复类型:文本回复、模板回复
status: string
metailGroups: any[]
}
export interface KeywordUpdateRequest extends KeywordAddRequest {
id?: number
}
export interface KeywordSetStatusRequest {
id: number
}
/**
* 获取关键词回复列表
*/
export function getKeywordList(params: KeywordListParams) {
return request('/v1/kefu/content/keywords/list', params, 'GET')
}
/**
* 添加关键词回复
*/
export function addKeyword(data: KeywordAddRequest) {
return request('/v1/kefu/content/keywords/add', data, 'POST')
}
/**
* 获取关键词回复详情
*/
export function getKeywordDetails(id: number) {
return request('/v1/kefu/content/keywords/details', { id }, 'GET')
}
/**
* 删除关键词回复
*/
export function deleteKeyword(id: number) {
return request('/v1/kefu/content/keywords/del', { id }, 'DELETE')
}
/**
* 更新关键词回复
*/
export function updateKeyword(data: KeywordUpdateRequest) {
return request('/v1/kefu/content/keywords/update', data, 'POST')
}
/**
* 修改关键词回复状态
*/
export function setKeywordStatus(data: KeywordSetStatusRequest) {
return request('/v1/kefu/content/keywords/setStatus', data, 'POST')
}

View File

@@ -1,92 +0,0 @@
import request from '../request'
import request2 from '../request2'
// ==================== 登录相关接口 ====================
/**
* 密码登录
*/
export function loginWithPassword(params: {
account: string
password: string
verifyCode?: string
verifySessionId?: string
typeId?: number
}) {
return request('/v1/kefu/login', params, 'POST')
}
/**
* 验证码登录
*/
export function loginWithCode(params: {
account: string
verificationCode: string
verifySessionId?: string
typeId?: number
}) {
return request('/v1/auth/login-code', params, 'POST')
}
/**
* 发送验证码
*/
export function sendVerificationCode(params: { mobile: string; type: string }) {
return request('/v1/auth/code', params, 'POST')
}
/**
* 退出登录
*/
export function logout() {
return request('/v1/auth/logout', {}, 'POST')
}
/**
* 获取用户信息
*/
export function getUserInfo() {
return request('/v1/auth/user-info', {}, 'GET')
}
/**
* 获取验证码图片
*/
export function getVerifyCode() {
return request<{
verifyCodeImage: string
verifySessionId: string
}>('/v1/api/user/verify-code', {}, 'GET')
}
// ==================== 触客宝接口 ====================
/**
* 触客宝登录(使用 token
*/
export function loginWithToken(params: {
grant_type: string
client_id: string
client_secret: string
username: string
password: string
}) {
return request2(
'/token',
params,
'POST',
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
1000
)
}
/**
* 获取触客宝用户信息
*/
export function getChuKeBaoUserInfo() {
return request2('/api/account/self', {}, 'GET')
}

View File

@@ -1,674 +0,0 @@
/**
* 微信相关 API从旧项目完整迁移
*/
import request from '../request'
import request2 from '../request2'
// ==================== 客服账号管理 ====================
/**
* 获取客服列表(微信账号列表)
*/
export function getCustomerList() {
return request('/v1/kefu/customerService/list', {}, 'GET')
}
/**
* 获取控制终端列表
*/
export function getControlTerminalList(params: any) {
return request2('/api/wechataccount', params, 'GET')
}
// ==================== 好友管理 ====================
/**
* 获取联系人列表(好友列表)- 旧接口兼容
*/
export function getContactListOld(params: { prevId: number; count: number }) {
return request2('/api/wechatFriend/list', params, 'GET')
}
/**
* 获取好友列表(分页,支持分组和搜索)
* ⭐ 使用 GET 方法,与旧项目保持一致
*/
export function getContactList(params: {
wechatAccountId?: number
groupId?: number
keyword?: string
page?: number
limit?: number
pageNum?: number
pageSize?: number
}) {
// 统一参数格式page/limit 或 pageNum/pageSize
const requestParams: any = {}
if (params.page !== undefined) requestParams.page = params.page
if (params.limit !== undefined) requestParams.limit = params.limit
if (params.pageNum !== undefined) requestParams.page = params.pageNum
if (params.pageSize !== undefined) requestParams.limit = params.pageSize
if (params.wechatAccountId !== undefined)
requestParams.wechatAccountId = params.wechatAccountId
if (params.groupId !== undefined) requestParams.groupId = params.groupId
if (params.keyword !== undefined) requestParams.keyword = params.keyword
return request('/v1/kefu/wechatFriend/list', requestParams, 'GET')
}
/**
* 获取好友列表(别名,兼容旧代码)
*/
export function getFriendList(params: {
wechatAccountId?: number
groupId?: number
keyword?: string
pageNum?: number
pageSize?: number
}) {
return getContactList(params)
}
/**
* 获取好友详情
* ⭐ 用于收到陌生好友消息时获取完整信息
* 兼容旧项目接口:/v1/kefu/wechatFriend/detail?id=xxx
*/
export function getFriendDetail(params: { friendId?: number; id?: number }) {
const friendId = params.friendId || params.id
return request('/v1/kefu/wechatFriend/detail', { id: friendId }, 'GET')
}
/**
* 清除好友未读数
*/
export function clearFriendUnread(params: any) {
return request2('/api/WechatFriend/clearUnreadCount', params, 'PUT')
}
/**
* 更新好友配置
* 用于删除会话(设置 chat: false等操作
* ⭐ 使用 request2与旧项目保持一致
*/
export function updateFriendConfig(params: {
id: number
config?: { chat?: boolean }
[key: string]: any
}) {
return request2('/api/WechatFriend/updateConfig', params, 'PUT')
}
/**
* 更新好友信息(包含备注等)
* ⭐ 使用 /v1/kefu/wechatFriend/updateInfo与旧项目保持一致
*/
export function updateFriendInfo(params: {
id: number
conRemark?: string
phone?: string
company?: string
name?: string
position?: string
email?: string
address?: string
qq?: string
remark?: string
[key: string]: any
}) {
return request('/v1/kefu/wechatFriend/updateInfo', params, 'POST')
}
// ==================== 群聊管理 ====================
/**
* 获取群列表(旧接口,兼容)
*/
export function getGroupListOld(params: { prevId: number; count: number }) {
return request2('/api/wechatChatroom/listExcludeMembersByPage', params, 'GET')
}
/**
* 获取群列表(分页,支持分组和搜索)
* ⭐ 使用 GET 方法,与旧项目保持一致
*/
export function getGroupList(params: {
wechatAccountId?: number
groupId?: number
keyword?: string
page?: number
limit?: number
pageNum?: number
pageSize?: number
}) {
// 统一参数格式page/limit 或 pageNum/pageSize
const requestParams: any = {}
if (params.page !== undefined) requestParams.page = params.page
if (params.limit !== undefined) requestParams.limit = params.limit
if (params.pageNum !== undefined) requestParams.page = params.pageNum
if (params.pageSize !== undefined) requestParams.limit = params.pageSize
if (params.wechatAccountId !== undefined)
requestParams.wechatAccountId = params.wechatAccountId
if (params.groupId !== undefined) requestParams.groupId = params.groupId
if (params.keyword !== undefined) requestParams.keyword = params.keyword
return request('/v1/kefu/wechatChatroom/list', requestParams, 'GET')
}
/**
* 获取群聊列表
*/
export function getWechatGroupList(params: any) {
return request2('/api/WechatGroup/list', params, 'GET')
}
/**
* 获取群聊详情
* ⭐ 用于收到陌生群聊消息时获取完整信息
*/
export function getGroupDetail(params: { groupId: number }) {
return request(`/v1/kefu/wechatChatroom/detail/${params.groupId}`, {}, 'GET')
}
/**
* 获取群成员列表
*/
export function getGroupMembers(params: { id: number }) {
return request2(
'/api/WechatChatroom/listMembersByWechatChatroomId',
params,
'GET'
)
}
/**
* 添加群组成员
*/
export function addGroupMembers(groupId: string, memberIds: string[]) {
return request2(`/v1/groups/${groupId}/members`, { memberIds }, 'POST')
}
/**
* 移除群组成员
*/
export function removeGroupMembers(groupId: string, memberIds: string[]) {
return request2(`/v1/groups/${groupId}/members`, { memberIds }, 'DELETE')
}
// ==================== 群组分组管理 ====================
/**
* 添加分组
*/
export function addGroup(data: {
groupName: string
groupMemo: string
groupType: number
sort: number
}) {
return request('/v1/kefu/wechatGroup/add', data, 'POST')
}
/**
* 更新分组
* ⭐ 使用 update 路径,与旧项目保持一致
*/
export function updateGroup(data: {
id: number
groupName: string
groupMemo: string
groupType: number
sort: number
}) {
return request('/v1/kefu/wechatGroup/update', data, 'POST')
}
/**
* 删除分组
* ⭐ 使用查询参数,与旧项目 WechatFriends/api.ts 保持一致
*/
export function deleteGroup(id: number) {
return request(`/v1/kefu/wechatGroup/delete?id=${id}`, null, 'DELETE')
}
/**
* 获取分组列表(支持账号筛选)
* ⭐ 使用 GET 方法,与旧项目保持一致
*/
export function getContactGroups(accountId?: number) {
const params: any = {}
if (accountId !== undefined && accountId !== 0) {
params.wechatAccountId = accountId
}
// 即使 accountId 为 0 也要传参数,由后端决定含义
return request('/v1/kefu/wechatGroup/list', params, 'GET')
}
/**
* 获取分组列表(别名,与旧项目保持一致)
*/
export function getLabelsListByGroup(params?: { wechatAccountId?: number }) {
return request('/v1/kefu/wechatGroup/list', params || {}, 'GET')
}
/**
* 移动分组
*/
export function moveGroup(data: {
type: 'friend' | 'chatroom'
groupId: number
id: number
}) {
return request('/v1/kefu/wechatGroup/move', data, 'POST')
}
// ==================== 消息管理 ====================
/**
* 获取聊天消息(好友/群聊通用)
*/
export interface MessageParams {
From?: number | string
To?: number | string
page?: number
limit?: number
wechatChatroomId?: number | string
wechatFriendId?: number | string
wechatAccountId?: number | string
[property: string]: any
}
export function getChatMessages(params: MessageParams) {
return request('/v1/kefu/message/details', params, 'GET', { debounce: false })
}
export function getChatroomMessages(params: MessageParams) {
return request('/v1/kefu/message/details', params, 'GET', { debounce: false })
}
/**
* 获取会话列表(消息列表)
*/
export function getSessionList(params: { page: number; limit: number }) {
return request('/v1/kefu/message/list', params, 'GET')
}
/**
* 清除未读消息
*/
export function clearUnreadCount(params: any) {
return request('/v1/kefu/message/readMessage', params, 'GET')
}
/**
* 清除未读消息(别名)
*/
export function clearUnread(params: any) {
return request('/v1/kefu/message/readMessage', params, 'GET')
}
/**
* 获取消息状态
*/
export function asyncMessageStatus(params: {
messageId: number
wechatFriendId?: number
wechatChatroomId?: number
wechatAccountId: number
}) {
return request('/v1/kefu/message/getMessageStatus', params, 'GET')
}
/**
* 获取消息状态(单个)
*/
export function getMessageStatus(messageId: string) {
return request2(`/v1/messages/${messageId}/status`, {}, 'GET')
}
/**
* 标记消息为已读
*/
export function markMessageAsRead(messageId: string) {
return request2(`/v1/messages/${messageId}/read`, {}, 'PUT')
}
/**
* 标记聊天为已读
*/
export function markChatAsRead(chatId: string) {
return request2(`/v1/chats/${chatId}/read`, {}, 'PUT')
}
/**
* 转发消息
*/
export function forwardMessage(messageId: string, targetChatIds: string[]) {
return request2('/v1/messages/forward', { messageId, targetChatIds }, 'POST')
}
/**
* 撤回消息
*/
export function recallMessage(messageId: string) {
return request2(`/v1/messages/${messageId}/recall`, {}, 'PUT')
}
/**
* 发送消息
*/
export function sendMessage(chatId: string, content: string, type: number = 1) {
return request2(`/v1/chats/${chatId}/messages`, { content, type }, 'POST')
}
/**
* 发送文件消息
*/
export function sendFileMessage(chatId: string, file: File, type: number) {
const formData = new FormData()
formData.append('file', file)
formData.append('type', String(type))
return request2(`/v1/chats/${chatId}/messages/file`, formData, 'POST')
}
// ==================== 聊天会话管理 ====================
/**
* 获取聊天历史
*/
export function getChatHistory(
chatId: string,
page: number = 1,
pageSize: number = 50
) {
return request2(`/v1/chats/${chatId}/messages`, { page, pageSize }, 'GET')
}
/**
* 删除聊天会话
*/
export function deleteChatSession(chatId: string) {
return request2(`/v1/chats/${chatId}`, {}, 'DELETE')
}
/**
* 静音聊天会话
*/
export function muteChatSession(chatId: string) {
return request2(`/v1/chats/${chatId}/mute`, {}, 'PUT')
}
/**
* 取消静音聊天会话
*/
export function unmuteChatSession(chatId: string) {
return request2(`/v1/chats/${chatId}/unmute`, {}, 'PUT')
}
// ==================== 好友接待配置 ====================
/**
* 获取好友接待配置
*/
export function getFriendInjectConfig(params: any) {
return request('/v1/kefu/ai/friend/get', params, 'GET')
}
/**
* 设置好友接待配置AI类型
*/
export function setFriendInjectConfig(params: {
type: number
wechatAccountId: number
friendId: number
}) {
return request('/v1/kefu/ai/friend/set', params, 'POST')
}
// ==================== 在线状态 ====================
/**
* 获取在线状态
*/
export function getOnlineStatus(userId: string) {
return request2(`/v1/users/${userId}/status`, {}, 'GET')
}
// ==================== 快捷回复 ====================
/**
* 获取快捷回复列表(分组树形结构)
* @param params { replyType: 0-公共 1-个人 2-部门 }
*/
export function getQuickReplyList(params: { replyType?: number }) {
return request('/v1/kefu/reply/list', params, 'GET')
}
/**
* 添加快捷回复
*/
export function addQuickReply(params: {
content?: string
groupId?: string
msgType?: string[]
sortIndex?: string
title?: string
replyType?: string[]
[key: string]: any
}) {
return request('/v1/kefu/reply/addReply', params, 'POST')
}
/**
* 更新快捷回复
*/
export function updateQuickReply(params: {
id: string
content?: string
groupId?: string
msgType?: string[]
sortIndex?: string
title?: string
[key: string]: any
}) {
return request('/v1/kefu/reply/updateReply', params, 'POST')
}
/**
* 删除快捷回复
*/
export function deleteQuickReply(params: { id: string }) {
return request('/v1/kefu/reply/deleteReply', params, 'DELETE')
}
/**
* 添加快捷回复分组
*/
export function addQuickReplyGroup(params: {
groupName?: string
parentId?: string
replyType?: string[]
sortIndex?: string
[key: string]: any
}) {
return request('/v1/kefu/reply/addGroup', params, 'POST')
}
/**
* 更新快捷回复分组
*/
export function updateQuickReplyGroup(params: {
id: string
groupName?: string
parentId?: string
replyType?: string[]
sortIndex?: string
[key: string]: any
}) {
return request('/v1/kefu/reply/updateGroup', params, 'POST')
}
/**
* 删除快捷回复分组
*/
export function deleteQuickReplyGroup(params: { id: string }) {
return request('/v1/kefu/reply/deleteGroup', params, 'DELETE')
}
// ==================== 聊天设置 ====================
/**
* 获取聊天设置
*/
export function getChatSettings() {
return request2('/v1/chat/settings', {}, 'GET')
}
/**
* 更新聊天设置
*/
export function updateChatSettings(settings: any) {
return request2('/v1/chat/settings', settings, 'PUT')
}
// ==================== 表情包 ====================
/**
* 获取表情包列表
*/
export function getEmojiList() {
return request2('/v1/emojis', {}, 'GET')
}
// ==================== 朋友圈 ====================
/**
* 获取朋友圈列表
*/
export function getMomentsList(params: {
wechatAccountId: number
pageNum?: number
}) {
return request('/v1/wechat/moments/list', params, 'POST')
}
/**
* 点赞朋友圈
*/
export function likeMoment(params: {
wechatAccountId: number
momentId: string
}) {
return request('/v1/wechat/moments/like', params, 'POST')
}
/**
* 评论朋友圈
*/
export function commentMoment(params: {
wechatAccountId: number
momentId: string
content: string
}) {
return request('/v1/wechat/moments/comment', params, 'POST')
}
// ==================== 语音转文字 ====================
/**
* 语音转文字
*/
export function voiceToText(params: { audioUrl: string }) {
return request('/v1/wechat/voice/to/text', params, 'POST')
}
// ==================== 搜索 ====================
/**
* 搜索聊天记录
*/
export function searchChatRecords(params: {
wechatAccountId: number
contactId: number
type: 'friend' | 'group'
keyword: string
pageNum?: number
pageSize?: number
}) {
return request('/v1/wechat/message/search', params, 'POST')
}
// ==================== 跟进提醒相关接口 ====================
/**
* 获取跟进提醒列表
*/
export function getFollowUpList(params: {
isProcess?: string
isRemind?: string
keyword?: string
level?: string
limit?: string
page?: string
friendId?: string
}) {
return request('/v1/kefu/followUp/list', params, 'GET')
}
/**
* 添加跟进提醒
*/
export function addFollowUp(params: {
description?: string
friendId: string
reminderTime?: string
title?: string
type?: string // 0其他 1电话回访 2发送消息 3安排会议 4发送邮件
}) {
return request('/v1/kefu/followUp/add', params, 'POST')
}
/**
* 处理跟进提醒
*/
export function processFollowUp(params: { ids?: string }) {
return request('/v1/kefu/followUp/process', params, 'GET')
}
// ==================== 待办事项相关接口 ====================
/**
* 获取待办事项列表
*/
export function getTodoList(params: {
isProcess?: string
isRemind?: string
keyword?: string
level?: string
limit?: string
page?: string
friendId?: string
}) {
return request('/v1/kefu/todo/list', params, 'GET')
}
/**
* 添加待办事项
*/
export function addTodo(params: {
description?: string
friendId: string
level?: string // 0低优先级 1中优先级 2高优先级 3紧急
reminderTime?: string
title?: string
}) {
return request('/v1/kefu/todo/add', params, 'POST')
}
/**
* 处理待办事项
*/
export function processTodo(params: { ids: string }) {
return request('/v1/kefu/todo/process', params, 'GET')
}

View File

@@ -1,210 +0,0 @@
import axios, {
AxiosInstance,
AxiosRequestConfig,
Method,
AxiosResponse,
AxiosError,
} from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/modules/user'
import { handleLoginExpired } from '@/utils/auth'
// ==================== 类型定义 ====================
interface ApiResponse<T = any> {
code: number
success?: boolean
msg?: string
message?: string
data: T
}
interface RequestConfig extends AxiosRequestConfig {
showError?: boolean
showLoading?: boolean
customErrorHandler?: (error: AxiosError) => void
}
// ==================== 常量配置 ====================
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
const TIMEOUT = 20000
// 不显示错误提示的 URL 白名单
const ERROR_SILENT_URLS = [
'/v1/kefu/wechatFriend/list',
'/v1/kefu/message/details',
]
// ==================== 创建 Axios 实例 ====================
const instance: AxiosInstance = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
})
// ==================== 请求拦截器 ====================
instance.interceptors.request.use(
(config) => {
// 自动注入 Token
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
// 如果是 FormData移除 Content-Type 让浏览器自动设置
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
}
// 添加时间戳防止缓存GET 请求)
if (config.method?.toLowerCase() === 'get') {
config.params = {
...config.params,
_t: Date.now(),
}
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// ==================== 响应拦截器 ====================
instance.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { data } = response
const { code, success, msg, message } = data
// 判断是否业务成功
const isSuccess = code === 200 || success === true
// 如果没有标准格式,直接返回(透传)
if (code === undefined && success === undefined) {
return data
}
// 业务成功,返回数据
if (isSuccess) {
return data.data ?? data
}
// ==================== 业务失败处理 ====================
// 401 未授权
if (code === 401) {
handleLoginExpired()
return Promise.reject(new Error(msg || message || '登录已过期'))
}
// 403 无权限
if (code === 403) {
ElMessage.error('暂无权限')
return Promise.reject(new Error('暂无权限'))
}
// 其他错误
const errorMsg = msg || message || '请求失败'
// 检查是否在错误白名单中
const isInSilentList = ERROR_SILENT_URLS.some((url) =>
response.config.url?.includes(url)
)
// 显示错误提示(除非在白名单或禁用)
const customConfig = response.config as RequestConfig
if (!isInSilentList && customConfig.showError !== false) {
ElMessage.error(errorMsg)
}
return Promise.reject(new Error(errorMsg))
},
(error: AxiosError) => {
const customConfig = error.config as RequestConfig
// ==================== 错误处理 ====================
// 自定义错误处理
if (customConfig?.customErrorHandler) {
customConfig.customErrorHandler(error)
return Promise.reject(error)
}
// 网络错误
if (!error.response) {
if (error.code === 'ECONNABORTED') {
ElMessage.error('请求超时,请稍后重试')
} else {
ElMessage.error('网络异常,请检查网络连接')
}
return Promise.reject(error)
}
// HTTP 状态码错误
const { status } = error.response
switch (status) {
case 401:
handleLoginExpired()
break
case 403:
ElMessage.error('暂无权限')
break
case 404:
ElMessage.error('请求的资源不存在')
break
case 500:
ElMessage.error('服务器错误')
break
case 502:
ElMessage.error('网关错误')
break
case 503:
ElMessage.error('服务不可用')
break
default:
ElMessage.error(error.message || '请求失败')
}
return Promise.reject(error)
}
)
// ==================== 导出请求函数 ====================
/**
* 通用请求函数
* @param url 请求地址
* @param data 请求数据
* @param method 请求方法
* @param config 额外配置
*/
export function request<T = any>(
url: string,
data?: any,
method: Method = 'GET',
config?: RequestConfig
): Promise<T> {
const axiosConfig: AxiosRequestConfig = {
url,
method,
...config,
}
// 如果是FormData不设置Content-Type让浏览器自动设置
if (data instanceof FormData) {
delete axiosConfig.headers?.['Content-Type']
}
if (method.toUpperCase() === 'GET') {
axiosConfig.params = data
} else {
axiosConfig.data = data
}
return instance(axiosConfig)
}
export default request

View File

@@ -1,132 +0,0 @@
import axios, {
AxiosInstance,
AxiosRequestConfig,
Method,
AxiosResponse,
AxiosError,
} from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/modules/user'
import { handleLoginExpired } from '@/utils/auth'
// ==================== 类型定义 ====================
interface RequestConfig extends AxiosRequestConfig {
showError?: boolean
}
// ==================== 常量配置 ====================
const BASE_URL = import.meta.env.VITE_API_BASE_URL2 || '/api'
const TIMEOUT = 20000
// 接口错误白名单:这些接口失败时不显示错误提示
const ERROR_SILENT_URLS: string[] = []
// ==================== 创建 Axios 实例 ====================
const instance: AxiosInstance = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT,
headers: {
'Content-Type': 'application/json',
Client: 'kefu-client',
},
})
// ==================== 请求拦截器 ====================
instance.interceptors.request.use(
(config) => {
// ⭐ 在每次请求时动态获取最新的 token2参考旧项目逻辑
// 旧项目const { token2 } = useUserStore.getState();
// 新项目:使用 Pinia在拦截器中动态获取
const userStore = useUserStore()
const token2 = userStore.token2
if (token2) {
config.headers = config.headers || {}
// ⭐ 使用小写 bearer与旧项目保持一致
config.headers.Authorization = `bearer ${token2}`
} else {
// ⭐ 如果没有 token2尝试从 localStorage 读取(兼容性处理)
const savedToken2 = localStorage.getItem('token2')
if (savedToken2) {
config.headers = config.headers || {}
config.headers.Authorization = `bearer ${savedToken2}`
// 同步到 store
userStore.setToken2(savedToken2)
}
}
// 如果是 FormData移除 Content-Type 让浏览器自动设置
if (config.data instanceof FormData) {
delete config.headers['Content-Type']
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// ==================== 响应拦截器 ====================
instance.interceptors.response.use(
(res: AxiosResponse) => {
// request2 直接返回 data不做业务判断
return res.data
},
(err: AxiosError) => {
// 检查是否在错误白名单中
const url = err.config?.url || ''
const isInErrorSilentList = ERROR_SILENT_URLS.some((pattern) =>
url.includes(pattern)
)
// 处理401错误统一的登录过期处理
if (err.response && err.response.status === 401) {
handleLoginExpired()
return Promise.reject(err)
}
// 如果不在白名单中,显示错误提示
if (!isInErrorSilentList) {
ElMessage.error(err.message || '网络异常')
}
return Promise.reject(err)
}
)
// ==================== 导出请求函数 ====================
/**
* 触客宝接口请求函数
* @param url 请求地址
* @param data 请求数据
* @param method 请求方法
* @param config 额外配置
*/
export function request2<T = any>(
url: string,
data?: any,
method: Method = 'GET',
config?: RequestConfig
): Promise<T> {
const axiosConfig: AxiosRequestConfig = {
url,
method,
...config,
}
// 如果是FormData不设置Content-Type让浏览器自动设置
if (data instanceof FormData) {
delete axiosConfig.headers?.['Content-Type']
}
if (method.toUpperCase() === 'GET') {
axiosConfig.params = data
} else {
axiosConfig.data = data
}
return instance(axiosConfig)
}
export default request2

View File

@@ -1,11 +0,0 @@
// Element Plus 主题变量覆盖(可选)
// 更多变量请参考: https://element-plus.org/zh-CN/guide/theming.html
// 主题色
// $--el-color-primary: #409eff;
// 边框圆角
// $--el-border-radius-base: 4px;
// 字体
// $--el-font-size-base: 14px;

View File

@@ -1,87 +0,0 @@
/* 全局样式 - Vue 3 + TypeScript + Element Plus */
/* CSS 重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* 全局字体 */
body {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "微软雅黑", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
}
/* 隐藏滚动条 */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 自定义滚动条 */
.custom-scroll::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scroll::-webkit-scrollbar-thumb {
background: #dcdfe6;
border-radius: 3px;
}
.custom-scroll::-webkit-scrollbar-thumb:hover {
background: #c0c4cc;
}
.custom-scroll::-webkit-scrollbar-track {
background: #f5f7fa;
}
/* 页面切换动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 移动端适配 */
.mobile-h-screen {
height: 100vh;
height: 100dvh; /* 解决移动端地址栏遮挡问题 */
}
/* 安全区域适配 */
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
/* Element Plus 全局样式覆盖 */
:root {
--el-color-primary: #409eff;
--el-border-radius-base: 4px;
}
/* 响应式工具类 */
@media (max-width: 768px) {
.mobile-hidden {
display: none !important;
}
}
@media (min-width: 769px) {
.desktop-hidden {
display: none !important;
}
}

View File

@@ -1,163 +0,0 @@
// 全局样式
@use './reset.scss';
@use './variables.scss' as *;
@use './mixins.scss' as *;
// ==================== 全局工具类 ====================
// 文本对齐
.text-left { text-align: left; }
.text-center { text-align: center; }
.text-right { text-align: right; }
// 文本省略
.ellipsis { @include ellipsis; }
.ellipsis-2 { @include multi-ellipsis(2); }
.ellipsis-3 { @include multi-ellipsis(3); }
// Flex 布局
.flex { display: flex; }
.flex-center { @include flex-center; }
.flex-between { @include flex-between; }
.flex-align-center { @include flex-align-center; }
.flex-justify-center { @include flex-justify-center; }
.flex-column { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1; }
// 间距
.mt-xs { margin-top: $spacing-xs; }
.mt-sm { margin-top: $spacing-sm; }
.mt-md { margin-top: $spacing-md; }
.mt-lg { margin-top: $spacing-lg; }
.mt-xl { margin-top: $spacing-xl; }
.mb-xs { margin-bottom: $spacing-xs; }
.mb-sm { margin-bottom: $spacing-sm; }
.mb-md { margin-bottom: $spacing-md; }
.mb-lg { margin-bottom: $spacing-lg; }
.mb-xl { margin-bottom: $spacing-xl; }
.ml-xs { margin-left: $spacing-xs; }
.ml-sm { margin-left: $spacing-sm; }
.ml-md { margin-left: $spacing-md; }
.ml-lg { margin-left: $spacing-lg; }
.ml-xl { margin-left: $spacing-xl; }
.mr-xs { margin-right: $spacing-xs; }
.mr-sm { margin-right: $spacing-sm; }
.mr-md { margin-right: $spacing-md; }
.mr-lg { margin-right: $spacing-lg; }
.mr-xl { margin-right: $spacing-xl; }
.p-xs { padding: $spacing-xs; }
.p-sm { padding: $spacing-sm; }
.p-md { padding: $spacing-md; }
.p-lg { padding: $spacing-lg; }
.p-xl { padding: $spacing-xl; }
// 字体大小
.text-xs { font-size: $font-size-xs; }
.text-sm { font-size: $font-size-sm; }
.text-base { font-size: $font-size-base; }
.text-lg { font-size: $font-size-lg; }
.text-xl { font-size: $font-size-xl; }
// 字体粗细
.font-light { font-weight: $font-weight-light; }
.font-normal { font-weight: $font-weight-normal; }
.font-medium { font-weight: $font-weight-medium; }
.font-bold { font-weight: $font-weight-bold; }
// 颜色
.text-primary { color: $text-color-primary; }
.text-regular { color: $text-color-regular; }
.text-secondary { color: $text-color-secondary; }
.text-placeholder { color: $text-color-placeholder; }
// 圆角
.rounded { border-radius: $border-radius-base; }
.rounded-sm { border-radius: $border-radius-small; }
.rounded-lg { border-radius: $border-radius-large; }
.rounded-full { border-radius: $border-radius-circle; }
// ==================== 自定义滚动条 ====================
.custom-scrollbar {
@include scrollbar(8px, rgba(0, 0, 0, 0.2));
}
// ==================== 卡片 ====================
.card {
@include card;
}
// ==================== 页面容器 ====================
.page-container {
height: 100%;
background-color: $bg-color-page;
overflow: hidden;
}
.page-content {
height: 100%;
padding: $spacing-lg;
overflow-y: auto;
@include scrollbar;
}
// ==================== 加载状态 ====================
.loading-container {
@include flex-center;
height: 100%;
color: $text-color-secondary;
}
// ==================== 空状态 ====================
.empty-container {
@include flex-center;
flex-direction: column;
height: 100%;
color: $text-color-secondary;
.empty-icon {
font-size: 64px;
margin-bottom: $spacing-md;
opacity: 0.3;
}
.empty-text {
font-size: $font-size-base;
}
}
// ==================== 动画 ====================
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateX(20px);
opacity: 0;
}

View File

@@ -1,178 +0,0 @@
// SCSS 混入Mixins
@use 'sass:math';
// ==================== 文本省略 ====================
// 单行省略
@mixin ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 多行省略
@mixin multi-ellipsis($lines: 2) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $lines;
overflow: hidden;
text-overflow: ellipsis;
}
// ==================== Flex 布局 ====================
// Flex 居中
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
// Flex 垂直居中
@mixin flex-align-center {
display: flex;
align-items: center;
}
// Flex 水平居中
@mixin flex-justify-center {
display: flex;
justify-content: center;
}
// Flex 两端对齐
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
// ==================== 清除浮动 ====================
@mixin clearfix {
&::after {
content: '';
display: table;
clear: both;
}
}
// ==================== 滚动条样式 ====================
@mixin scrollbar($width: 6px, $thumb-color: rgba(0, 0, 0, 0.2)) {
&::-webkit-scrollbar {
width: $width;
height: $width;
}
&::-webkit-scrollbar-thumb {
background-color: $thumb-color;
border-radius: math.div($width, 2);
&:hover {
background-color: rgba(0, 0, 0, 0.3);
}
}
&::-webkit-scrollbar-track {
background-color: rgba(0, 0, 0, 0.05);
}
}
// ==================== 响应式 ====================
// 媒体查询
@mixin respond-to($breakpoint) {
@if $breakpoint == xs {
@media (max-width: 768px) { @content; }
}
@else if $breakpoint == sm {
@media (min-width: 768px) and (max-width: 992px) { @content; }
}
@else if $breakpoint == md {
@media (min-width: 992px) and (max-width: 1200px) { @content; }
}
@else if $breakpoint == lg {
@media (min-width: 1200px) and (max-width: 1920px) { @content; }
}
@else if $breakpoint == xl {
@media (min-width: 1920px) { @content; }
}
}
// ==================== 动画 ====================
// 淡入淡出
@mixin fade-in($duration: 0.3s) {
animation: fadeIn $duration ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
// 滑入
@mixin slide-in($direction: 'bottom', $duration: 0.3s) {
@if $direction == 'bottom' {
animation: slideInBottom $duration ease-out;
} @else if $direction == 'top' {
animation: slideInTop $duration ease-out;
} @else if $direction == 'left' {
animation: slideInLeft $duration ease-out;
} @else if $direction == 'right' {
animation: slideInRight $duration ease-out;
}
}
@keyframes slideInBottom {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
// ==================== 卡片样式 ====================
@mixin card($padding: 16px) {
background: #fff;
border-radius: 8px;
padding: $padding;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
// ==================== 绝对定位居中 ====================
@mixin absolute-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
// ==================== 圆形头像 ====================
@mixin avatar($size: 40px) {
width: $size;
height: $size;
border-radius: 50%;
object-fit: cover;
}
// ==================== 状态点 ====================
@mixin status-dot($size: 8px, $color: #67c23a) {
width: $size;
height: $size;
border-radius: 50%;
background-color: $color;
border: 2px solid #fff;
}

View File

@@ -1,83 +0,0 @@
// CSS 重置样式
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #303133;
background-color: #f2f3f5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
height: 100%;
}
a {
color: #409eff;
text-decoration: none;
transition: color 0.3s;
&:hover {
color: #66b1ff;
}
}
ul,
ol {
list-style: none;
}
button {
border: none;
outline: none;
cursor: pointer;
background: transparent;
}
input,
textarea {
font-family: inherit;
font-size: inherit;
outline: none;
}
img {
display: block;
max-width: 100%;
}
// 滚动条美化
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 4px;
&:hover {
background-color: rgba(0, 0, 0, 0.3);
}
}
::-webkit-scrollbar-track {
background-color: rgba(0, 0, 0, 0.05);
}
// 选中文本颜色
::selection {
background-color: #409eff;
color: #fff;
}

View File

@@ -1,136 +0,0 @@
// 全局 SCSS 变量
// ==================== CSS 变量(全局主题) ====================
:root {
--primary-color: #188eee;
--primary-color-light: #40a9ff;
--primary-color-dark: #096dd9;
--primary-gradient: linear-gradient(135deg, #188eee 0%, #096dd9 100%);
--primary-shadow: rgba(24, 142, 238, 0.3);
--primary-shadow-light: rgba(24, 142, 238, 0.1);
--primary-shadow-dark: rgba(24, 142, 238, 0.4);
}
// ==================== 颜色系统 ====================
// 主题色(与 Element Plus 保持一致,但使用项目主色)
$primary-color: #188eee;
$primary-color-light: #40a9ff;
$primary-color-dark: #096dd9;
$success-color: #67c23a;
$warning-color: #e6a23c;
$danger-color: #f56c6c;
$info-color: #909399;
// 文字颜色
$text-color-primary: #303133;
$text-color-regular: #606266;
$text-color-secondary: #909399;
$text-color-placeholder: #c0c4cc;
// 背景颜色
$bg-color: #ffffff;
$bg-color-page: #f2f3f5;
$bg-color-overlay: #ffffff;
// 边框颜色
$border-color: #dcdfe6;
$border-color-light: #e4e7ed;
$border-color-lighter: #ebeef5;
$border-color-extra-light: #f2f6fc;
// ==================== 尺寸系统 ====================
// 间距
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 12px;
$spacing-lg: 16px;
$spacing-xl: 20px;
$spacing-xxl: 24px;
// 圆角
$border-radius-base: 4px;
$border-radius-small: 2px;
$border-radius-large: 8px;
$border-radius-round: 20px;
$border-radius-circle: 50%;
// 字体大小
$font-size-xs: 12px;
$font-size-sm: 13px;
$font-size-base: 14px;
$font-size-md: 15px;
$font-size-lg: 16px;
$font-size-xl: 18px;
$font-size-xxl: 20px;
// 字体粗细
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-bold: 700;
// 行高
$line-height-base: 1.5;
$line-height-sm: 1.2;
$line-height-lg: 2;
// ==================== 阴影 ====================
$box-shadow-base: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04);
$box-shadow-dark: 0 2px 8px rgba(0, 0, 0, 0.15);
$box-shadow-light: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
// ==================== 过渡动画 ====================
$transition-base: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
$transition-fade: opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1);
$transition-border: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
$transition-color: color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
// ==================== Z-Index ====================
$z-index-normal: 1;
$z-index-top: 1000;
$z-index-popper: 2000;
$z-index-dialog: 3000;
$z-index-message: 4000;
// ==================== 布局 ====================
// 头部高度
$header-height: 60px;
// 侧边栏宽度
$sidebar-width: 240px;
$sidebar-collapsed-width: 64px;
// 聊天布局
$chat-sidebar-width: 280px;
$chat-panel-width: 320px;
// 容器最大宽度
$container-max-width: 1400px;
// ==================== 微信客服专用 ====================
// 消息气泡
$message-bubble-bg-self: #95ec69;
$message-bubble-bg-other: #ffffff;
$message-bubble-border-color: #e4e7ed;
$message-bubble-radius: 8px;
// 会话列表
$session-item-height: 72px;
$session-item-hover-bg: #f5f7fa;
$session-item-active-bg: #ecf5ff;
// 联系人列表
$contact-item-height: 60px;
$contact-avatar-size: 40px;
// 在线状态
$status-online: #67c23a;
$status-offline: #909399;
$status-busy: #e6a23c;

View File

@@ -1,312 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync']
const computedEager: typeof import('@vueuse/core')['computedEager']
const computedInject: typeof import('@vueuse/core')['computedInject']
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
const controlledRef: typeof import('@vueuse/core')['controlledRef']
const createApp: typeof import('vue')['createApp']
const createEventHook: typeof import('@vueuse/core')['createEventHook']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createPinia: typeof import('pinia')['createPinia']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
const reactivePick: typeof import('@vueuse/core')['reactivePick']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
const refDebounced: typeof import('@vueuse/core')['refDebounced']
const refDefault: typeof import('@vueuse/core')['refDefault']
const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
const throttledRef: typeof import('@vueuse/core')['throttledRef']
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
const toRaw: typeof import('vue')['toRaw']
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
const unref: typeof import('vue')['unref']
const unrefElement: typeof import('@vueuse/core')['unrefElement']
const until: typeof import('@vueuse/core')['until']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useAnimate: typeof import('@vueuse/core')['useAnimate']
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
const useArraySome: typeof import('@vueuse/core')['useArraySome']
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
const useCached: typeof import('@vueuse/core')['useCached']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
const useCycleList: typeof import('@vueuse/core')['useCycleList']
const useDark: typeof import('@vueuse/core')['useDark']
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDropZone: typeof import('@vueuse/core')['useDropZone']
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
const useFavicon: typeof import('@vueuse/core')['useFavicon']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
const useFocus: typeof import('@vueuse/core')['useFocus']
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
const useFps: typeof import('@vueuse/core')['useFps']
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteQuery: typeof import('@tanstack/vue-query')['useInfiniteQuery']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
const useInterval: typeof import('@vueuse/core')['useInterval']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLink: typeof import('vue-router')['useLink']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
const useMutation: typeof import('@tanstack/vue-query')['useMutation']
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
const useNetwork: typeof import('@vueuse/core')['useNetwork']
const useNow: typeof import('@vueuse/core')['useNow']
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const useParallax: typeof import('@vueuse/core')['useParallax']
const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useQuery: typeof import('@tanstack/vue-query')['useQuery']
const useQueryClient: typeof import('@tanstack/vue-query')['useQueryClient']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
const useScroll: typeof import('@vueuse/core')['useScroll']
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSlots: typeof import('vue')['useSlots']
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
const useThrottle: typeof import('@vueuse/core')['useThrottle']
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToNumber: typeof import('@vueuse/core')['useToNumber']
const useToString: typeof import('@vueuse/core')['useToString']
const useToggle: typeof import('@vueuse/core')['useToggle']
const useTransition: typeof import('@vueuse/core')['useTransition']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
const useVibrate: typeof import('@vueuse/core')['useVibrate']
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
const watch: typeof import('vue')['watch']
const watchArray: typeof import('@vueuse/core')['watchArray']
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
const watchDeep: typeof import('@vueuse/core')['watchDeep']
const watchEffect: typeof import('vue')['watchEffect']
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
const watchOnce: typeof import('@vueuse/core')['watchOnce']
const watchPausable: typeof import('@vueuse/core')['watchPausable']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
const whenever: typeof import('@vueuse/core')['whenever']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

View File

@@ -1,52 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree']
EmojiPicker: typeof import('./components/common/EmojiPicker/index.vue')['default']
FriendSelection: typeof import('./components/business/FriendSelection/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SessionContextMenu: typeof import('./components/business/SessionContextMenu/index.vue')['default']
}
}

View File

@@ -1,72 +0,0 @@
<template>
<div class="friend-selection">
<div class="placeholder">
<el-icon :size="40" color="#d9d9d9">
<UserFilled />
</el-icon>
<p>好友选择功能开发中...</p>
<p class="hint">需要从联系人列表选择好友</p>
</div>
<div class="footer">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" disabled>确定</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { UserFilled } from '@element-plus/icons-vue'
interface Props {
excludeIds?: (number | string)[]
includeIds?: (number | string)[]
multiple?: boolean
}
withDefaults(defineProps<Props>(), {
excludeIds: () => [],
includeIds: () => [],
multiple: true,
})
const emit = defineEmits<{
confirm: [ids: number[]]
cancel: []
}>()
const handleCancel = () => {
emit('cancel')
}
</script>
<style scoped lang="scss">
.friend-selection {
display: flex;
flex-direction: column;
height: 100%;
.placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
text-align: center;
color: var(--el-text-color-secondary);
.hint {
margin-top: 8px;
font-size: 12px;
}
}
.footer {
padding: 16px;
border-top: 1px solid var(--el-border-color-light);
display: flex;
justify-content: flex-end;
gap: 8px;
}
}
</style>

View File

@@ -1,173 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="visible"
ref="menuRef"
class="session-context-menu"
:style="{ left: `${x}px`, top: `${y}px` }"
@click.stop
>
<div class="menu-overlay" @click="handleClose" @contextmenu.prevent></div>
<div class="menu-content">
<div class="menu-item" @click="handleTogglePin">
<el-icon><Top /></el-icon>
<span>{{ isPinned ? '取消置顶' : '置顶' }}</span>
</div>
<div class="menu-item" @click="handleEditRemark">
<el-icon><Edit /></el-icon>
<span>修改备注</span>
</div>
<div class="menu-item danger" @click="handleDelete">
<el-icon><Delete /></el-icon>
<span>删除</span>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { Top, Edit, Delete } from '@element-plus/icons-vue'
import type { ChatSession } from '@/utils/db'
interface Props {
visible: boolean
x: number
y: number
session: ChatSession | null
}
interface Emits {
(e: 'close'): void
(e: 'toggle-pin', session: ChatSession): void
(e: 'edit-remark', session: ChatSession): void
(e: 'delete', session: ChatSession): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const menuRef = ref<HTMLDivElement>()
const isPinned = computed(() => props.session?.config?.top || false)
/**
* 关闭菜单
*/
const handleClose = () => {
emit('close')
}
/**
* 置顶/取消置顶
*/
const handleTogglePin = () => {
if (props.session) {
emit('toggle-pin', props.session)
}
handleClose()
}
/**
* 修改备注
*/
const handleEditRemark = () => {
if (props.session) {
emit('edit-remark', props.session)
}
handleClose()
}
/**
* 删除会话
*/
const handleDelete = () => {
if (props.session) {
emit('delete', props.session)
}
handleClose()
}
/**
* 点击外部关闭菜单
*/
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
handleClose()
}
}
/**
* 监听菜单显示状态
*/
watch(
() => props.visible,
(visible) => {
if (visible) {
document.addEventListener('mousedown', handleClickOutside)
} else {
document.removeEventListener('mousedown', handleClickOutside)
}
}
)
onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside)
})
</script>
<style scoped lang="scss">
.session-context-menu {
position: fixed;
z-index: 2000;
pointer-events: none;
.menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
pointer-events: all;
}
.menu-content {
position: relative;
min-width: 140px;
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
pointer-events: all;
overflow: hidden;
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-size: 14px;
color: var(--el-text-color-primary);
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: var(--el-fill-color-light);
}
&.danger {
color: var(--el-color-danger);
&:hover {
background-color: var(--el-color-danger-light-9);
}
}
.el-icon {
font-size: 16px;
}
}
}
}
</style>

View File

@@ -1,143 +0,0 @@
<template>
<div class="emoji-picker">
<!-- 分类标签 -->
<div class="emoji-categories">
<button
v-for="category in categories"
:key="category.key"
:class="['category-btn', { active: activeCategory === category.key }]"
:title="category.title"
@click="activeCategory = category.key"
>
{{ category.label }}
</button>
</div>
<!-- 表情网格 -->
<div class="emoji-grid">
<div
v-for="emoji in currentEmojis"
:key="emoji.name"
class="emoji-item"
:title="emoji.name"
@click="handleSelect(emoji)"
>
<img :src="emoji.path" :alt="emoji.name" class="emoji-image" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import {
EmojiCategory,
getEmojisByCategory,
type EmojiInfo,
} from '@/utils/wechatEmoji'
const emit = defineEmits<{
(e: 'select', emoji: EmojiInfo): void
(e: 'select-text', emoji: string): void
}>()
// 当前选中的分类
const activeCategory = ref<EmojiCategory>(EmojiCategory.FACE)
// 分类配置
const categories = [
{ key: EmojiCategory.FACE, label: '😊', title: '人脸' },
{ key: EmojiCategory.GESTURE, label: '👋', title: '手势' },
{ key: EmojiCategory.ANIMAL, label: '🐷', title: '动物' },
{ key: EmojiCategory.BLESSING, label: '🎉', title: '祝福' },
{ key: EmojiCategory.OTHER, label: '❤️', title: '其他' },
]
// 获取当前分类的表情
const currentEmojis = computed(() => getEmojisByCategory(activeCategory.value))
/**
* 处理表情选择
*/
const handleSelect = (emoji: EmojiInfo) => {
// 发送表情信息对象(用于 contentEditable 插入图片)
emit('select', emoji)
// 同时发送文本格式(用于兼容)
emit('select-text', `[${emoji.name}]`)
}
</script>
<style scoped lang="scss">
.emoji-picker {
width: 460px;
height: 350px;
background: #fff;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
.emoji-categories {
display: flex;
gap: 4px;
padding: 8px;
border-bottom: 1px solid var(--el-border-color-light);
background: var(--el-fill-color-lighter);
.category-btn {
flex: 1;
padding: 6px 12px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
font-size: 20px;
transition: background-color 0.2s;
&:hover {
background: var(--el-fill-color-light);
}
&.active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
}
.emoji-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(10, 1fr);
grid-auto-rows: 36px;
align-content: start;
gap: 6px;
padding: 12px;
overflow-y: auto;
min-height: 0;
.emoji-item {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
&:hover {
background-color: var(--el-fill-color-light);
}
.emoji-image {
width: 32px;
height: 32px;
object-fit: contain;
}
}
}
}
</style>

View File

@@ -1,107 +0,0 @@
/**
* 账号状态处理器
*
* 负责处理:
* - 账号在线状态
* - 账号登录响应
* - 账号列表更新
*/
import type { WebSocketMessage } from '@/types/wechat'
import type { HandlerRegistry } from './index'
import { useMessageSubscription } from '../useMessageSubscription'
import { WS_CMD_TYPE } from '@/constants/wechat'
import { useAccountStore } from '@/stores/modules/wechat'
const { emitAccountStatus } = useMessageSubscription()
// ==================== 账号状态处理器 ====================
/**
* 处理账号在线状态响应
* CmdRequestWechatAccountsAliveStatusResp
*/
function handleAccountAliveStatus(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data || !data.wechatAccountsAliveStatus) {
console.warn('[AccountHandler] 账号状态数据为空')
return
}
const aliveStatus = data.wechatAccountsAliveStatus as Record<number, boolean>
console.log('[AccountHandler] 📡 账号状态更新:', {
count: Object.keys(aliveStatus).length,
online: Object.values(aliveStatus).filter(Boolean).length,
})
// 发布每个账号的状态
Object.entries(aliveStatus).forEach(([accountId, isOnline]) => {
emitAccountStatus(Number(accountId), isOnline)
})
// 更新 Store
try {
const accountStore = useAccountStore()
accountStore.updateAccountsOnlineStatus(aliveStatus)
} catch (error) {
console.error('[AccountHandler] 更新 Store 失败:', error)
}
}
/**
* 处理登录响应
* CmdSignInResp
*/
function handleSignInResp(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data) {
console.warn('[AccountHandler] 登录响应数据为空')
return
}
console.log('[AccountHandler] 🔐 登录响应:', {
code: data.code,
message: data.message,
})
if (data.code === 0) {
console.log('[AccountHandler] ✅ 登录成功')
} else {
console.error('[AccountHandler] ❌ 登录失败:', data.message)
}
}
/**
* 处理账号列表更新
* CmdAccountListUpdate
*/
function handleAccountListUpdate(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data || !data.accounts) {
console.warn('[AccountHandler] 账号列表数据为空')
return
}
console.log('[AccountHandler] 📋 账号列表更新:', {
count: data.accounts.length,
})
// TODO: 更新账号列表 Store
}
// ==================== 导出处理器映射 ====================
export const accountHandlers: HandlerRegistry = {
// 账号在线状态
[WS_CMD_TYPE.ACCOUNT_ALIVE_STATUS_RESP]: handleAccountAliveStatus,
// 登录响应
[WS_CMD_TYPE.SIGN_IN_RESP]: handleSignInResp,
// 账号列表更新
[WS_CMD_TYPE.ACCOUNT_LIST_UPDATE]: handleAccountListUpdate,
}

View File

@@ -1,210 +0,0 @@
/**
* WebSocket 消息处理器注册中心
*
* 职责:
* - 统一注册所有消息处理器
* - 提供处理器调用入口
* - 支持动态扩展
*/
import type { WebSocketMessage } from '@/types/wechat'
import { messageHandlers } from './messageHandlers'
import { accountHandlers } from './accountHandlers'
import { sessionHandlers } from './sessionHandlers'
import { systemHandlers } from './systemHandlers'
// ==================== 类型定义 ====================
export type MessageHandler = (message: WebSocketMessage) => void | Promise<void>
export type HandlerRegistry = Record<string, MessageHandler>
// ==================== 处理器注册表 ====================
/**
* 默认处理器映射
* 包含所有模块的处理器
*/
const defaultHandlers: HandlerRegistry = {
...messageHandlers,
...accountHandlers,
...sessionHandlers,
...systemHandlers,
}
// ==================== 处理器管理器 ====================
/**
* 消息处理器管理器
* 支持动态注册和注销处理器
*/
export class MessageHandlerManager {
private handlers: HandlerRegistry
private readonly isDev = import.meta.env.DEV
constructor(initialHandlers: HandlerRegistry = {}) {
this.handlers = { ...defaultHandlers, ...initialHandlers }
if (this.isDev) {
console.log('[MessageHandler] 已注册的处理器:', Object.keys(this.handlers))
}
}
/**
* 处理消息
* @param cmdType 命令类型
* @param message 消息数据
*/
async handle(cmdType: string, message: WebSocketMessage): Promise<void> {
const handler = this.handlers[cmdType]
if (!handler) {
if (this.isDev) {
console.warn(`[MessageHandler] 未注册的消息类型: ${cmdType}`)
}
return
}
try {
if (this.isDev) {
console.log(`[MessageHandler] 处理消息: ${cmdType}`, {
preview: JSON.stringify(message).substring(0, 100) + '...',
})
}
await handler(message)
if (this.isDev) {
console.log(`[MessageHandler] ✅ 处理完成: ${cmdType}`)
}
} catch (error) {
console.error(`[MessageHandler] ❌ 处理失败: ${cmdType}`, error)
// 不抛出错误,避免影响后续消息处理
}
}
/**
* 注册新的处理器
* @param cmdType 命令类型
* @param handler 处理函数
*/
register(cmdType: string, handler: MessageHandler): void {
if (this.handlers[cmdType] && this.isDev) {
console.warn(`[MessageHandler] 覆盖已存在的处理器: ${cmdType}`)
}
this.handlers[cmdType] = handler
if (this.isDev) {
console.log(`[MessageHandler] 注册处理器: ${cmdType}`)
}
}
/**
* 批量注册处理器
* @param handlers 处理器映射
*/
registerBatch(handlers: HandlerRegistry): void {
Object.entries(handlers).forEach(([cmdType, handler]) => {
this.register(cmdType, handler)
})
}
/**
* 注销处理器
* @param cmdType 命令类型
*/
unregister(cmdType: string): void {
if (!this.handlers[cmdType]) {
if (this.isDev) {
console.warn(`[MessageHandler] 处理器不存在: ${cmdType}`)
}
return
}
delete this.handlers[cmdType]
if (this.isDev) {
console.log(`[MessageHandler] 注销处理器: ${cmdType}`)
}
}
/**
* 获取已注册的处理器列表
*/
getRegisteredTypes(): string[] {
return Object.keys(this.handlers)
}
/**
* 检查是否已注册
* @param cmdType 命令类型
*/
has(cmdType: string): boolean {
return !!this.handlers[cmdType]
}
/**
* 清空所有处理器(用于测试)
*/
clear(): void {
this.handlers = {}
if (this.isDev) {
console.log('[MessageHandler] 已清空所有处理器')
}
}
/**
* 重置为默认处理器
*/
reset(): void {
this.handlers = { ...defaultHandlers }
if (this.isDev) {
console.log('[MessageHandler] 已重置为默认处理器')
}
}
}
// ==================== 单例实例 ====================
let managerInstance: MessageHandlerManager | null = null
/**
* 获取消息处理器管理器实例(单例)
*/
export function getMessageHandlerManager(): MessageHandlerManager {
if (!managerInstance) {
managerInstance = new MessageHandlerManager()
}
return managerInstance
}
/**
* 创建消息处理函数(简化版)
*
* @example
* ```typescript
* const handleMessage = createMessageHandler()
*
* ws.onmessage = (event) => {
* const message = JSON.parse(event.data)
* handleMessage(message.cmdType, message)
* }
* ```
*/
export function createMessageHandler() {
const manager = getMessageHandlerManager()
return (cmdType: string, message: WebSocketMessage) => {
return manager.handle(cmdType, message)
}
}
// ==================== 导出 ====================
export {
messageHandlers,
accountHandlers,
sessionHandlers,
systemHandlers,
}

View File

@@ -1,331 +0,0 @@
/**
* 聊天消息处理器
*
* 负责处理:
* - 新消息接收
* - 消息状态更新
* - 消息撤回
*/
import type { WebSocketMessage } from '@/types/wechat'
import type { HandlerRegistry } from './index'
import { useMessageSubscription } from '../useMessageSubscription'
import { SessionManager } from '@/utils/dbManagers/SessionManager'
import { MessageManager } from '@/utils/dbManagers/MessageManager'
import { WS_CMD_TYPE } from '@/constants/wechat'
import { useMessageStore } from '@/stores/modules/wechat/useMessageStore'
const { emitNewMessage, emitMessageUpdate, emitMessageRecall } = useMessageSubscription()
// ==================== 辅助函数 ====================
/**
* 验证消息数据有效性
*/
function validateMessageData(data: any): boolean {
if (!data) {
console.warn('[MessageHandler] 消息数据为空')
return false
}
if (!data.sessionId || !data.sessionType) {
console.warn('[MessageHandler] 缺少会话信息:', data)
return false
}
return true
}
/**
* 构建标准 Message 对象
*/
function buildStandardMessage(data: any, currentAccountId?: number): Message {
return {
id: data.id || Date.now(),
clientId: data.clientId || `msg_${Date.now()}`,
serverId: data.serverId,
sessionId: data.sessionId,
wechatAccountId: data.wechatAccountId || currentAccountId || 0,
msgType: data.msgType || 1,
content: data.content || '',
direction: data.isSend ? 'send' : 'receive',
isSend: data.isSend || false,
createTime: data.createTime || new Date().toISOString(),
timestamp: data.wechatTime || Date.now(),
status: data.status || 'success',
sender: data.sender,
}
}
// ==================== 消息处理器 ====================
/**
* 处理新消息
* CmdNewMessage / CmdReceiveMessage
*/
async function handleNewMessage(wsMessage: WebSocketMessage): Promise<void> {
const data = wsMessage.data
// 1. 验证数据
if (!validateMessageData(data)) {
return
}
// 2. 消息去重(如果启用)
if (data.clientId && (await MessageManager.checkDuplicate(data.clientId))) {
console.log('[MessageHandler] 重复消息,跳过:', data.clientId)
return
}
console.log('[MessageHandler] 📨 收到新消息:', {
sessionId: data.sessionId,
type: data.sessionType,
content: data.content?.substring(0, 20) + '...',
})
try {
// 3. 更新会话(异步,不阻塞)
SessionManager.updateOnNewMessage(
data.sessionId,
data.sessionType,
data.content,
data.wechatAccountId
).catch((error) => {
console.error('[MessageHandler] 更新会话失败:', error)
})
// 4. 保存消息记录(如果启用缓存)
if (MessageManager.shouldCacheMessage(data.sessionId)) {
const chatMessage = buildStandardMessage(data)
MessageManager.addMessage(chatMessage).catch((error) => {
console.error('[MessageHandler] 保存消息失败:', error)
})
}
// 5. 发布新消息事件(立即通知订阅者)
const message = buildStandardMessage(data)
emitNewMessage(message)
console.log('[MessageHandler] ✅ 新消息处理完成')
} catch (error) {
console.error('[MessageHandler] ❌ 新消息处理失败:', error)
}
}
/**
* 处理发送消息响应
* CmdSendMessageResp - 只有发送消息才会有这个响应,包含 seq
*/
function handleSendMessageResp(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data) {
console.warn('[MessageHandler] 发送消息响应数据为空')
return
}
// ⭐ CmdSendMessageResp 包含 seq用于匹配和服务器返回的消息ID
const { seq, friendMessage, chatroomMessage } = data
if (!seq) {
console.warn('[MessageHandler] CmdSendMessageResp 缺少 seq:', data)
return
}
// ⭐ 确定会话ID从 friendMessage 或 chatroomMessage 中提取)
// 旧项目数据结构:{ seq, friendMessage: { wechatFriendId, id }, chatroomMessage: { wechatChatroomId, id } }
let targetSessionId: number | undefined
let isGroupChat = false
if (friendMessage?.wechatFriendId) {
// 好友消息:使用 wechatFriendId 作为 sessionId
targetSessionId =
typeof friendMessage.wechatFriendId === 'string'
? Number(friendMessage.wechatFriendId)
: friendMessage.wechatFriendId
isGroupChat = false
} else if (chatroomMessage?.wechatChatroomId) {
// 群聊消息:使用 wechatChatroomId 作为 sessionId可能是 string需要转换
const chatroomId =
typeof chatroomMessage.wechatChatroomId === 'string'
? Number(chatroomMessage.wechatChatroomId)
: chatroomMessage.wechatChatroomId
if (!isNaN(chatroomId)) {
targetSessionId = chatroomId
}
isGroupChat = true
}
if (!targetSessionId) {
console.warn('[MessageHandler] CmdSendMessageResp 无法确定 sessionId:', {
seq,
friendMessage,
chatroomMessage,
fullData: data,
})
return
}
// ⭐ 通过 seq 找到之前发送的临时消息
// ⚠️ 注意:发送消息时使用的 sessionId 可能是 session.id对于群聊可能是 session.chatroomId
// 但响应中的 sessionId 是 wechatFriendId 或 wechatChatroomId
// 我们需要尝试两种可能的 sessionId
const messageStore = useMessageStore()
let tempMessage = messageStore.findMessageBySeq(targetSessionId, seq)
// ⭐ 如果没找到,尝试使用 session.id因为发送时可能使用的是 session.id
// 对于群聊session.id 可能等于 chatroomId也可能不等于
if (!tempMessage) {
// 遍历所有会话,查找包含该 seq 的消息
const allSessions = Array.from(messageStore.messages.keys())
for (const sessionId of allSessions) {
tempMessage = messageStore.findMessageBySeq(sessionId, seq)
if (tempMessage) {
targetSessionId = sessionId // ⭐ 更新为实际找到的 sessionId
break
}
}
}
if (tempMessage) {
// ⭐ 获取服务器返回的真实消息ID
const serverMessageId = friendMessage?.id || chatroomMessage?.id
// ⭐ 通过事件机制更新消息状态(包含 sessionId
emitMessageUpdate(seq, {
status: 'success', // 发送成功
id: serverMessageId || tempMessage.id, // 使用服务器返回的真实ID
serverId: serverMessageId ? String(serverMessageId) : undefined,
sessionId: targetSessionId, // ⭐ 传递 sessionId供订阅者使用
})
console.log('[MessageHandler] ✅ 发送消息响应处理完成:', {
seq,
sessionId: targetSessionId,
serverMessageId,
tempMessageId: tempMessage.id,
})
} else {
console.warn('[MessageHandler] ⚠️ 未找到对应的临时消息:', {
seq,
sessionId: targetSessionId,
friendMessage,
chatroomMessage,
})
}
}
/**
* 处理消息状态更新
* CmdMessageStatus - 通用消息状态更新(不包含 seq
*/
function handleMessageStatus(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data) {
console.warn('[MessageHandler] 消息状态数据为空')
return
}
const { messageId, clientId, status, sessionId } = data
// ⭐ CmdMessageStatus 不包含 seq使用 messageId 或 clientId
const id = messageId || clientId
if (!id) {
console.warn('[MessageHandler] 缺少消息标识:', data)
return
}
// console.log('[MessageHandler] 📝 消息状态更新:', {
// id,
// status,
// sessionId,
// })
// 发布消息更新事件
emitMessageUpdate(id, {
status: status || 'success',
serverId: data.serverId,
})
// 更新数据库(异步)
if (sessionId && MessageManager.shouldCacheMessage(sessionId)) {
MessageManager.updateMessageStatus(id, status || 'success').catch((error) => {
console.error('[MessageHandler] 更新消息状态失败:', error)
})
}
}
/**
* 处理消息撤回
* CmdRecallMessage
*/
function handleMessageRecall(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data || !data.messageId || !data.sessionId) {
console.warn('[MessageHandler] 撤回消息数据不完整:', data)
return
}
console.log('[MessageHandler] 🗑️ 消息撤回:', {
messageId: data.messageId,
sessionId: data.sessionId,
})
// 发布撤回事件
emitMessageRecall(data.messageId, data.sessionId)
// 更新数据库(异步)
if (MessageManager.shouldCacheMessage(data.sessionId)) {
MessageManager.markAsRecalled(data.messageId).catch((error) => {
console.error('[MessageHandler] 标记撤回失败:', error)
})
}
}
/**
* 处理发送消息结果
* CmdSendMessageResult
*/
function handleSendMessageResult(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data) {
console.warn('[MessageHandler] 发送结果数据为空')
return
}
console.log('[MessageHandler] 📤 发送消息结果:', {
messageId: data.friendMessageId || data.chatroomMessageId,
status: data.sendStatus,
})
// 更新消息状态
const messageId = data.friendMessageId || data.chatroomMessageId
if (messageId) {
const status = data.sendStatus === 0 ? 'success' : 'failed'
emitMessageUpdate(messageId, { status })
}
}
// ==================== 导出处理器映射 ====================
export const messageHandlers: HandlerRegistry = {
// 接收新消息(不包含 seq
[WS_CMD_TYPE.RECEIVE_MESSAGE]: handleNewMessage,
[WS_CMD_TYPE.NEW_MESSAGE]: handleNewMessage, // 别名
// ⭐ 发送消息响应(包含 seq需要特殊处理
[WS_CMD_TYPE.SEND_MESSAGE_RESP]: handleSendMessageResp,
// 通用消息状态更新(不包含 seq
[WS_CMD_TYPE.MESSAGE_STATUS]: handleMessageStatus,
// 消息撤回
[WS_CMD_TYPE.RECALL_MESSAGE]: handleMessageRecall,
// 发送结果
[WS_CMD_TYPE.SEND_MESSAGE_RESULT]: handleSendMessageResult,
}

View File

@@ -1,114 +0,0 @@
/**
* 会话相关处理器
*
* 负责处理:
* - 会话列表更新
* - 会话置顶
* - 未读数变化
*/
import type { WebSocketMessage } from '@/types/wechat'
import type { HandlerRegistry } from './index'
import { useMessageSubscription } from '../useMessageSubscription'
import { WS_CMD_TYPE } from '@/constants/wechat'
import { useSessionStore } from '@/stores/modules/wechat'
const { emitSessionUpdate } = useMessageSubscription()
// ==================== 会话处理器 ====================
/**
* 处理会话更新
* CmdSessionUpdate
*/
function handleSessionUpdate(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data || !data.sessionId) {
console.warn('[SessionHandler] 会话更新数据不完整')
return
}
console.log('[SessionHandler] 🔄 会话更新:', {
sessionId: data.sessionId,
type: data.type,
})
// 发布会话更新事件
emitSessionUpdate(data.sessionId, data)
// 更新 Store
try {
const sessionStore = useSessionStore()
if (data.type === 'pin') {
sessionStore.togglePin(data.sessionId)
} else if (data.type === 'mute') {
sessionStore.toggleMute(data.sessionId)
}
} catch (error) {
console.error('[SessionHandler] 更新 Store 失败:', error)
}
}
/**
* 处理未读数清零
* CmdClearUnread
*/
function handleClearUnread(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data || !data.sessionId) {
console.warn('[SessionHandler] 清除未读数据不完整')
return
}
console.log('[SessionHandler] 🔔 清除未读:', {
sessionId: data.sessionId,
})
// 更新 Store
try {
const sessionStore = useSessionStore()
sessionStore.clearUnreadCount(data.sessionId)
} catch (error) {
console.error('[SessionHandler] 清除未读失败:', error)
}
}
/**
* 处理会话删除
* CmdSessionDelete
*/
function handleSessionDelete(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data || !data.sessionId) {
console.warn('[SessionHandler] 删除会话数据不完整')
return
}
console.log('[SessionHandler] 🗑️ 删除会话:', {
sessionId: data.sessionId,
})
// 更新 Store
try {
const sessionStore = useSessionStore()
sessionStore.removeSession(data.sessionId)
} catch (error) {
console.error('[SessionHandler] 删除会话失败:', error)
}
}
// ==================== 导出处理器映射 ====================
export const sessionHandlers: HandlerRegistry = {
// 会话更新
[WS_CMD_TYPE.SESSION_UPDATE]: handleSessionUpdate,
// 清除未读
[WS_CMD_TYPE.CLEAR_UNREAD]: handleClearUnread,
// 删除会话
[WS_CMD_TYPE.SESSION_DELETE]: handleSessionDelete,
}

View File

@@ -1,129 +0,0 @@
/**
* 系统通知处理器
*
* 负责处理:
* - 系统通知
* - 心跳响应
* - 错误消息
*/
import type { WebSocketMessage } from '@/types/wechat'
import type { HandlerRegistry } from './index'
import { useMessageSubscription } from '../useMessageSubscription'
import { WS_CMD_TYPE } from '@/constants/wechat'
import { ElNotification } from 'element-plus'
const { emitSystemNotification } = useMessageSubscription()
// ==================== 系统处理器 ====================
/**
* 处理心跳响应
* CmdHeartbeat / CmdPing
*/
function handleHeartbeat(wsMessage: WebSocketMessage): void {
console.log('[SystemHandler] 💓 心跳响应')
// 更新最后活跃时间
const lastActiveTime = Date.now()
localStorage.setItem('lastSyncTime', lastActiveTime.toString())
}
/**
* 处理系统通知
* CmdSystemNotification
*/
function handleSystemNotification(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data || !data.type || !data.content) {
console.warn('[SystemHandler] 系统通知数据不完整')
return
}
console.log('[SystemHandler] 📢 系统通知:', {
type: data.type,
content: data.content,
})
// 发布通知事件
emitSystemNotification(data.type, data.content)
// 显示桌面通知
if (data.showNotification !== false) {
ElNotification({
title: '系统通知',
message: data.content,
type: data.level || 'info',
duration: 5000,
})
}
}
/**
* 处理错误消息
* CmdError
*/
function handleError(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data) {
console.warn('[SystemHandler] 错误消息数据为空')
return
}
console.error('[SystemHandler] ❌ 错误消息:', {
code: data.code,
message: data.message,
})
// 显示错误通知
ElNotification({
title: '错误',
message: data.message || '发生未知错误',
type: 'error',
duration: 5000,
})
}
/**
* 处理服务器时间同步
* CmdServerTime
*/
function handleServerTime(wsMessage: WebSocketMessage): void {
const data = wsMessage.data
if (!data || !data.serverTime) {
return
}
const serverTime = data.serverTime
const localTime = Date.now()
const timeDiff = serverTime - localTime
console.log('[SystemHandler] 🕐 服务器时间:', {
serverTime: new Date(serverTime).toLocaleString(),
localTime: new Date(localTime).toLocaleString(),
diff: `${timeDiff}ms`,
})
// 保存时间差(用于时间校准)
localStorage.setItem('serverTimeDiff', timeDiff.toString())
}
// ==================== 导出处理器映射 ====================
export const systemHandlers: HandlerRegistry = {
// 心跳
[WS_CMD_TYPE.HEARTBEAT]: handleHeartbeat,
[WS_CMD_TYPE.PING]: handleHeartbeat, // 别名
// 系统通知
[WS_CMD_TYPE.SYSTEM_NOTIFICATION]: handleSystemNotification,
// 错误消息
[WS_CMD_TYPE.ERROR]: handleError,
// 服务器时间
[WS_CMD_TYPE.SERVER_TIME]: handleServerTime,
}

View File

@@ -1,12 +0,0 @@
/**
* 微信业务 Composables 统一导出
*/
export { useWebSocket } from './useWebSocket'
export { useMessageSubscription, setupMessageSubscription } from './useMessageSubscription'
export { useAIRequestQueue } from './useAIRequestQueue'
export { useMessageParser } from './useMessageParser'
// 导出 handlers
export { getMessageHandlerManager, createMessageHandler } from './handlers'
export type { MessageHandler, HandlerRegistry } from './handlers'

View File

@@ -1,151 +0,0 @@
/**
* AI 请求队列管理 Composable
*/
import { ref, onUnmounted } from 'vue'
import { debounce } from 'lodash-es'
import type { Message } from '@/types/wechat'
import { AI_REQUEST_DEBOUNCE } from '@/constants/wechat'
import { useAIStore } from '@/stores/modules/wechat'
export function useAIRequestQueue(delay: number = AI_REQUEST_DEBOUNCE) {
const aiStore = useAIStore()
// ==================== 状态 ====================
/** 消息队列 */
const queue = ref<Message[]>([])
/** 是否正在处理 */
const isProcessing = ref(false)
/** 当前生成ID */
const currentGenerationId = ref<string | null>(null)
// ==================== 防抖处理 ====================
/**
* 处理队列(防抖)
*/
const processQueue = debounce(
async (accountId: number, contactId: string) => {
if (queue.value.length === 0 || isProcessing.value) {
return
}
isProcessing.value = true
try {
const messages = [...queue.value]
queue.value = []
console.log('开始处理AI请求队列:', messages.length, '条消息')
// 调用AI生成
const response = await aiStore.generateReply({
messages,
contactId,
accountId,
})
console.log('AI生成完成:', response.content)
return response
} catch (error) {
console.error('AI生成失败:', error)
throw error
} finally {
isProcessing.value = false
currentGenerationId.value = null
}
},
delay,
)
// ==================== 队列操作 ====================
/**
* 添加消息到队列
*/
const addToQueue = (message: Message, accountId: number, contactId: string) => {
queue.value.push(message)
console.log('添加消息到AI队列:', message.id, '队列长度:', queue.value.length)
// 触发防抖处理
processQueue(accountId, contactId)
}
/**
* 清空队列
*/
const clearQueue = () => {
queue.value = []
processQueue.cancel()
isProcessing.value = false
currentGenerationId.value = null
console.log('AI请求队列已清空')
}
/**
* 立即处理队列(取消防抖)
*/
const processImmediately = async (accountId: number, contactId: string) => {
processQueue.cancel()
if (queue.value.length === 0) {
return
}
isProcessing.value = true
try {
const messages = [...queue.value]
queue.value = []
const response = await aiStore.generateReply({
messages,
contactId,
accountId,
})
return response
} catch (error) {
console.error('AI生成失败:', error)
throw error
} finally {
isProcessing.value = false
}
}
/**
* 获取队列长度
*/
const getQueueLength = () => queue.value.length
/**
* 是否有待处理的消息
*/
const hasPendingMessages = () => queue.value.length > 0
// ==================== 生命周期 ====================
onUnmounted(() => {
clearQueue()
})
// ==================== 返回 ====================
return {
// 状态
queue,
isProcessing,
currentGenerationId,
// 方法
addToQueue,
clearQueue,
processQueue: processImmediately,
getQueueLength,
hasPendingMessages,
}
}

View File

@@ -1,300 +0,0 @@
/**
* 消息解析 Composable
*/
import { computed, type Ref } from 'vue'
import type { Message } from '@/types/wechat'
import { MESSAGE_TYPE, IMAGE_URL_REGEX, VIDEO_URL_REGEX, AUDIO_URL_REGEX } from '@/constants/wechat'
export function useMessageParser(message: Ref<Message>) {
// ==================== 消息类型判断 ====================
/**
* 消息类型
*/
const messageType = computed(() => {
const type = message.value.msgType
switch (type) {
case MESSAGE_TYPE.TEXT:
return 'text'
case MESSAGE_TYPE.IMAGE:
return 'image'
case MESSAGE_TYPE.VIDEO:
return 'video'
case MESSAGE_TYPE.AUDIO:
return 'audio'
case MESSAGE_TYPE.FILE:
return 'file'
case MESSAGE_TYPE.LOCATION:
return 'location'
case MESSAGE_TYPE.EMOJI:
return 'emoji'
case MESSAGE_TYPE.MINI_PROGRAM:
return 'miniProgram'
case MESSAGE_TYPE.RED_PACKET:
return 'redPacket'
case MESSAGE_TYPE.TRANSFER:
return 'transfer'
case MESSAGE_TYPE.SYSTEM:
case MESSAGE_TYPE.TIME_DIVIDER:
case MESSAGE_TYPE.RECOMMEND_REMARK:
case MESSAGE_TYPE.GROUP_INVITE:
return 'system'
default:
return 'unknown'
}
})
/**
* 是否是文本消息
*/
const isTextMessage = computed(() => messageType.value === 'text')
/**
* 是否是图片消息
*/
const isImageMessage = computed(() => messageType.value === 'image')
/**
* 是否是视频消息
*/
const isVideoMessage = computed(() => messageType.value === 'video')
/**
* 是否是语音消息
*/
const isAudioMessage = computed(() => messageType.value === 'audio')
/**
* 是否是文件消息
*/
const isFileMessage = computed(() => messageType.value === 'file')
/**
* 是否是位置消息
*/
const isLocationMessage = computed(() => messageType.value === 'location')
/**
* 是否是表情消息
*/
const isEmojiMessage = computed(() => messageType.value === 'emoji')
/**
* 是否是系统消息
*/
const isSystemMessage = computed(() => messageType.value === 'system')
/**
* 是否是自己发送的消息
*/
const isOwnMessage = computed(() => message.value.isSend)
/**
* 是否已撤回
*/
const isRecalled = computed(() => message.value.isRecalled)
// ==================== 消息内容解析 ====================
/**
* 解析的消息内容
*/
const parsedContent = computed(() => {
try {
const content = message.value.content
// 文件消息
if (isFileMessage.value) {
const fileData = tryParseJSON(content)
if (fileData) {
return {
type: 'file',
url: fileData.url || content,
name: fileData.title || fileData.name || '文件',
size: fileData.size || 0,
ext: fileData.fileext || '',
}
}
}
// 图片消息
if (isImageMessage.value) {
return {
type: 'image',
url: content,
}
}
// 视频消息
if (isVideoMessage.value) {
const videoData = tryParseJSON(content)
if (videoData) {
return {
type: 'video',
url: videoData.url || content,
thumbUrl: videoData.thumbUrl,
duration: videoData.duration,
}
}
return {
type: 'video',
url: content,
}
}
// 语音消息
if (isAudioMessage.value) {
const audioData = tryParseJSON(content)
if (audioData) {
return {
type: 'audio',
url: audioData.url || content,
duration: audioData.duration || 0,
text: audioData.text,
}
}
return {
type: 'audio',
url: content,
duration: 0,
}
}
// 位置消息
if (isLocationMessage.value) {
const locationData = tryParseJSON(content)
if (locationData) {
return {
type: 'location',
label: locationData.label || '',
lat: locationData.lat || 0,
lng: locationData.lng || 0,
poiName: locationData.poiName,
}
}
}
// 文本消息(默认)
return {
type: 'text',
text: content || '',
}
} catch (error) {
console.error('消息解析失败:', error)
return {
type: 'text',
text: message.value.content || '[消息解析失败]',
}
}
})
/**
* 消息预览文本(用于会话列表)
*/
const previewText = computed(() => {
if (isRecalled.value) {
return '[已撤回]'
}
switch (messageType.value) {
case 'text':
return message.value.content
case 'image':
return '[图片]'
case 'video':
return '[视频]'
case 'audio':
return '[语音]'
case 'file':
return '[文件]'
case 'location':
return '[位置]'
case 'emoji':
return '[表情]'
case 'miniProgram':
return '[小程序]'
case 'redPacket':
return '[红包]'
case 'transfer':
return '[转账]'
case 'system':
return message.value.content
default:
return '[未知消息]'
}
})
// ==================== 辅助方法 ====================
/**
* 尝试解析JSON
*/
function tryParseJSON(str: string) {
try {
return JSON.parse(str)
} catch {
return null
}
}
/**
* 判断是否是URL
*/
function isURL(str: string) {
try {
new URL(str)
return true
} catch {
return false
}
}
/**
* 判断是否是图片URL
*/
function isImageURL(url: string) {
return IMAGE_URL_REGEX.test(url)
}
/**
* 判断是否是视频URL
*/
function isVideoURL(url: string) {
return VIDEO_URL_REGEX.test(url)
}
/**
* 判断是否是音频URL
*/
function isAudioURL(url: string) {
return AUDIO_URL_REGEX.test(url)
}
// ==================== 返回 ====================
return {
// 消息类型
messageType,
isTextMessage,
isImageMessage,
isVideoMessage,
isAudioMessage,
isFileMessage,
isLocationMessage,
isEmojiMessage,
isSystemMessage,
isOwnMessage,
isRecalled,
// 解析内容
parsedContent,
previewText,
// 工具方法
isURL,
isImageURL,
isVideoURL,
isAudioURL,
}
}

View File

@@ -1,354 +0,0 @@
/**
* WebSocket 消息订阅中心(发布订阅模式)
*
* 核心职责:
* - 解耦消息发送方WebSocket和接收方组件/Store
* - 支持多个订阅者
* - 提供类型安全的事件系统
*
* 使用场景:
* 1. 组件订阅:实时更新 UI
* 2. Store 订阅:更新全局状态
* 3. Service 订阅:触发副作用(通知、音效等)
*/
import mitt, { type Emitter } from 'mitt'
import type { Message } from '@/types/wechat'
// ==================== 事件类型定义 ====================
type MessageEvents = {
// 新消息
'message:new': Message
// 消息更新
'message:update': {
id: number | string
changes: Partial<Message>
}
// 消息撤回
'message:recall': {
messageId: number
sessionId: number
}
// 会话更新
'session:update': {
sessionId: number
changes: any
}
// 账号状态
'account:status': {
accountId: number
isOnline: boolean
}
// 系统通知
'system:notification': {
type: string
content: string
}
}
// ==================== 调试工具 ====================
let eventEmitter: Emitter<MessageEvents> | null = null
let subscriptionCount = new Map<keyof MessageEvents, number>()
// 开发环境:记录订阅和发布
const isDev = import.meta.env.DEV
function logSubscription(event: keyof MessageEvents, action: 'subscribe' | 'unsubscribe') {
if (!isDev) return
const count = subscriptionCount.get(event) || 0
const newCount = action === 'subscribe' ? count + 1 : Math.max(0, count - 1)
subscriptionCount.set(event, newCount)
console.log(`[MessageSubscription] ${action}: ${event}, 当前订阅数: ${newCount}`)
}
function logEmit(event: keyof MessageEvents, data: any) {
if (!isDev) return
const count = subscriptionCount.get(event) || 0
console.log(`[MessageSubscription] emit: ${event}, 订阅者数: ${count}`, {
preview: JSON.stringify(data).substring(0, 100) + '...',
})
}
// ==================== 核心实现 ====================
/**
* 获取或创建事件发射器(单例)
*/
function getEmitter(): Emitter<MessageEvents> {
if (!eventEmitter) {
eventEmitter = mitt<MessageEvents>()
// 开发环境:显示订阅信息
if (isDev) {
console.log('[MessageSubscription] 事件中心已初始化')
}
}
return eventEmitter
}
/**
* 消息订阅 Hook
*
* @example
* ```typescript
* // 在组件中使用
* const { onNewMessage, emitNewMessage } = useMessageSubscription()
*
* // 订阅新消息
* onNewMessage((message) => {
* console.log('收到新消息:', message)
* })
*
* // 发布新消息(通常由 WebSocket 调用)
* emitNewMessage(message)
* ```
*/
export function useMessageSubscription() {
const emitter = getEmitter()
// ==================== 订阅方法(组件/Store 使用)====================
/**
* 订阅新消息
*/
const onNewMessage = (handler: (message: Message) => void) => {
logSubscription('message:new', 'subscribe')
emitter.on('message:new', handler)
// 返回取消订阅函数
return () => {
logSubscription('message:new', 'unsubscribe')
emitter.off('message:new', handler)
}
}
/**
* 订阅消息更新
*/
const onMessageUpdate = (
handler: (update: { id: number | string; changes: Partial<Message> }) => void
) => {
logSubscription('message:update', 'subscribe')
emitter.on('message:update', handler)
return () => {
logSubscription('message:update', 'unsubscribe')
emitter.off('message:update', handler)
}
}
/**
* 订阅消息撤回
*/
const onMessageRecall = (handler: (data: { messageId: number; sessionId: number }) => void) => {
logSubscription('message:recall', 'subscribe')
emitter.on('message:recall', handler)
return () => {
logSubscription('message:recall', 'unsubscribe')
emitter.off('message:recall', handler)
}
}
/**
* 订阅会话更新
*/
const onSessionUpdate = (handler: (data: { sessionId: number; changes: any }) => void) => {
logSubscription('session:update', 'subscribe')
emitter.on('session:update', handler)
return () => {
logSubscription('session:update', 'unsubscribe')
emitter.off('session:update', handler)
}
}
/**
* 订阅账号状态
*/
const onAccountStatus = (handler: (data: { accountId: number; isOnline: boolean }) => void) => {
logSubscription('account:status', 'subscribe')
emitter.on('account:status', handler)
return () => {
logSubscription('account:status', 'unsubscribe')
emitter.off('account:status', handler)
}
}
/**
* 订阅系统通知
*/
const onSystemNotification = (handler: (data: { type: string; content: string }) => void) => {
logSubscription('system:notification', 'subscribe')
emitter.on('system:notification', handler)
return () => {
logSubscription('system:notification', 'unsubscribe')
emitter.off('system:notification', handler)
}
}
// ==================== 发布方法WebSocket 使用)====================
/**
* 发布新消息
*/
const emitNewMessage = (message: Message) => {
logEmit('message:new', message)
emitter.emit('message:new', message)
}
/**
* 发布消息更新
*/
const emitMessageUpdate = (id: number | string, changes: Partial<Message>) => {
logEmit('message:update', { id, changes })
emitter.emit('message:update', { id, changes })
}
/**
* 发布消息撤回
*/
const emitMessageRecall = (messageId: number, sessionId: number) => {
logEmit('message:recall', { messageId, sessionId })
emitter.emit('message:recall', { messageId, sessionId })
}
/**
* 发布会话更新
*/
const emitSessionUpdate = (sessionId: number, changes: any) => {
logEmit('session:update', { sessionId, changes })
emitter.emit('session:update', { sessionId, changes })
}
/**
* 发布账号状态
*/
const emitAccountStatus = (accountId: number, isOnline: boolean) => {
logEmit('account:status', { accountId, isOnline })
emitter.emit('account:status', { accountId, isOnline })
}
/**
* 发布系统通知
*/
const emitSystemNotification = (type: string, content: string) => {
logEmit('system:notification', { type, content })
emitter.emit('system:notification', { type, content })
}
// ==================== 工具方法 ====================
/**
* 清除所有订阅(用于测试)
*/
const clearAll = () => {
emitter.all.clear()
subscriptionCount.clear()
console.log('[MessageSubscription] 已清除所有订阅')
}
/**
* 获取订阅统计(用于调试)
*/
const getStats = () => {
return Object.fromEntries(subscriptionCount)
}
return {
// 订阅
onNewMessage,
onMessageUpdate,
onMessageRecall,
onSessionUpdate,
onAccountStatus,
onSystemNotification,
// 发布
emitNewMessage,
emitMessageUpdate,
emitMessageRecall,
emitSessionUpdate,
emitAccountStatus,
emitSystemNotification,
// 工具
clearAll,
getStats,
}
}
/**
* 全局单例:在 Store 中统一订阅
*
* @example
* ```typescript
* // stores/modules/wechat/useMessageStore.ts
* import { setupMessageSubscription } from '@/composables/business/wechat/useMessageSubscription'
*
* export const useMessageStore = defineStore('message', () => {
* const messages = ref<Message[]>([])
*
* // 设置订阅
* setupMessageSubscription({
* onNewMessage: (msg) => {
* messages.value.push(msg)
* },
* onMessageUpdate: ({ id, changes }) => {
* // 更新消息
* }
* })
*
* return { messages }
* })
* ```
*/
export function setupMessageSubscription(handlers: {
onNewMessage?: (message: Message) => void
onMessageUpdate?: (update: { id: number | string; changes: Partial<Message> }) => void
onMessageRecall?: (data: { messageId: number; sessionId: number }) => void
onSessionUpdate?: (data: { sessionId: number; changes: any }) => void
onAccountStatus?: (data: { accountId: number; isOnline: boolean }) => void
onSystemNotification?: (data: { type: string; content: string }) => void
}) {
const subscription = useMessageSubscription()
// 自动设置所有订阅
const unsubscribers: (() => void)[] = []
if (handlers.onNewMessage) {
unsubscribers.push(subscription.onNewMessage(handlers.onNewMessage))
}
if (handlers.onMessageUpdate) {
unsubscribers.push(subscription.onMessageUpdate(handlers.onMessageUpdate))
}
if (handlers.onMessageRecall) {
unsubscribers.push(subscription.onMessageRecall(handlers.onMessageRecall))
}
if (handlers.onSessionUpdate) {
unsubscribers.push(subscription.onSessionUpdate(handlers.onSessionUpdate))
}
if (handlers.onAccountStatus) {
unsubscribers.push(subscription.onAccountStatus(handlers.onAccountStatus))
}
if (handlers.onSystemNotification) {
unsubscribers.push(subscription.onSystemNotification(handlers.onSystemNotification))
}
// 返回统一的清理函数
return () => {
unsubscribers.forEach(unsub => unsub())
}
}

View File

@@ -1,537 +0,0 @@
/**
* WebSocket 连接管理 Composable重构版 - 集成 IndexedDB
*
* 核心改造:
* ✅ 收到新消息 → 更新 IndexedDBSessionManager.updateOnNewMessage
* ✅ 会话不存在 → 自动获取好友详情并创建
* ✅ 断线重连 → 增量同步遗漏消息
* ✅ 心跳检测 → 记录最后同步时间
*
* @author TouchVueThree Team
* @date 2026-01-13
*/
import { ref, onUnmounted } from 'vue'
import type { WebSocketConfig, WebSocketMessage, WebSocketStatus } from '@/types/wechat'
import {
WS_HEARTBEAT_INTERVAL,
WS_RECONNECT_INTERVAL,
WS_MAX_RECONNECT_ATTEMPTS,
WS_RECONNECT_BACKOFF_BASE,
WS_CMD_TYPE,
} from '@/constants/wechat'
import { useMessageSubscription } from './useMessageSubscription'
import { SessionManager } from '@/utils/dbManagers/SessionManager'
import { MessageManager } from '@/utils/dbManagers/MessageManager'
import { getMessageHandlerManager } from './handlers'
import { ElMessage } from 'element-plus'
import { useAccountStore } from '@/stores/modules/wechat' // ⭐ 导入账号 Store
// 默认配置
const DEFAULT_CONFIG: Partial<WebSocketConfig> = {
url: import.meta.env.VITE_API_WS_URL || 'ws://localhost:8080/ws',
client: 'kefu-client',
autoReconnect: true,
reconnectInterval: WS_RECONNECT_INTERVAL,
maxReconnectAttempts: WS_MAX_RECONNECT_ATTEMPTS,
heartbeatInterval: WS_HEARTBEAT_INTERVAL,
}
// ⭐⭐⭐ 单例模式:全局共享 WebSocket 状态 ⭐⭐⭐
const ws = ref<WebSocket | null>(null)
const status = ref<WebSocketStatus>('disconnected')
const reconnectAttempts = ref(0)
const config = ref<WebSocketConfig | null>(null)
// 定时器(全局)
let heartbeatTimer: NodeJS.Timeout | null = null
let reconnectTimer: NodeJS.Timeout | null = null
let heartbeatTimeoutTimer: NodeJS.Timeout | null = null
let aliveStatusTimer: NodeJS.Timeout | null = null // ⭐ 在线状态查询定时器
// 消息订阅(全局)
const { emitNewMessage, emitMessageUpdate } = useMessageSubscription()
// ⭐ 消息处理器管理器(全局)
const handlerManager = getMessageHandlerManager()
// ⭐ 最后同步时间(全局,用于断线重连后增量同步)
let lastSyncTime = Date.now()
// ⭐ 在线状态查询相关(全局)
let lastAliveStatusRequest = 0
const ALIVE_STATUS_REQUEST_INTERVAL = 5000 // 每 5 秒查询一次
/**
* WebSocket 管理 Composable单例模式
*
* ⚠️ 重要:此 composable 采用单例模式
* - 所有组件共享同一个 WebSocket 连接
* - 多次调用 useWebSocket() 返回的是同一个实例
* - 只需在根组件Chat/index.vue调用 connect() 一次
*/
export function useWebSocket() {
// ==================== 状态(单例共享) ====================
// ws, status, reconnectAttempts, config 已在外部定义
// ==================== 计算属性 ====================
const isConnected = () => status.value === 'connected'
const isConnecting = () => status.value === 'connecting'
const isReconnecting = () => status.value === 'reconnecting'
// ==================== 连接管理 ====================
/**
* 连接 WebSocket
*/
const connect = (userConfig: Partial<WebSocketConfig>) => {
// 如果已连接,先断开
if (ws.value) {
disconnect()
}
// 合并配置
config.value = {
...DEFAULT_CONFIG,
...userConfig,
} as WebSocketConfig
// 创建连接
status.value = 'connecting'
const wsUrl = `${config.value.url}?token=${config.value.accessToken}&accountId=${config.value.accountId}`
try {
ws.value = new WebSocket(wsUrl)
// 绑定事件
ws.value.onopen = handleOpen
ws.value.onmessage = handleMessage
ws.value.onclose = handleClose
ws.value.onerror = handleError
} catch (error) {
console.error('WebSocket 连接失败:', error)
status.value = 'error'
handleReconnect()
}
}
/**
* 断开连接
*/
const disconnect = () => {
stopHeartbeat()
stopReconnect()
stopAliveStatusQuery() // ⭐ 停止在线状态查询
if (ws.value) {
try {
ws.value.close()
} catch (error) {
console.error('WebSocket 关闭失败:', error)
}
ws.value = null
}
status.value = 'disconnected'
reconnectAttempts.value = 0
}
/**
* 重连
*/
const reconnect = () => {
if (!config.value || !config.value.autoReconnect) {
return
}
if (reconnectAttempts.value >= config.value.maxReconnectAttempts) {
console.error('WebSocket 重连次数超限')
status.value = 'error'
ElMessage.error('连接失败,请刷新页面重试')
return
}
status.value = 'reconnecting'
reconnectAttempts.value++
// 指数退避
const delay =
config.value.reconnectInterval * Math.pow(WS_RECONNECT_BACKOFF_BASE, reconnectAttempts.value - 1)
console.log(`WebSocket 将在 ${delay}ms 后重连(第 ${reconnectAttempts.value} 次)`)
reconnectTimer = setTimeout(() => {
connect(config.value!)
}, delay)
}
// ==================== 事件处理 ====================
/**
* 连接打开
*/
const handleOpen = async () => {
console.log('✅ WebSocket 连接成功')
status.value = 'connected'
reconnectAttempts.value = 0
// 发送登录命令
if (config.value) {
send({
cmdType: WS_CMD_TYPE.SIGN_IN,
seq: Date.now(),
client: config.value.client,
accountId: config.value.accountId,
})
}
// ⭐ 重连后增量同步遗漏消息
const now = Date.now()
if (now - lastSyncTime > 5000) {
// 断线超过 5 秒
console.log('🔄 检测到连接中断,开始增量同步...')
await syncMissedMessages()
}
// 更新最后同步时间
lastSyncTime = now
localStorage.setItem('lastSyncTime', now.toString())
// 启动心跳
startHeartbeat()
// ⭐ 启动在线状态查询(延迟启动,确保账号列表已加载)
// 延迟 2 秒,给账号列表加载时间
setTimeout(() => {
startAliveStatusQuery()
}, 2000)
}
/**
* 接收消息(使用新的 Handler 架构)
*/
const handleMessage = async (event: MessageEvent) => {
try {
const message: WebSocketMessage = JSON.parse(event.data)
console.log('WebSocket 收到消息:', message)
const { cmdType } = message
// ⭐ 使用统一的 Handler 管理器处理消息
await handlerManager.handle(cmdType, message)
// 特殊处理:心跳响应(需要清除超时定时器)
if (cmdType === WS_CMD_TYPE.HEARTBEAT || cmdType === WS_CMD_TYPE.PING) {
if (heartbeatTimeoutTimer) {
clearTimeout(heartbeatTimeoutTimer)
heartbeatTimeoutTimer = null
}
lastSyncTime = Date.now()
localStorage.setItem('lastSyncTime', lastSyncTime.toString())
}
} catch (error) {
console.error('WebSocket 消息解析失败:', error)
}
}
/**
* 连接关闭
*/
const handleClose = (event: CloseEvent) => {
console.log('WebSocket 连接关闭:', event.code, event.reason)
status.value = 'disconnected'
stopHeartbeat()
// 非正常关闭,尝试重连
if (event.code !== 1000 && config.value?.autoReconnect) {
handleReconnect()
}
}
/**
* 连接错误
*/
const handleError = (event: Event) => {
console.error('WebSocket 错误:', event)
status.value = 'error'
}
/**
* 处理重连
*/
const handleReconnect = () => {
reconnect()
}
// ==================== 消息处理 ====================
// 注:消息处理逻辑已移至 handlers/ 模块
// 具体实现见:
// - messageHandlers.ts: 聊天消息处理
// - accountHandlers.ts: 账号状态处理
// - sessionHandlers.ts: 会话相关处理
// - systemHandlers.ts: 系统通知处理
// ==================== 发送消息 ====================
/**
* 发送消息
*/
const send = (message: WebSocketMessage) => {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
console.error('WebSocket 未连接')
return false
}
try {
ws.value.send(JSON.stringify(message))
return true
} catch (error) {
console.error('WebSocket 发送消息失败:', error)
return false
}
}
/**
* 发送命令
*/
const sendCommand = (cmdType: string, data?: any) => {
// ⭐ 使用旧项目的格式:直接展开 data 到顶层,不要包裹
const message = {
cmdType,
...data, // ✅ 展开 data而不是 { data: {...} }
seq: Date.now(),
}
console.log('[WebSocket] 📤 发送命令:', cmdType, message)
const success = send(message)
if (!success) {
console.error('[WebSocket] ❌ 发送命令失败:', cmdType)
}
return success
}
// ==================== 心跳 ====================
/**
* 启动心跳
*/
const startHeartbeat = () => {
stopHeartbeat()
heartbeatTimer = setInterval(() => {
if (isConnected()) {
sendCommand(WS_CMD_TYPE.HEARTBEAT)
// ⭐ 设置心跳超时检测5秒内未收到响应则重连
heartbeatTimeoutTimer = setTimeout(() => {
console.warn('⚠️ 心跳超时,准备重连...')
ws.value?.close()
handleReconnect()
}, 5000)
}
}, config.value?.heartbeatInterval || WS_HEARTBEAT_INTERVAL)
}
/**
* 停止心跳
*/
const stopHeartbeat = () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
if (heartbeatTimeoutTimer) {
clearTimeout(heartbeatTimeoutTimer)
heartbeatTimeoutTimer = null
}
}
/**
* 停止重连
*/
const stopReconnect = () => {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
// ==================== 在线状态查询 ====================
/**
* 请求账号在线状态
*/
const requestAliveStatus = () => {
if (!isConnected()) {
console.warn('[WebSocket] ⚠️ WebSocket 未连接,跳过在线状态查询')
return
}
const accountStore = useAccountStore()
const now = Date.now()
// 限流:避免频繁请求
if (now - lastAliveStatusRequest < ALIVE_STATUS_REQUEST_INTERVAL) {
console.log('[WebSocket] ⏸️ 在线状态查询限流中,跳过本次请求')
return
}
const accountIds = accountStore.accountList.map((acc) => acc.id)
if (accountIds.length === 0) {
console.log('[WebSocket] ⚠️ 账号列表为空,跳过在线状态查询')
return
}
// 发送查询命令
const success = sendCommand('CmdRequestWechatAccountsAliveStatus', {
wechatAccountIds: accountIds,
})
if (success) {
lastAliveStatusRequest = now
console.log('[WebSocket] 📡 请求账号在线状态:', accountIds)
} else {
console.error('[WebSocket] ❌ 发送在线状态查询命令失败')
}
}
/**
* 启动在线状态查询定时器
*/
const startAliveStatusQuery = () => {
stopAliveStatusQuery()
if (!isConnected()) {
console.warn('[WebSocket] ⚠️ WebSocket 未连接,无法启动在线状态查询')
return
}
const accountStore = useAccountStore()
const accountIds = accountStore.accountList.map((acc) => acc.id)
if (accountIds.length === 0) {
console.warn('[WebSocket] ⚠️ 账号列表为空,延迟启动在线状态查询')
// 如果账号列表为空,延迟 3 秒再试
setTimeout(() => {
startAliveStatusQuery()
}, 3000)
return
}
console.log('[WebSocket] ✅ 启动在线状态查询定时器,账号数量:', accountIds.length)
// 立即请求一次
requestAliveStatus()
// 启动定时器每5秒查询一次
aliveStatusTimer = setInterval(() => {
if (isConnected()) {
requestAliveStatus()
} else {
console.warn('[WebSocket] ⚠️ WebSocket 已断开,停止在线状态查询')
stopAliveStatusQuery()
}
}, ALIVE_STATUS_REQUEST_INTERVAL)
}
/**
* 停止在线状态查询定时器
*/
const stopAliveStatusQuery = () => {
if (aliveStatusTimer) {
clearInterval(aliveStatusTimer)
aliveStatusTimer = null
console.log('[WebSocket] ⏹ 停止在线状态查询定时器')
}
}
// ==================== 增量同步(防数据丢失)====================
/**
* 增量同步遗漏消息
* 断线重连后调用,拉取断线期间的消息
*/
const syncMissedMessages = async () => {
try {
const savedLastSyncTime = localStorage.getItem('lastSyncTime')
const syncFrom = savedLastSyncTime ? parseInt(savedLastSyncTime) : lastSyncTime
console.log(`📥 开始增量同步,从 ${new Date(syncFrom).toLocaleString()} 开始`)
// TODO: 调用增量同步接口
// const missedMessages = await getMessagesSince({
// wechatAccountId: config.value?.accountId,
// since: syncFrom,
// })
// 模拟:这里需要后端提供增量同步接口
// 格式GET /api/messages/since?timestamp=xxx&accountId=xxx
// 返回:{ list: Message[], total: number }
// 处理遗漏的消息
// for (const message of missedMessages) {
// await handleNewMessage({ cmdType: WS_CMD_TYPE.RECEIVE_MESSAGE, data: message })
// }
console.log('✅ 增量同步完成')
} catch (error) {
console.error('❌ 增量同步失败:', error)
// 不阻断连接流程
}
}
// ==================== 生命周期 ====================
onUnmounted(() => {
disconnect()
})
// ==================== 初始化 ====================
/**
* 初始化:恢复最后同步时间
*/
const initLastSyncTime = () => {
const saved = localStorage.getItem('lastSyncTime')
if (saved) {
lastSyncTime = parseInt(saved)
console.log(`📅 恢复最后同步时间: ${new Date(lastSyncTime).toLocaleString()}`)
}
}
// 自动初始化
initLastSyncTime()
// ==================== 返回 ====================
return {
// 状态
ws,
status,
reconnectAttempts,
config,
// 计算属性
isConnected,
isConnecting,
isReconnecting,
// 方法
connect,
disconnect,
reconnect,
send,
sendCommand,
syncMissedMessages, // 暴露增量同步方法
// ⭐ 在线状态查询
requestAliveStatus,
startAliveStatusQuery,
stopAliveStatusQuery,
}
}

View File

@@ -1,302 +0,0 @@
/**
* 微信相关常量定义
*/
import { MessageType, AIType as AITypeEnum } from '@/types/wechat'
// ==================== 消息类型 ====================
/** 消息类型常量 */
export const MESSAGE_TYPE = {
TEXT: 1, // 文本
IMAGE: 3, // 图片
AUDIO: 34, // 语音
VIDEO: 43, // 视频
EMOJI: 47, // 表情
LOCATION: 48, // 位置
FILE: 49, // 文件
LINK: 49, // 链接
MINI_PROGRAM: 4901, // 小程序
RED_PACKET: 4902, // 红包
TRANSFER: 4903, // 转账
SYSTEM: 10000, // 系统消息
TIME_DIVIDER: -10001, // 时间分隔
RECALL: 10002, // 撤回消息
RECOMMEND_REMARK: 570425393, // 推荐备注
GROUP_INVITE: 90000, // 群邀请
} as const
/** 系统消息类型列表 */
export const SYSTEM_MESSAGE_TYPES = [
MESSAGE_TYPE.SYSTEM,
MESSAGE_TYPE.TIME_DIVIDER,
MESSAGE_TYPE.RECALL,
MESSAGE_TYPE.RECOMMEND_REMARK,
MESSAGE_TYPE.GROUP_INVITE,
]
/** 媒体消息类型列表 */
export const MEDIA_MESSAGE_TYPES = [
MESSAGE_TYPE.IMAGE,
MESSAGE_TYPE.VIDEO,
MESSAGE_TYPE.AUDIO,
MESSAGE_TYPE.FILE,
]
// ==================== AI 类型 ====================
/** AI 模式常量 */
export const AI_TYPE = {
MANUAL: 0, // 人工接待
ASSIST: 1, // AI辅助
TAKEOVER: 2, // AI接管
} as const
/** AI 模式选项 */
export const AI_TYPE_OPTIONS = [
{ value: AI_TYPE.MANUAL, label: '人工接待', icon: 'User' },
{ value: AI_TYPE.ASSIST, label: 'AI辅助', icon: 'MagicStick' },
{ value: AI_TYPE.TAKEOVER, label: 'AI接管', icon: 'Cpu' },
] as const
// ==================== 文件类型 ====================
/** 图片文件扩展名 */
export const IMAGE_EXTENSIONS = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'svg',
'ico',
] as const
/** 视频文件扩展名 */
export const VIDEO_EXTENSIONS = [
'mp4',
'avi',
'mov',
'wmv',
'flv',
'mkv',
'webm',
'3gp',
'rmvb',
'mpeg',
'mpg',
] as const
/** 音频文件扩展名 */
export const AUDIO_EXTENSIONS = [
'mp3',
'wav',
'ogg',
'aac',
'm4a',
'flac',
'wma',
'amr',
'silk',
] as const
/** 文档文件扩展名 */
export const DOCUMENT_EXTENSIONS = [
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'md',
'csv',
] as const
/** 压缩文件扩展名 */
export const ARCHIVE_EXTENSIONS = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'] as const
/** 所有支持的文件扩展名 */
export const ALL_FILE_EXTENSIONS = [
...IMAGE_EXTENSIONS,
...VIDEO_EXTENSIONS,
...AUDIO_EXTENSIONS,
...DOCUMENT_EXTENSIONS,
...ARCHIVE_EXTENSIONS,
] as const
// ==================== 文件大小限制 ====================
/** 文件大小限制MB */
export const FILE_SIZE_LIMITS = {
IMAGE: 10, // 图片 10MB
VIDEO: 100, // 视频 100MB
AUDIO: 10, // 音频 10MB
FILE: 100, // 文件 100MB
} as const
// ==================== WebSocket 命令类型 ====================
/** WebSocket 命令类型 */
export const WS_CMD_TYPE = {
// ==================== 连接相关 ====================
SIGN_IN: 'CmdSignIn', // 登录
SIGN_IN_RESP: 'CmdSignInResp', // 登录响应
HEARTBEAT: 'CmdHeartbeat', // 心跳
PING: 'CmdPing', // Ping心跳别名
// ==================== 消息发送 ====================
// ⚠️ 注意:实际发送消息时使用 'CmdSendMessage' 命令(旧项目格式)
// 通过 msgType 字段区分消息类型1=文本, 3=图片, 34=语音, 48=位置, 49=文件等)
SEND_MESSAGE: 'CmdSendMessage', // 发送消息(统一命令)
SEND_TEXT: 'CmdSendTextMsg', // 发送文本消息(未使用)
SEND_IMAGE: 'CmdSendImageMsg', // 发送图片消息(未使用)
SEND_VIDEO: 'CmdSendVideoMsg', // 发送视频消息(未使用)
SEND_AUDIO: 'CmdSendAudioMsg', // 发送语音消息(未使用)
SEND_FILE: 'CmdSendFileMsg', // 发送文件消息(未使用)
SEND_LOCATION: 'CmdSendLocationMsg', // 发送位置消息(未使用)
SEND_MESSAGE_RESP: 'CmdSendMessageResp', // 发送消息响应
SEND_MESSAGE_RESULT: 'CmdSendMessageResult', // 发送消息结果
// ==================== 消息接收 ====================
RECEIVE_MESSAGE: 'CmdReceiveMessage', // 接收消息
NEW_MESSAGE: 'CmdNewMessage', // 新消息(别名)
MESSAGE_STATUS: 'CmdMessageStatus', // 消息状态
RECALL_MESSAGE: 'CmdRecallMsg', // 撤回消息
// ==================== 会话管理 ====================
SESSION_UPDATE: 'CmdSessionUpdate', // 会话更新
SESSION_DELETE: 'CmdSessionDelete', // 会话删除
CLEAR_UNREAD: 'CmdClearUnread', // 清除未读
SYNC_MESSAGE: 'CmdSyncMessage', // 同步消息
// ==================== 账号状态 ====================
ACCOUNT_STATUS: 'CmdAccountStatus', // 账号状态
ACCOUNT_ALIVE_STATUS_RESP: 'CmdRequestWechatAccountsAliveStatusResp', // 账号在线状态响应
ACCOUNT_LIST_UPDATE: 'CmdAccountListUpdate', // 账号列表更新
// ==================== 系统通知 ====================
SYSTEM_NOTIFICATION: 'CmdSystemNotification', // 系统通知
ERROR: 'CmdError', // 错误消息
SERVER_TIME: 'CmdServerTime', // 服务器时间同步
} as const
// ==================== 时间格式 ====================
/** 时间格式常量 */
export const TIME_FORMAT = {
FULL: 'YYYY-MM-DD HH:mm:ss',
DATE: 'YYYY-MM-DD',
TIME: 'HH:mm:ss',
SHORT: 'MM-DD HH:mm',
MINUTE: 'HH:mm',
} as const
// ==================== 分页 ====================
/** 默认分页参数 */
export const DEFAULT_PAGE_SIZE = 20
/** 消息加载页大小 */
export const MESSAGE_PAGE_SIZE = 50
/** 联系人加载页大小 */
export const CONTACT_PAGE_SIZE = 100
// ==================== 防抖/节流时间 ====================
/** AI 请求防抖时间(毫秒) */
export const AI_REQUEST_DEBOUNCE = 3000
/** 消息批量处理延迟(毫秒) */
export const MESSAGE_BATCH_DELAY = 16
/** 搜索防抖时间(毫秒) */
export const SEARCH_DEBOUNCE = 300
/** 滚动节流时间(毫秒) */
export const SCROLL_THROTTLE = 100
// ==================== WebSocket 配置 ====================
/** 心跳间隔(毫秒) */
export const WS_HEARTBEAT_INTERVAL = 30000
/** 重连间隔(毫秒) */
export const WS_RECONNECT_INTERVAL = 3000
/** 最大重连次数 */
export const WS_MAX_RECONNECT_ATTEMPTS = 5
/** 重连指数退避基数 */
export const WS_RECONNECT_BACKOFF_BASE = 1.5
// ==================== 虚拟滚动 ====================
/** 虚拟滚动预渲染数量 */
export const VIRTUAL_SCROLL_OVERSCAN = 5
/** 预估消息项高度 */
export const ESTIMATED_MESSAGE_HEIGHT = 80
/** 预估系统消息高度 */
export const ESTIMATED_SYSTEM_MESSAGE_HEIGHT = 30
/** 预估时间分隔高度 */
export const ESTIMATED_TIME_DIVIDER_HEIGHT = 40
// ==================== 缓存 ====================
/** 消息缓存最大数量 */
export const MAX_CACHED_MESSAGES = 1000
/** 会话缓存最大数量 */
export const MAX_CACHED_SESSIONS = 100
/** 联系人缓存最大数量 */
export const MAX_CACHED_CONTACTS = 500
// ==================== 正则表达式 ====================
/** URL 正则 */
export const URL_REGEX = /^https?:\/\//i
/** 图片 URL 正则 */
export const IMAGE_URL_REGEX = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i
/** 视频 URL 正则 */
export const VIDEO_URL_REGEX = /\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i
/** 音频 URL 正则 */
export const AUDIO_URL_REGEX = /\.(mp3|wav|ogg|aac|m4a)$/i
/** 文件 URL 正则 */
export const FILE_URL_REGEX = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|zip|rar|7z)$/i
/** 手机号正则 */
export const PHONE_REGEX = /^1[3-9]\d{9}$/
/** 微信号正则 */
export const WECHAT_ID_REGEX = /^[a-zA-Z][-_a-zA-Z0-9]{5,19}$/
// ==================== 其他 ====================
/** 表情包路径前缀 */
export const EMOJI_PATH_PREFIX = '/assets/face/'
/** 表情包扩展名 */
export const EMOJI_EXTENSION = '.png'
/** 默认头像 */
export const DEFAULT_AVATAR = '/assets/default-avatar.png'
/** 默认群聊头像 */
export const DEFAULT_GROUP_AVATAR = '/assets/default-group-avatar.png'
/** 本地存储键前缀 */
export const STORAGE_KEY_PREFIX = 'wechat_'
/** 消息批量上传数量 */
export const MESSAGE_BATCH_UPLOAD_SIZE = 50

View File

@@ -1,21 +0,0 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
// Element Plus 全局类型
declare module 'element-plus/global'
// 环境变量类型声明
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_API_BASE_URL: string
// 更多环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -1,349 +0,0 @@
<template>
<el-container class="main-layout">
<!-- 顶部导航栏 -->
<el-header class="main-header">
<div class="header-left">
<!-- 功能切换按钮 -->
<el-button
type="primary"
:icon="Histogram"
@click="handleToggleFeature"
/>
<!-- AI配置按钮 -->
<el-button
:icon="Service"
@click="handleAIConfig"
/>
<!-- 发朋友圈按钮 -->
<el-button
:icon="Promotion"
@click="handlePostMoments"
>
发朋友圈
</el-button>
<!-- 标题 -->
<span class="header-title">{{ pageTitle }}</span>
</div>
<div class="header-right">
<el-space :size="16">
<!-- 算力显示 -->
<div class="tokens-display">
<el-icon class="tokens-icon">
<Lightning />
</el-icon>
<span class="tokens-count">{{ user?.tokens || 0 }}</span>
</div>
<!-- 通知 -->
<el-badge :value="unreadNotifications" :max="99" :hidden="unreadNotifications === 0">
<el-button :icon="Bell" circle @click="handleNotifications" />
</el-badge>
<!-- 用户信息 -->
<el-dropdown trigger="click" @command="handleUserCommand">
<div class="user-section">
<el-avatar :size="40" :src="user?.avatar">
<el-icon><User /></el-icon>
</el-avatar>
<div class="user-info">
<div class="user-name">{{ user?.username || '用户' }}</div>
<div class="user-role">高级客服专员</div>
</div>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>
<span style="font-weight: bold; color: #409eff">
{{ user?.account }}
</span>
</el-dropdown-item>
<el-dropdown-item command="settings" :icon="Setting">
系统设置
</el-dropdown-item>
<el-dropdown-item command="clearCache" :icon="Delete">
清除缓存
</el-dropdown-item>
<el-dropdown-item command="logout" :icon="SwitchButton" divided>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-space>
</div>
</el-header>
<!-- 主内容区 -->
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Histogram,
Service,
Promotion,
Lightning,
Bell,
User,
Setting,
Delete,
SwitchButton,
} from '@element-plus/icons-vue'
import { useUserStore } from '@/stores'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const { user } = userStore
// 页面标题
const pageTitle = computed(() => route.meta.title as string || '触客宝')
// 未读通知数(示例)
const unreadNotifications = computed(() => 0)
/**
* 切换功能(聊天/能力中心)
*/
const handleToggleFeature = () => {
if (route.path.startsWith('/power-center')) {
router.push('/chat')
} else {
router.push('/power-center')
}
}
/**
* AI配置
*/
const handleAIConfig = () => {
router.push('/settings')
}
/**
* 发朋友圈
*/
const handlePostMoments = () => {
router.push('/power-center/content-management')
}
/**
* 通知
*/
const handleNotifications = () => {
ElMessage.info('暂无新通知')
}
/**
* 用户菜单命令
*/
const handleUserCommand = async (command: string) => {
switch (command) {
case 'settings':
router.push('/settings')
break
case 'clearCache':
await handleClearCache()
break
case 'logout':
await handleLogout()
break
}
}
/**
* 清除缓存
*/
const handleClearCache = async () => {
try {
await ElMessageBox.confirm(
'清除缓存后需要重新登录,确定要继续吗?',
'清除缓存',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
)
const loading = ElMessage.loading('正在清除缓存...')
// 清除 localStorage
localStorage.clear()
// 清除 sessionStorage
sessionStorage.clear()
// 清除 IndexedDB如果有
if (window.indexedDB) {
const databases = await indexedDB.databases()
await Promise.all(
databases.map((db) =>
db.name ? indexedDB.deleteDatabase(db.name) : Promise.resolve(),
),
)
}
loading.close()
ElMessage.success('缓存清除成功')
// 跳转到登录页
setTimeout(() => {
router.push('/login')
}, 500)
} catch (error) {
if (error !== 'cancel') {
console.error('清除缓存失败:', error)
ElMessage.error('清除缓存失败,请稍后重试')
}
}
}
/**
* 退出登录
*/
const handleLogout = async () => {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '退出登录', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
userStore.logout()
router.push('/login')
ElMessage.success('已退出登录')
} catch (error) {
// 用户取消
}
}
</script>
<style scoped lang="scss">
.main-layout {
height: 100vh;
background: #f5f7fa;
}
.main-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
padding: 0 24px;
background: linear-gradient(135deg, var(--el-color-primary) 0%, #2563eb 50%, #4f46e5 100%);
color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.header-left {
display: flex;
align-items: center;
gap: 12px;
.header-title {
margin-left: 12px;
font-size: 18px;
font-weight: 600;
color: #fff;
}
:deep(.el-button) {
&.el-button--primary {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
&:hover {
background: rgba(255, 255, 255, 0.3);
}
}
&:not(.el-button--primary) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #fff;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
}
}
.header-right {
.tokens-display {
display: flex;
align-items: center;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.15);
border-radius: 20px;
cursor: default;
.tokens-icon {
font-size: 18px;
color: #ffd700;
margin-right: 6px;
}
.tokens-count {
font-size: 16px;
font-weight: 600;
color: #fff;
}
}
:deep(.el-button) {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #fff;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
.user-section {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 12px 4px 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 24px;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
.user-info {
.user-name {
font-size: 14px;
font-weight: 500;
color: #fff;
}
.user-role {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
}
}
}
}
.main-content {
padding: 0;
overflow: hidden;
}
</style>

View File

@@ -1,106 +0,0 @@
<template>
<div class="power-layout">
<!-- 顶部导航 -->
<div class="power-header">
<div class="header-left">
<el-button
type=""
:icon="ArrowLeft"
@click="handleBack"
>
{{ backButtonText }}
</el-button>
<div class="title-section">
<span class="title">{{ title }}</span>
<span v-if="subtitle" class="subtitle">{{ subtitle }}</span>
</div>
</div>
<div class="header-right">
<slot name="header-right" />
</div>
</div>
<!-- 内容区 -->
<div class="power-content">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue'
interface Props {
title?: string
subtitle?: string
backButtonText?: string
showBackButton?: boolean
onBackClick?: () => void
}
const props = withDefaults(defineProps<Props>(), {
title: '触客宝',
backButtonText: '返回功能中心',
showBackButton: true,
})
const router = useRouter()
/**
* 返回
*/
const handleBack = () => {
if (props.onBackClick) {
props.onBackClick()
} else {
router.back()
}
}
</script>
<style scoped lang="scss">
.power-layout {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
.power-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid var(--el-border-color-light);
.header-left {
display: flex;
align-items: center;
gap: 16px;
.title-section {
display: flex;
align-items: baseline;
gap: 12px;
.title {
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
}
}
}
}
.power-content {
flex: 1;
overflow: auto;
padding: 24px;
}
}
</style>

View File

@@ -1,6 +0,0 @@
/**
* 布局组件统一导出
*/
export { default as MainLayout } from './MainLayout.vue'
export { default as PowerLayout } from './PowerLayout.vue'

View File

@@ -1,39 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
import { useUserStore } from './stores/modules/user'
// 样式导入
import 'element-plus/dist/index.css'
import './assets/styles/global.scss'
// ==================== 创建应用实例 ====================
const app = createApp(App)
// ==================== 初始化 Pinia ====================
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
// ==================== 初始化用户信息 ====================
const userStore = useUserStore()
// 异步初始化用户信息和数据库(不阻塞应用启动)
userStore.initUser().catch((error) => {
console.error('初始化用户信息失败:', error)
})
// ==================== 配置 Element Plus ====================
app.use(ElementPlus, {
locale: zhCn,
size: 'default',
})
// ==================== 使用路由 ====================
app.use(router)
// ==================== 挂载应用 ====================
app.mount('#app')

View File

@@ -1,41 +0,0 @@
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import { ElMessage } from 'element-plus'
/**
* 设置路由守卫
*/
export function setupRouterGuard(router: Router) {
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
// 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} - 触客宝`
}
// 检查是否需要登录
if (to.meta.requiresAuth) {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录')
next({
path: '/login',
query: { redirect: to.fullPath },
})
return
}
}
// 如果已登录,访问登录页则跳转到首页
if (to.path === '/login' && userStore.isLoggedIn) {
next('/chat')
return
}
next()
})
router.afterEach(() => {
// 路由切换后的处理
})
}

View File

@@ -1,129 +0,0 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { setupRouterGuard } from './guards'
// ==================== 路由配置 ====================
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Index',
redirect: '/chat',
},
{
path: '/login',
name: 'Login',
component: () => import('@views/Login/index.vue'),
meta: {
requiresAuth: false,
title: '登录',
layout: 'blank',
},
},
{
path: '/chat',
name: 'Chat',
component: () => import('@views/Chat/index.vue'),
meta: {
requiresAuth: true,
title: '聊天',
layout: 'main',
},
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@views/Dashboard/index.vue'),
meta: {
requiresAuth: true,
title: '数据看板',
layout: 'main',
},
},
{
path: '/settings',
name: 'Settings',
component: () => import('@views/Settings/index.vue'),
meta: {
requiresAuth: true,
title: '系统设置',
layout: 'main',
},
},
{
path: '/power-center',
name: 'PowerCenter',
redirect: '/power-center/customer-management',
meta: {
requiresAuth: true,
title: '能力中心',
layout: 'main',
},
children: [
{
path: 'customer-management',
name: 'CustomerManagement',
component: () => import('@views/PowerCenter/CustomerManagement/index.vue'),
meta: {
requiresAuth: true,
title: '客户管理',
layout: 'power',
},
},
{
path: 'content-management',
name: 'ContentManagement',
component: () => import('@views/PowerCenter/ContentManagement/index.vue'),
meta: {
requiresAuth: true,
title: '内容管理',
layout: 'power',
},
},
{
path: 'data-statistics',
name: 'DataStatistics',
component: () => import('@views/PowerCenter/DataStatistics/index.vue'),
meta: {
requiresAuth: true,
title: '数据统计',
layout: 'power',
},
},
{
path: 'ai-training',
name: 'AiTraining',
component: () => import('@views/PowerCenter/AiTraining/index.vue'),
meta: {
requiresAuth: true,
title: 'AI 训练',
layout: 'power',
},
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@views/404/index.vue'),
meta: {
title: '404',
},
},
]
// ==================== 创建路由实例 ====================
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
},
})
// ==================== 设置路由守卫 ====================
setupRouterGuard(router)
export default router

View File

@@ -1,18 +0,0 @@
// Store 统一导出
export { useUserStore } from './modules/user'
export type { User } from './modules/user'
// 微信模块 Stores
export {
useAccountStore,
useContactStore,
useSessionStore,
useMessageStore,
useAIStore,
useUIStore,
} from './modules/wechat'
// 后续添加其他 Store 时在这里导出
// export { useAppStore } from './modules/app'
// export { useWebSocketStore } from './modules/websocket'

View File

@@ -1,271 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { loginWithPassword, loginWithCode } from '@/api'
import router from '@/router'
import { ElMessage } from 'element-plus'
import { databaseManager, checkBrowserSupport } from '@/utils/db'
// ==================== 类型定义 ====================
export interface User {
id: number
account: string
username: string
phone: string
avatar: string
isAdmin: number
companyId: number
typeId: number
status: number
s2_accountId: string
createTime: string
updateTime: string | null
tokens: number
lastLoginIp: string
lastLoginTime: number
}
interface LoginResponse {
token: string
member: User
kefuData?: {
self: any
token: {
access_token: string
}
}
}
// ==================== Store 定义 ====================
export const useUserStore = defineStore('user', () => {
// ==================== State ====================
const user = ref<User | null>(null)
const token = ref<string>('')
const token2 = ref<string>('') // WebSocket token
// ==================== Getters ====================
const isLoggedIn = computed(() => !!token.value && !!user.value)
const isAdmin = computed(() => user.value?.isAdmin === 1)
const userId = computed(() => user.value?.id || 0)
const accountId = computed(() => user.value?.s2_accountId || '')
// ==================== Actions ====================
/**
* 设置用户信息
*/
const setUser = (userData: User) => {
user.value = userData
}
/**
* 设置 Token
*/
const setToken = (tokenValue: string) => {
token.value = tokenValue
if (tokenValue) {
localStorage.setItem('token', tokenValue)
} else {
localStorage.removeItem('token')
}
}
/**
* 设置 Token2WebSocket token
*/
const setToken2 = (token2Value: string) => {
token2.value = token2Value
if (token2Value) {
localStorage.setItem('token2', token2Value)
} else {
localStorage.removeItem('token2')
}
}
/**
* 登录(密码登录或验证码登录)
*/
const login = async (
params: {
account: string
password?: string
verificationCode?: string
verifyCode?: string
verifySessionId?: string
typeId?: number
},
isPasswordLogin: boolean = true
) => {
try {
// ⚠️ 登录前:检查浏览器兼容性
const browserSupport = checkBrowserSupport()
if (!browserSupport.supported) {
ElMessage.warning(browserSupport.message)
// 降级:继续登录,但提示用户某些功能可能不可用
}
const loginParams = {
...params,
verifySessionId: params.verifySessionId || '',
typeId: params.typeId || 1,
}
const response = (isPasswordLogin
? await loginWithPassword(loginParams)
: await loginWithCode(loginParams)) as LoginResponse
const { member, kefuData } = response
// 清空 WebSocket 连接状态(如果需要,后续添加 WebSocket Store 时启用)
// useWebSocketStore.getState().clearConnectionState()
// 设置用户信息和 token
setToken(response.token)
setUser(member)
// 设置 token2触客宝 token
if (kefuData?.token?.access_token) {
setToken2(kefuData.token.access_token)
}
// 设置触客宝用户信息(如果需要,后续添加 CkChat Store 时启用)
// if (kefuData?.self) {
// useCkChatStore.getState().setUserInfo(kefuData.self)
// }
// ⭐ 关键:初始化数据库(一号一库)
try {
await databaseManager.ensureDatabase(member.id)
console.log('✅ 数据库初始化成功')
} catch (dbError) {
console.error('❌ 数据库初始化失败:', dbError)
ElMessage.warning('本地数据库初始化失败,部分功能可能不可用')
// 不阻断登录流程降级到纯API模式
}
ElMessage.success('登录成功')
// 跳转到首页
const redirect = router.currentRoute.value.query.redirect as string
router.push(redirect || '/chat')
} catch (error) {
console.error('登录失败:', error)
throw error
}
}
/**
* 退出登录
*
* @param skipRouterPush 是否跳过路由跳转(用于统一的 handleLoginExpired 中)
*/
const logout = async (skipRouterPush = false) => {
console.log('[UserStore] 🚪 开始退出登录')
try {
// ⭐ 1. 关闭 WebSocket 连接
try {
// 动态导入避免循环依赖
const { useWebSocket } = await import('@/composables/business/wechat')
const { disconnect } = useWebSocket()
disconnect()
console.log('[UserStore] ✅ WebSocket 已关闭')
} catch (error) {
console.error('[UserStore] ❌ 关闭 WebSocket 失败:', error)
}
// ⭐ 2. 关闭数据库连接
try {
await databaseManager.closeCurrentDatabase()
console.log('[UserStore] ✅ 数据库已关闭')
} catch (error) {
console.error('[UserStore] ❌ 关闭数据库失败:', error)
}
} catch (error) {
console.error('[UserStore] ❌ 关闭资源失败:', error)
}
// ⭐ 3. 清除本地存储
localStorage.removeItem('token')
localStorage.removeItem('token2')
localStorage.removeItem('user-store')
// ⭐ 4. 清除状态
user.value = null
token.value = ''
token2.value = ''
// ⭐ 5. 跳转到登录页(可选)
if (!skipRouterPush) {
router.push('/login')
}
console.log('[UserStore] ✅ 退出登录完成')
}
/**
* 初始化用户信息(从本地存储恢复)
*/
const initUser = async () => {
const savedToken = localStorage.getItem('token')
const savedToken2 = localStorage.getItem('token2')
const savedUser = localStorage.getItem('user-store')
if (savedToken) {
token.value = savedToken
}
if (savedToken2) {
token2.value = savedToken2
}
if (savedUser) {
try {
const parsed = JSON.parse(savedUser)
if (parsed.state?.user) {
user.value = parsed.state.user
// ⭐ 如果用户已登录,初始化数据库
if (user.value?.id) {
try {
await databaseManager.ensureDatabase(user.value.id)
console.log('✅ 数据库已初始化(从本地存储恢复)')
} catch (error) {
console.error('初始化数据库失败:', error)
// 不阻断用户使用降级到纯API模式
}
}
}
} catch (error) {
console.warn('恢复用户信息失败:', error)
}
}
}
return {
// State
user,
token,
token2,
// Getters
isLoggedIn,
isAdmin,
userId,
accountId,
// Actions
setUser,
setToken,
setToken2,
login,
logout,
initUser,
}
}, {
// 持久化配置
persist: {
key: 'user-store',
storage: localStorage,
paths: ['user', 'token', 'token2'],
},
})

View File

@@ -1,14 +0,0 @@
/**
* 微信模块 Store 统一导出
*/
export { useAccountStore } from './useAccountStore'
export { useContactStore } from './useContactStore'
export { useSessionStore } from './useSessionStore'
export { useMessageStore } from './useMessageStore'
export { useAIStore } from './useAIStore'
export { useUIStore } from './useUIStore'
export { useChatWindowStore } from './useChatWindowStore'
// 导出类型
export type * from '@/types/wechat'

View File

@@ -1,245 +0,0 @@
/**
* AI功能管理 Store
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { AIConfig, AIGenerateRequest, AIGenerateResponse, Message } from '@/types/wechat'
import { AI_TYPE } from '@/constants/wechat'
import { aiChat, dataProcessing } from '@/api'
import { nanoid } from 'nanoid'
export const useAIStore = defineStore('wechat-ai', () => {
// ==================== 状态 ====================
/** AI配置按联系人ID */
const aiConfigs = ref<Map<string, AIConfig>>(new Map())
/** 是否正在生成AI回复 */
const isGenerating = ref(false)
/** 当前生成ID用于取消 */
const currentGenerationId = ref<string | null>(null)
/** 生成的内容(流式传输时) */
const generatedContent = ref('')
/** 生成进度 */
const generationProgress = ref(0)
// ==================== 计算属性 ====================
/**
* 获取联系人的AI配置
*/
const getAIConfig = computed(() => (contactId: string) => {
return aiConfigs.value.get(contactId) || createDefaultConfig(contactId)
})
/**
* 是否启用AI
*/
const isAIEnabled = computed(() => (contactId: string) => {
const config = aiConfigs.value.get(contactId)
return config?.enabled ?? false
})
/**
* 获取AI类型
*/
const getAIType = computed(() => (contactId: string) => {
const config = aiConfigs.value.get(contactId)
return config?.type ?? AI_TYPE.MANUAL
})
// ==================== Actions ====================
/**
* 更新AI配置
*/
const updateAIConfig = async (contactId: string, updates: Partial<AIConfig>) => {
const currentConfig = aiConfigs.value.get(contactId) || createDefaultConfig(contactId)
const newConfig = { ...currentConfig, ...updates }
aiConfigs.value.set(contactId, newConfig)
return newConfig
}
/**
* 设置AI类型
*/
const setAIType = async (contactId: string, type: number) => {
await updateAIConfig(contactId, { type })
}
/**
* 启用/禁用AI
*/
const toggleAI = async (contactId: string) => {
const currentConfig = aiConfigs.value.get(contactId) || createDefaultConfig(contactId)
await updateAIConfig(contactId, { enabled: !currentConfig.enabled })
}
/**
* 生成AI回复
*/
const generateReply = async (request: AIGenerateRequest): Promise<AIGenerateResponse> => {
// 生成唯一ID
const generationId = nanoid()
currentGenerationId.value = generationId
isGenerating.value = true
generatedContent.value = ''
generationProgress.value = 0
try {
// 准备消息上下文最近10条消息
const context = request.messages.slice(-10).map((msg) => ({
role: msg.isSend ? 'assistant' : 'user',
content: msg.content,
}))
// 调用AI接口
const response = await aiChat({
messages: context,
accountId: request.accountId,
contactId: request.contactId,
customPrompt: request.customPrompt,
})
// 检查是否被取消
if (currentGenerationId.value !== generationId) {
throw new Error('生成已取消')
}
generatedContent.value = response.content
generationProgress.value = 100
return response
} catch (error) {
console.error('AI生成失败:', error)
throw error
} finally {
// 只有当前生成ID匹配时才清理
if (currentGenerationId.value === generationId) {
isGenerating.value = false
currentGenerationId.value = null
}
}
}
/**
* 手动触发AI生成
*/
const manualTriggerAI = async (
contactId: string,
accountId: number,
messages: Message[],
): Promise<string> => {
try {
const response = await generateReply({
messages,
contactId,
accountId,
})
return response.content
} catch (error) {
console.error('手动触发AI失败:', error)
throw error
}
}
/**
* 停止生成
*/
const stopGeneration = () => {
currentGenerationId.value = null
isGenerating.value = false
generatedContent.value = ''
generationProgress.value = 0
}
/**
* 数据处理用于AI学习
*/
const processData = async (data: {
accountId: number
contactId: string
messages: Message[]
}) => {
try {
await dataProcessing({
accountId: data.accountId,
contactId: data.contactId,
messages: data.messages.map((msg) => ({
content: msg.content,
msgType: msg.msgType,
isSend: msg.isSend,
timestamp: msg.timestamp,
})),
})
} catch (error) {
console.error('数据处理失败:', error)
throw error
}
}
/**
* 批量加载AI配置
*/
const loadAIConfigs = async (contactIds: string[]) => {
// TODO: 从后端批量加载AI配置
console.log('加载AI配置:', contactIds)
}
/**
* 重置状态
*/
const reset = () => {
aiConfigs.value.clear()
isGenerating.value = false
currentGenerationId.value = null
generatedContent.value = ''
generationProgress.value = 0
}
// ==================== 返回 ====================
return {
// 状态
aiConfigs,
isGenerating,
currentGenerationId,
generatedContent,
generationProgress,
// 计算属性
getAIConfig,
isAIEnabled,
getAIType,
// Actions
updateAIConfig,
setAIType,
toggleAI,
generateReply,
manualTriggerAI,
stopGeneration,
processData,
loadAIConfigs,
reset,
}
})
/**
* 创建默认AI配置
*/
function createDefaultConfig(contactId: string): AIConfig {
return {
contactId,
type: AI_TYPE.MANUAL,
enabled: false,
autoReply: false,
replyDelay: 3000,
}
}

View File

@@ -1,225 +0,0 @@
/**
* 微信账号管理 Store
*/
import { defineStore } from 'pinia'
import { ref, computed, onMounted } from 'vue'
import type { WeChatAccount } from '@/types/wechat'
import { getCustomerList } from '@/api'
import { useMessageSubscription } from '@/composables/business/wechat/useMessageSubscription'
export const useAccountStore = defineStore(
'wechat-account',
() => {
// ==================== 状态 ====================
/** 账号列表 */
const accountList = ref<WeChatAccount[]>([])
/** 当前选中的账号 */
const currentAccount = ref<WeChatAccount | null>(null)
/** 未读消息数按账号ID */
const unreadCounts = ref<Map<number, number>>(new Map())
/** 加载状态 */
const loading = ref(false)
// ==================== 订阅 WebSocket 事件 ====================
const { onAccountStatus } = useMessageSubscription()
// 订阅账号在线状态变化
onAccountStatus(({ accountId, isOnline }) => {
console.log('[AccountStore] 📡 收到在线状态变化:', { accountId, isOnline })
updateOnlineStatus(accountId, isOnline)
})
// ==================== 计算属性 ====================
/** 在线账号列表 */
const onlineAccounts = computed(() => accountList.value.filter((acc) => acc.isOnline))
/** 离线账号列表 */
const offlineAccounts = computed(() =>
accountList.value.filter((acc) => !acc.isOnline),
)
/** 总未读数 */
const totalUnreadCount = computed(() => {
return Array.from(unreadCounts.value.values()).reduce((sum, count) => sum + count, 0)
})
/** 当前账号未读数 */
const currentUnreadCount = computed(() => {
if (!currentAccount.value) return 0
return unreadCounts.value.get(currentAccount.value.id) || 0
})
// ==================== Actions ====================
/**
* 加载账号列表
*/
const loadAccounts = async () => {
loading.value = true
try {
const response = await getCustomerList()
accountList.value = response
// 如果还没有选中账号,选中第一个
if (!currentAccount.value && accountList.value.length > 0) {
currentAccount.value = accountList.value[0]
}
return accountList.value
} catch (error) {
console.error('加载账号列表失败:', error)
throw error
} finally {
loading.value = false
}
}
/**
* 切换账号
*/
const switchAccount = (accountId: number) => {
const account = accountList.value.find((acc) => acc.id === accountId)
if (account) {
currentAccount.value = account
return true
}
return false
}
/**
* 切换到全部账号(显示所有账号的消息)
*/
const switchToAllAccounts = () => {
currentAccount.value = {
id: 0,
name: '全部',
avatar: '',
isOnline: true,
}
}
/**
* 更新账号信息
*/
const updateAccount = (accountId: number, updates: Partial<WeChatAccount>) => {
const index = accountList.value.findIndex((acc) => acc.id === accountId)
if (index !== -1) {
accountList.value[index] = { ...accountList.value[index], ...updates }
// 如果更新的是当前账号,也更新当前账号
if (currentAccount.value?.id === accountId) {
currentAccount.value = accountList.value[index]
}
}
}
/**
* 设置账号未读数
*/
const setUnreadCount = (accountId: number, count: number) => {
unreadCounts.value.set(accountId, count)
}
/**
* 增加账号未读数
*/
const increaseUnreadCount = (accountId: number, delta = 1) => {
const current = unreadCounts.value.get(accountId) || 0
unreadCounts.value.set(accountId, current + delta)
}
/**
* 清除账号未读数
*/
const clearUnreadCount = (accountId: number) => {
unreadCounts.value.set(accountId, 0)
}
/**
* 获取账号未读数
*/
const getUnreadCount = (accountId: number) => {
return unreadCounts.value.get(accountId) || 0
}
/**
* 更新账号在线状态
*/
const updateOnlineStatus = (accountId: number, isOnline: boolean) => {
updateAccount(accountId, { isOnline })
}
/**
* 批量更新账号在线状态
* @param aliveStatus 在线状态对象 { accountId: isOnline }
*/
const updateAccountsOnlineStatus = (aliveStatus: Record<number, boolean>) => {
Object.entries(aliveStatus).forEach(([accountId, isOnline]) => {
updateOnlineStatus(Number(accountId), isOnline)
})
// 按在线状态排序:在线的排在前面
accountList.value.sort((a, b) => {
if (a.isOnline && !b.isOnline) return -1
if (!a.isOnline && b.isOnline) return 1
return 0
})
console.log('[AccountStore] ✅ 批量更新在线状态完成:', {
total: Object.keys(aliveStatus).length,
online: Object.values(aliveStatus).filter(Boolean).length,
})
}
/**
* 重置状态
*/
const reset = () => {
accountList.value = []
currentAccount.value = null
unreadCounts.value.clear()
loading.value = false
}
// ==================== 返回 ====================
return {
// 状态
accountList,
currentAccount,
unreadCounts,
loading,
// 计算属性
onlineAccounts,
offlineAccounts,
totalUnreadCount,
currentUnreadCount,
// Actions
loadAccounts,
switchAccount,
switchToAllAccounts,
updateAccount,
setUnreadCount,
increaseUnreadCount,
clearUnreadCount,
getUnreadCount,
updateOnlineStatus,
updateAccountsOnlineStatus,
reset,
}
},
{
persist: {
paths: ['currentAccount'],
},
},
)

View File

@@ -1,284 +0,0 @@
/**
* 聊天窗口 Store
* 管理聊天窗口的 UI 状态
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useSessionStore } from './useSessionStore'
export const useChatWindowStore = defineStore('chat-window', () => {
// ==================== UI 状态 ====================
/** 是否显示个人资料卡 */
const showProfileCard = ref(true)
/** 是否显示聊天记录搜索 */
const showChatHistory = ref(false)
/** 是否显示跟进提醒弹窗 */
const showFollowUpReminder = ref(false)
/** 是否显示待办事项弹窗 */
const showTodoList = ref(false)
// ==================== 输入框状态 ====================
/** 当前输入内容 */
const inputContent = ref('')
/** 待插入的内容(用于快捷语等功能) */
const pendingInsertContent = ref('')
/** 草稿箱按会话ID存储 */
const drafts = ref<Map<number, string>>(new Map())
// ==================== AI 配置 ====================
/** AI 模式类型按会话ID存储 */
const aiTypes = ref<Map<number, 0 | 1 | 2>>(new Map())
// ==================== 计算属性 ====================
/**
* 当前会话的 AI 类型
*/
const currentAIType = computed(() => {
const sessionStore = useSessionStore()
const sessionId = sessionStore.currentSession?.id
if (!sessionId) return 0
return aiTypes.value.get(sessionId) || 0
})
/**
* 当前会话的草稿
*/
const currentDraft = computed({
get: () => {
const sessionStore = useSessionStore()
const sessionId = sessionStore.currentSession?.id
if (!sessionId) return ''
return drafts.value.get(sessionId) || ''
},
set: (value: string) => {
const sessionStore = useSessionStore()
const sessionId = sessionStore.currentSession?.id
if (!sessionId) return
if (value) {
drafts.value.set(sessionId, value)
} else {
drafts.value.delete(sessionId)
}
},
})
// ==================== UI 操作 ====================
/**
* 切换个人资料卡显示状态
*/
const toggleProfileCard = () => {
showProfileCard.value = !showProfileCard.value
console.log(`👤 个人资料卡: ${showProfileCard.value ? '显示' : '隐藏'}`)
}
/**
* 打开聊天记录搜索
*/
const openChatHistory = () => {
showChatHistory.value = true
console.log('🔍 打开聊天记录搜索')
}
/**
* 关闭聊天记录搜索
*/
const closeChatHistory = () => {
showChatHistory.value = false
console.log('❌ 关闭聊天记录搜索')
}
/**
* 打开跟进提醒弹窗
*/
const openFollowUpReminder = () => {
showFollowUpReminder.value = true
console.log('🔔 打开跟进提醒')
}
/**
* 关闭跟进提醒弹窗
*/
const closeFollowUpReminder = () => {
showFollowUpReminder.value = false
console.log('❌ 关闭跟进提醒')
}
/**
* 打开待办事项弹窗
*/
const openTodoList = () => {
showTodoList.value = true
console.log('✅ 打开待办事项')
}
/**
* 关闭待办事项弹窗
*/
const closeTodoList = () => {
showTodoList.value = false
console.log('❌ 关闭待办事项')
}
// ==================== 输入框操作 ====================
/**
* 插入文本到输入框
*
* @param content 要插入的内容
*/
const insertText = (content: string) => {
pendingInsertContent.value = content
console.log(`📝 插入文本到输入框: ${content.substring(0, 50)}...`)
}
/**
* 清空待插入内容
*/
const clearPendingInsert = () => {
pendingInsertContent.value = ''
}
/**
* 保存草稿
*
* @param sessionId 会话ID
* @param content 草稿内容
*/
const saveDraft = (sessionId: number, content: string) => {
if (content) {
drafts.value.set(sessionId, content)
console.log(`💾 保存草稿: sessionId=${sessionId}`)
} else {
drafts.value.delete(sessionId)
}
}
/**
* 加载草稿
*
* @param sessionId 会话ID
* @returns 草稿内容
*/
const loadDraft = (sessionId: number): string => {
return drafts.value.get(sessionId) || ''
}
/**
* 清空草稿
*
* @param sessionId 会话ID
*/
const clearDraft = (sessionId: number) => {
drafts.value.delete(sessionId)
console.log(`🧹 清空草稿: sessionId=${sessionId}`)
}
// ==================== AI 配置 ====================
/**
* 更新 AI 类型
*
* @param sessionId 会话ID
* @param type AI 类型
*/
const updateAIType = async (sessionId: number, type: 0 | 1 | 2) => {
try {
// TODO: 调用 API 更新 AI 配置
// await setFriendInjectConfig({
// type,
// wechatAccountId: session.wechatAccountId,
// friendId: sessionId,
// })
aiTypes.value.set(sessionId, type)
const labels = ['人工接待', 'AI辅助', 'AI接管']
ElMessage.success(`已切换为${labels[type]}`)
console.log(`🤖 更新 AI 类型: sessionId=${sessionId}, type=${type}`)
} catch (error) {
console.error('更新 AI 配置失败:', error)
ElMessage.error('配置更新失败')
throw error
}
}
/**
* 获取 AI 类型
*
* @param sessionId 会话ID
* @returns AI 类型
*/
const getAIType = (sessionId: number): 0 | 1 | 2 => {
return aiTypes.value.get(sessionId) || 0
}
/**
* 清空所有状态(用于登出)
*/
const clearAll = () => {
showProfileCard.value = true
showChatHistory.value = false
showFollowUpReminder.value = false
showTodoList.value = false
inputContent.value = ''
drafts.value.clear()
aiTypes.value.clear()
console.log('🧹 清空聊天窗口状态')
}
// ==================== 返回 ====================
return {
// UI 状态
showProfileCard,
showChatHistory,
showFollowUpReminder,
showTodoList,
// 输入框状态
inputContent,
pendingInsertContent,
currentDraft,
// AI 配置
currentAIType,
// UI 操作
toggleProfileCard,
openChatHistory,
closeChatHistory,
openFollowUpReminder,
closeFollowUpReminder,
openTodoList,
closeTodoList,
// 输入框操作
insertText,
clearPendingInsert,
saveDraft,
loadDraft,
clearDraft,
// AI 配置
updateAIType,
getAIType,
// 其他
clearAll,
}
})

View File

@@ -1,908 +0,0 @@
/**
* 联系人管理 Store重构版 - 分组懒加载架构)
*
* 核心特性:
* ✅ 分组懒加载 - 点击展开时才加载联系人
* ✅ API搜索 - 并行请求好友和群列表
* ✅ 缓存策略 - 本地缓存提升体验
* ✅ 账号切换 - 支持多账号切换
*
* @author TouchVueThree Team
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Contact, ContactGroup, ContactType, GroupContactData } from '@/types/wechat'
import {
getContactList,
getGroupList,
getContactGroups,
getLabelsListByGroup,
updateFriendInfo,
moveGroup,
addGroup,
updateGroup,
deleteGroup,
setFriendInjectConfig as updateContactAiTypeAPI,
getFriendDetail,
getGroupDetail,
} from '@/api'
import { useAccountStore } from './useAccountStore'
import { useSessionStore } from './useSessionStore'
import { SessionManager } from '@/utils/dbManagers/SessionManager'
import { ElMessage } from 'element-plus'
// ==================== 缓存工具 ====================
const CACHE_TTL = 60 * 60 * 1000 // 1小时
/**
* 从缓存读取数据
*/
async function getFromCache<T>(key: string): Promise<T | null> {
try {
const cached = localStorage.getItem(`contact_cache_${key}`)
if (!cached) return null
const { data, timestamp } = JSON.parse(cached)
if (Date.now() - timestamp > CACHE_TTL) {
localStorage.removeItem(`contact_cache_${key}`)
return null
}
return data
} catch {
return null
}
}
/**
* 保存数据到缓存
*/
async function setToCache<T>(key: string, data: T): Promise<void> {
try {
localStorage.setItem(
`contact_cache_${key}`,
JSON.stringify({
data,
timestamp: Date.now(),
})
)
} catch (error) {
console.warn('缓存保存失败:', error)
}
}
/**
* 生成分组Key
*/
function getGroupKey(groupId: number, groupType: 1 | 2, accountId: number): string {
return `${groupId}_${groupType}_${accountId}`
}
/**
* 排序分组列表
*/
function sortGroups(groups: ContactGroup[]): ContactGroup[] {
return [...groups].sort((a, b) => {
// 默认群分组groupType=2 且 id=0始终在最前
const isDefaultGroupA = a.groupType === 2 && a.id === 0
const isDefaultGroupB = b.groupType === 2 && b.id === 0
if (isDefaultGroupA && !isDefaultGroupB) return -1
if (!isDefaultGroupA && isDefaultGroupB) return 1
// 未分组groupType=1 且 id=0始终在最后
const isUngroupedA = a.groupType === 1 && a.id === 0
const isUngroupedB = b.groupType === 1 && b.id === 0
if (isUngroupedA && !isUngroupedB) return 1
if (!isUngroupedA && isUngroupedB) return -1
// 其他按 sort 排序
return (a.sort || 0) - (b.sort || 0)
})
}
export const useContactStore = defineStore('wechat-contact', () => {
// ==================== 状态 ====================
/** 分组列表 */
const groups = ref<ContactGroup[]>([])
/** 展开的分组Set<groupKey> */
const expandedGroups = ref<Set<string>>(new Set())
/** 分组数据Map<groupKey, GroupContactData> */
const groupData = ref<Map<string, GroupContactData>>(new Map())
/** 当前选中的账号ID0表示全部 */
const selectedAccountId = ref<number>(0)
/** 搜索关键词 */
const searchKeyword = ref('')
/** 是否搜索模式 */
const isSearchMode = ref(false)
/** 搜索结果 */
const searchResults = ref<Contact[]>([])
/** 搜索加载状态 */
const searchLoading = ref(false)
/** 加载状态 */
const loading = ref(false)
// ==================== 计算属性 ====================
/** 所有联系人(从分组数据中提取) */
const allContacts = computed<Contact[]>(() => {
const contacts: Contact[] = []
groupData.value.forEach((data) => {
contacts.push(...data.contacts)
})
return contacts
})
/** 筛选后的联系人(搜索模式) */
const filteredContacts = computed(() => {
if (isSearchMode.value) {
return searchResults.value
}
return allContacts.value
})
// ==================== Actions ====================
/**
* 加载分组列表(带缓存)
*/
const loadGroups = async (accountId?: number) => {
const targetAccountId = accountId ?? selectedAccountId.value
// 1. 先尝试从缓存读取
const cacheKey = `groups_${targetAccountId}`
const cached = await getFromCache<ContactGroup[]>(cacheKey)
if (cached && cached.length > 0) {
groups.value = cached
// 后台更新
loadGroupsFromAPI(targetAccountId).catch(console.error)
return cached
}
// 2. 无缓存调用API
return await loadGroupsFromAPI(targetAccountId)
}
/**
* 从API加载分组列表
*/
const loadGroupsFromAPI = async (accountId: number) => {
try {
loading.value = true
const params: any = {
page: 1,
limit: 1000, // 分组列表通常不会太多
}
// 即便为 0 也要把 wechatAccountId 传给接口,由后端决定含义
if (accountId !== undefined) {
params.wechatAccountId = accountId
}
const response: any = await getLabelsListByGroup(params)
const list = response?.list || response?.data || response || []
// 转换为ContactGroup格式
const friendGroups: ContactGroup[] = list
.filter((item: any) => Number(item.groupType) === 1)
.map((item: any) => ({
id: item.id,
groupName: item.groupName,
groupType: 1,
count: item.count || -1, // -1表示未加载
sort: item.sort || 0,
groupMemo: item.groupMemo || '',
}))
const groupGroups: ContactGroup[] = list
.filter((item: any) => Number(item.groupType) === 2)
.map((item: any) => ({
id: item.id,
groupName: item.groupName,
groupType: 2,
count: item.count || -1,
sort: item.sort || 0,
groupMemo: item.groupMemo || '',
}))
// 补充默认分组
const extraGroups: ContactGroup[] = []
const hasDefaultGroup = groupGroups.some((g) => g.id === 0 && g.groupType === 2)
if (!hasDefaultGroup) {
extraGroups.push({
id: 0,
groupName: '默认群分组',
groupType: 2,
count: 0,
sort: -9999,
groupMemo: '',
})
}
const hasUngrouped = friendGroups.some((g) => g.id === 0 && g.groupType === 1)
if (!hasUngrouped) {
extraGroups.push({
id: 0,
groupName: '未分组',
groupType: 1,
count: 0,
sort: 999999,
groupMemo: '',
})
}
const allGroups = [...extraGroups, ...friendGroups, ...groupGroups]
const sortedGroups = sortGroups(allGroups)
groups.value = sortedGroups
// 缓存
await setToCache(`groups_${accountId}`, sortedGroups)
return sortedGroups
} catch (error) {
console.error('加载分组列表失败:', error)
ElMessage.error('加载分组列表失败')
throw error
} finally {
loading.value = false
}
}
/**
* 切换分组展开/折叠(懒加载)
*/
const toggleGroup = async (groupId: number, groupType: 1 | 2) => {
const accountId = selectedAccountId.value
const groupKey = getGroupKey(groupId, groupType, accountId)
const expanded = new Set(expandedGroups.value)
if (expanded.has(groupKey)) {
// 折叠
expanded.delete(groupKey)
expandedGroups.value = expanded
} else {
// 展开 - 懒加载
expanded.add(groupKey)
expandedGroups.value = expanded
await loadGroupContacts(groupId, groupType, 1, 50)
}
}
/**
* 加载分组联系人(懒加载,带缓存)
*/
const loadGroupContacts = async (
groupId: number,
groupType: 1 | 2,
page: number = 1,
limit: number = 50
) => {
const accountId = selectedAccountId.value
const groupKey = getGroupKey(groupId, groupType, accountId)
// 第一页时,先尝试从缓存读取
if (page === 1) {
const cacheKey = `groupContacts_${groupKey}`
const cached = await getFromCache<GroupContactData>(cacheKey)
if (cached && cached.contacts.length > 0) {
const data = new Map(groupData.value)
data.set(groupKey, { ...cached, loading: false })
groupData.value = data
// 后台更新
loadGroupContactsFromAPI(groupId, groupType, page, limit).catch(console.error)
return
}
}
// 设置loading状态保留现有联系人数据只更新loading状态
const data = new Map(groupData.value)
const existing = data.get(groupKey)
data.set(groupKey, {
contacts: existing?.contacts || [], // ⭐ 保留现有联系人,不重置
page: existing?.page || page,
pageSize: limit,
hasMore: existing?.hasMore ?? true,
loading: true, // 只更新loading状态
loaded: existing?.loaded || false,
})
groupData.value = data
// 调用API
await loadGroupContactsFromAPI(groupId, groupType, page, limit)
}
/**
* 从API加载分组联系人
*/
const loadGroupContactsFromAPI = async (
groupId: number,
groupType: 1 | 2,
page: number,
limit: number
) => {
const accountId = selectedAccountId.value
const groupKey = getGroupKey(groupId, groupType, accountId)
try {
let contacts: Contact[] = []
let total = 0
const params: any = {
page,
limit,
}
// 账号ID即便为 0 也要传
if (accountId !== undefined) {
params.wechatAccountId = accountId
}
// 分组ID即便为 0未分组/默认群分组)也要传给接口,由后端决定语义
if (groupId !== undefined) {
params.groupId = groupId
}
if (groupType === 1) {
// 好友列表API
const result: any = await getContactList(params)
const list = result?.list || result?.data || result || []
contacts = list.map((item: any) => ({
id: item.id,
type: 'friend' as ContactType,
wechatAccountId: item.wechatAccountId || accountId,
avatar: item.avatar || '',
nickname: item.nickname || '',
remark: item.conRemark || item.remark,
wxid: item.wechatId || item.wxid,
aiType: item.aiType ?? 0,
groupId: item.groupId,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
}))
total = result?.total || list.length
} else {
// 群列表
const result: any = await getGroupList(params)
const list = result?.list || result?.data || result || []
contacts = list.map((item: any) => ({
id: item.id,
type: 'group' as ContactType,
wechatAccountId: item.wechatAccountId || accountId,
avatar: item.chatroomAvatar || item.avatar || '',
nickname: item.nickname || '',
remark: item.conRemark || item.remark,
chatroomId: item.chatroomId || '',
chatroomAvatar: item.chatroomAvatar || item.avatar || '',
aiType: item.aiType ?? 0,
groupId: item.groupId,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
}))
total = result?.total || list.length
}
// 更新数据(追加模式:保留现有联系人,追加新联系人)
const data = new Map(groupData.value)
const existing = data.get(groupKey)
// ⭐ 关键:如果是第一页,替换数据;否则追加数据(确保不丢失现有数据)
let updatedContacts: Contact[]
if (page === 1) {
// 第一页:替换数据
updatedContacts = contacts
} else {
// 后续页:追加数据(保留现有联系人,追加新联系人)
const existingContacts = existing?.contacts || []
// 去重避免重复添加基于id和type
const existingIds = new Set(
existingContacts.map((c) => `${c.type}_${c.id}`)
)
const newContacts = contacts.filter(
(c) => !existingIds.has(`${c.type}_${c.id}`)
)
updatedContacts = [...existingContacts, ...newContacts]
}
const updatedData: GroupContactData = {
contacts: updatedContacts,
page,
pageSize: limit,
hasMore: contacts.length === limit, // 如果返回的数据量等于limit说明可能还有更多
loading: false,
loaded: true,
lastLoadTime: Date.now(),
}
data.set(groupKey, updatedData)
groupData.value = data
// 更新分组人数
const updatedGroups = groups.value.map((g) =>
g.id === groupId && g.groupType === groupType ? { ...g, count: total } : g
)
groups.value = updatedGroups
// 缓存第一页数据
if (page === 1) {
await setToCache(`groupContacts_${groupKey}`, updatedData)
}
} catch (error) {
console.error('加载分组联系人失败:', error)
// 恢复loading状态
const data = new Map(groupData.value)
const existing = data.get(groupKey)
if (existing) {
data.set(groupKey, { ...existing, loading: false })
groupData.value = data
}
throw error
}
}
/**
* 加载更多分组联系人
*/
const loadMoreGroupContacts = async (groupId: number, groupType: 1 | 2) => {
const accountId = selectedAccountId.value
const groupKey = getGroupKey(groupId, groupType, accountId)
const currentData = groupData.value.get(groupKey)
if (!currentData || !currentData.hasMore || currentData.loading) {
return // 没有更多数据或正在加载
}
// 加载下一页
const nextPage = currentData.page + 1
await loadGroupContacts(groupId, groupType, nextPage, currentData.pageSize)
}
/**
* 搜索联系人API搜索并行请求
*/
const searchContacts = async (keyword: string) => {
if (!keyword.trim()) {
isSearchMode.value = false
searchResults.value = []
searchKeyword.value = ''
return
}
searchKeyword.value = keyword
isSearchMode.value = true
searchLoading.value = true
try {
const accountId = selectedAccountId.value
const params: any = {
keyword,
page: 1,
limit: 100,
}
// 账号ID即便为 0 也要传
if (accountId !== undefined) {
params.wechatAccountId = accountId
}
// 并行请求好友和群列表
const [friendsResult, groupsResult] = await Promise.all([
getContactList(params),
getGroupList(params),
])
const friendsList = friendsResult?.list || friendsResult?.data || friendsResult || []
const groupsList = groupsResult?.list || groupsResult?.data || groupsResult || []
const allResults: Contact[] = [
...friendsList.map((item: any) => ({
id: item.id,
type: 'friend' as ContactType,
wechatAccountId: item.wechatAccountId || accountId,
avatar: item.avatar || '',
nickname: item.nickname || '',
remark: item.conRemark || item.remark,
wxid: item.wechatId || item.wxid,
aiType: item.aiType ?? 0,
groupId: item.groupId,
})),
...groupsList.map((item: any) => ({
id: item.id,
type: 'group' as ContactType,
wechatAccountId: item.wechatAccountId || accountId,
avatar: item.chatroomAvatar || item.avatar || '',
nickname: item.nickname || '',
remark: item.conRemark || item.remark,
chatroomId: item.chatroomId || '',
chatroomAvatar: item.chatroomAvatar || item.avatar || '',
aiType: item.aiType ?? 0,
groupId: item.groupId,
})),
]
searchResults.value = allResults
} catch (error) {
console.error('搜索联系人失败:', error)
ElMessage.error('搜索失败')
searchResults.value = []
} finally {
searchLoading.value = false
}
}
/**
* 清除搜索
*/
const clearSearch = () => {
searchKeyword.value = ''
isSearchMode.value = false
searchResults.value = []
searchLoading.value = false
}
/**
* 切换账号
* 清空所有旧账号的状态,重新加载新账号的数据
*/
const switchAccount = async (accountId: number) => {
const oldAccountId = selectedAccountId.value
// 如果切换到相同账号,不需要重置
if (oldAccountId === accountId) {
return
}
// 1. 清空所有展开状态(切换账号时应该折叠所有分组)
expandedGroups.value = new Set()
// 2. 清空所有已加载的联系人数据(切换账号时应该清空所有数据,重新加载)
groupData.value = new Map()
// 3. 清空分组列表(避免显示旧账号的分组)
groups.value = []
// 4. 清空搜索状态
searchKeyword.value = ''
isSearchMode.value = false
searchResults.value = []
searchLoading.value = false
// 5. 更新选中的账号ID
selectedAccountId.value = accountId
// 6. 重新加载新账号的分组列表
await loadGroups(accountId)
// 注意:不自动展开任何分组,让用户手动展开
}
/**
* 根据ID和类型获取联系人
*/
const getContact = (contactId: number, type: ContactType): Contact | undefined => {
for (const data of groupData.value.values()) {
const contact = data.contacts.find((c) => c.id === contactId && c.type === type)
if (contact) return contact
}
return undefined
}
/**
* 更新联系人信息
*/
const updateContact = (contactId: number, type: ContactType, updates: Partial<Contact>) => {
const accountId = selectedAccountId.value
const data = new Map(groupData.value)
for (const [groupKey, groupDataItem] of data.entries()) {
const contactIndex = groupDataItem.contacts.findIndex(
(c) => c.id === contactId && c.type === type
)
if (contactIndex !== -1) {
groupDataItem.contacts[contactIndex] = {
...groupDataItem.contacts[contactIndex],
...updates,
}
data.set(groupKey, groupDataItem)
break
}
}
groupData.value = data
// 如果当前在搜索模式,更新搜索结果
if (isSearchMode.value) {
const index = searchResults.value.findIndex((c) => c.id === contactId && c.type === type)
if (index !== -1) {
searchResults.value[index] = { ...searchResults.value[index], ...updates }
}
}
}
/**
* 更新联系人AI类型
*/
const updateContactAiType = async (
contactId: number,
type: ContactType,
accountId: number,
aiType: number
) => {
try {
await updateContactAiTypeAPI({
type: aiType,
wechatAccountId: accountId,
friendId: contactId,
})
// 更新本地数据
updateContact(contactId, type, { aiType })
return true
} catch (error) {
console.error('更新联系人AI类型失败:', error)
throw error
}
}
/**
* 修改联系人备注
*/
const updateContactRemark = async (
contactId: number,
groupId: number,
groupType: 1 | 2,
remark: string
) => {
try {
// 调用API更新备注
await updateFriendInfo({
id: contactId,
conRemark: remark,
})
// 更新内存中的数据
const accountId = selectedAccountId.value
const contactType: ContactType = groupType === 1 ? 'friend' : 'group'
updateContact(contactId, contactType, { remark })
// ⭐ 重新获取详情并同步到本地数据库(替代手动更新会话列表)
if (contactType === 'friend') {
const detail = await getFriendDetail({ id: contactId })
await SessionManager.syncSessionFromFriendDetail(contactId, detail.detail || detail)
} else {
const detail = await getGroupDetail({ groupId: contactId })
await SessionManager.syncSessionFromGroupDetail(contactId, detail)
}
ElMessage.success('修改备注成功')
} catch (error) {
console.error('修改备注失败:', error)
ElMessage.error('修改备注失败')
throw error
}
}
/**
* 移动联系人到其他分组
*/
const moveContactToGroup = async (
contactId: number,
fromGroupId: number,
toGroupId: number,
groupType: 1 | 2
) => {
try {
// 调用API移动分组
await moveGroup({
type: groupType === 1 ? 'friend' : 'chatroom',
groupId: toGroupId,
id: contactId,
})
const accountId = selectedAccountId.value
// 从原分组移除
const fromGroupKey = getGroupKey(fromGroupId, groupType, accountId)
const data = new Map(groupData.value)
const fromGroupData = data.get(fromGroupKey)
if (fromGroupData && fromGroupData.loaded) {
fromGroupData.contacts = fromGroupData.contacts.filter((c) => c.id !== contactId)
data.set(fromGroupKey, fromGroupData)
}
// 添加到新分组(如果已加载)
const toGroupKey = getGroupKey(toGroupId, groupType, accountId)
const toGroupData = data.get(toGroupKey)
if (toGroupData && toGroupData.loaded) {
// 重新加载该联系人数据
const contact = getContact(contactId, groupType === 1 ? 'friend' : 'group')
if (contact) {
toGroupData.contacts.push({ ...contact })
data.set(toGroupKey, toGroupData)
}
}
groupData.value = data
// 更新分组人数
const updatedGroups = groups.value.map((g) => {
if (g.id === fromGroupId && g.groupType === groupType) {
return { ...g, count: Math.max(0, g.count - 1) }
}
if (g.id === toGroupId && g.groupType === groupType) {
return { ...g, count: g.count + 1 }
}
return g
})
groups.value = updatedGroups
ElMessage.success('移动分组成功')
} catch (error) {
console.error('移动分组失败:', error)
ElMessage.error('移动分组失败')
throw error
}
}
/**
* 新增分组
*/
const addContactGroup = async (group: Omit<ContactGroup, 'id' | 'count'>) => {
try {
const result: any = await addGroup({
groupName: group.groupName,
groupMemo: group.groupMemo || '',
groupType: group.groupType,
sort: group.sort || 0,
})
const newGroup: ContactGroup = {
id: result?.id || result?.data?.id || 0,
groupName: result?.groupName || result?.data?.groupName || group.groupName,
groupType: (result?.groupType || result?.data?.groupType || group.groupType) as 1 | 2,
count: 0,
sort: result?.sort || result?.data?.sort || group.sort || 0,
groupMemo: result?.groupMemo || result?.data?.groupMemo || group.groupMemo,
}
const updatedGroups = [...groups.value, newGroup]
groups.value = sortGroups(updatedGroups)
ElMessage.success('新增分组成功')
return newGroup
} catch (error) {
console.error('新增分组失败:', error)
ElMessage.error('新增分组失败')
throw error
}
}
/**
* 更新分组
*/
const updateContactGroup = async (group: ContactGroup) => {
try {
await updateGroup({
id: group.id,
groupName: group.groupName,
groupMemo: group.groupMemo || '',
groupType: group.groupType,
sort: group.sort || 0,
})
const updatedGroups = groups.value.map((g) => (g.id === group.id ? group : g))
groups.value = sortGroups(updatedGroups)
ElMessage.success('编辑分组成功')
} catch (error) {
console.error('编辑分组失败:', error)
ElMessage.error('编辑分组失败')
throw error
}
}
/**
* 删除分组
*/
const deleteContactGroup = async (groupId: number, groupType: 1 | 2) => {
try {
await deleteGroup(groupId)
// 从分组列表删除
const updatedGroups = groups.value.filter(
(g) => !(g.id === groupId && g.groupType === groupType)
)
groups.value = updatedGroups
// 清理该分组的所有缓存数据
const accountId = selectedAccountId.value
const groupDataNew = new Map(groupData.value)
const expandedGroupsNew = new Set(expandedGroups.value)
// 清理所有账号的该分组数据
groupDataNew.forEach((value, key) => {
if (key.startsWith(`${groupId}_${groupType}_`)) {
groupDataNew.delete(key)
}
})
// 清理展开状态
expandedGroupsNew.forEach((key) => {
if (key.startsWith(`${groupId}_${groupType}_`)) {
expandedGroupsNew.delete(key)
}
})
groupData.value = groupDataNew
expandedGroups.value = expandedGroupsNew
ElMessage.success('删除分组成功')
} catch (error) {
console.error('删除分组失败:', error)
ElMessage.error('删除分组失败')
throw error
}
}
/**
* 重置状态
*/
const reset = () => {
groups.value = []
expandedGroups.value = new Set()
groupData.value = new Map()
selectedAccountId.value = 0
searchKeyword.value = ''
isSearchMode.value = false
searchResults.value = []
searchLoading.value = false
loading.value = false
}
// ==================== 返回 ====================
return {
// 状态
groups,
expandedGroups,
groupData,
selectedAccountId,
searchKeyword,
isSearchMode,
searchResults,
searchLoading,
loading,
// 计算属性
allContacts,
filteredContacts,
// Actions
loadGroups,
toggleGroup,
loadGroupContacts,
loadMoreGroupContacts,
searchContacts,
clearSearch,
switchAccount,
getContact,
updateContact,
updateContactAiType,
updateContactRemark,
moveContactToGroup,
addContactGroup,
updateContactGroup,
deleteContactGroup,
reset,
}
})

View File

@@ -1,602 +0,0 @@
/**
* 消息 Store
* 管理聊天消息的状态和操作
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import type { Message } from '@/types/wechat'
import { useSessionStore } from './useSessionStore'
import { getChatMessages, getChatroomMessages } from '@/api/modules/wechat'
import { setupMessageSubscription } from '@/composables/business/wechat/useMessageSubscription'
export const useMessageStore = defineStore('wechat-message', () => {
// ==================== 状态 ====================
/** 消息列表按会话ID存储 */
const messages = ref<Map<number, Message[]>>(new Map())
// ==================== WebSocket 订阅 ====================
/**
* ⭐ 设置 WebSocket 消息订阅
* 统一在 Store 中处理 WebSocket 消息事件
*/
setupMessageSubscription({
// 新消息
onNewMessage: (message: Message) => {
// console.log('[MessageStore] 收到新消息:', message)
if (message.sessionId) {
addMessage(message.sessionId, message)
}
},
// 消息更新
onMessageUpdate: ({ id, changes }) => {
console.log('[MessageStore] 消息状态更新:', { id, changes })
// ⭐ 如果 changes 中包含 sessionId直接使用
if (changes.sessionId && typeof changes.sessionId === 'number') {
const sessionId = changes.sessionId
// ⭐ 从 changes 中移除 sessionId避免写入消息对象
const { sessionId: _, ...updateData } = changes
updateMessageStatus(sessionId, id, updateData)
return
}
// ⭐ 否则遍历所有会话查找消息(兼容旧逻辑)
for (const [sessionId, sessionMessages] of messages.value.entries()) {
const message = sessionMessages.find(
(m) =>
m.id === id ||
m.clientId === id ||
(typeof id === 'number' && m.seq === id)
)
if (message) {
updateMessageStatus(sessionId, id, changes)
break
}
}
},
// 消息撤回
onMessageRecall: ({ messageId, sessionId }) => {
// console.log('[MessageStore] 消息撤回:', { messageId, sessionId })
const sessionMessages = messages.value.get(sessionId)
if (sessionMessages) {
const index = sessionMessages.findIndex((m) => m.id === messageId)
if (index !== -1) {
// 更新消息为撤回状态
sessionMessages[index] = {
...sessionMessages[index],
content: '消息已被撤回',
msgType: 10002, // 撤回消息类型
}
}
}
},
})
/** 加载状态 */
const loading = ref(false)
/** 是否还有更多消息 */
const hasMore = ref(true)
/** 当前页码 */
const currentPage = ref(1)
// ==================== AI 相关 ====================
/** AI 加载状态 */
const aiLoading = ref(false)
/** AI 回复队列 */
const aiReplyQueue = ref<string[]>([])
// ==================== 计算属性 ====================
/**
* 当前会话的消息列表
*/
const currentMessages = computed(() => {
const sessionStore = useSessionStore()
const sessionId = sessionStore.currentSession?.id
if (!sessionId) return []
return messages.value.get(sessionId) || []
})
/**
* 当前消息数量
*/
const currentMessageCount = computed(() => currentMessages.value.length)
// ==================== 消息操作 ====================
/**
* 加载消息列表
*
* @param sessionId 会话ID
* @param page 页码
* @param pageSize 每页数量
*/
const loadMessages = async (
sessionId: number,
page: number = 1,
pageSize: number = 50
) => {
const sessionStore = useSessionStore()
const session = sessionStore.sessions.find((s) => s.id === sessionId)
if (!session) {
// console.warn(`会话不存在: ${sessionId}`)
return
}
loading.value = true
try {
// 1. 从 IndexedDB 加载缓存(如果存在)
// TODO: 实现 MessageManager.getMessages
// const cached = await MessageManager.getMessages(sessionId, page, pageSize)
// if (cached.length > 0 && page === 1) {
// messages.value.set(sessionId, cached)
// }
// 2. 从 API 加载消息
const params: any = {
wechatAccountId: session.wechatAccountId,
page: page,
limit: pageSize,
}
// 根据会话类型设置参数
if (session.type === 'group') {
params.wechatChatroomId = sessionId
} else {
params.wechatFriendId = sessionId
}
// 调用 API
const apiCall =
session.type === 'group' ? getChatroomMessages : getChatMessages
const response = await apiCall(params)
// 3. 转换消息格式
let messageList: Message[] = []
if (Array.isArray(response)) {
messageList = response.map((item: any) =>
convertToMessage(item, session)
)
} else if (response && typeof response === 'object') {
const list = response.list || response.data || response.messages || []
if (Array.isArray(list)) {
messageList = list.map((item: any) => convertToMessage(item, session))
}
}
// ⭐ API 返回的数据可能是倒序(最新的在前),需要反转成升序(最早的在前)
// 参考旧项目sortMessagesByTime 按时间升序排序
// 如果 API 返回的是倒序,先反转数组
if (messageList.length > 1) {
const firstTime = new Date(messageList[0].createTime).getTime()
const lastTime = new Date(
messageList[messageList.length - 1].createTime
).getTime()
// 如果第一条消息的时间晚于最后一条,说明是倒序,需要反转
if (firstTime > lastTime) {
messageList.reverse()
}
}
// 4. 合并消息(如果是加载更多,追加;否则替换)
// ⭐ 聊天记录需要按时间升序排列(最早的在上,最新的在下)
// 参考旧项目sortMessagesByTime 按时间升序排序
if (page === 1) {
// 第一页:按时间升序排序(最早的在前)
messageList.sort((a, b) => {
const timeA = new Date(a.createTime).getTime()
const timeB = new Date(b.createTime).getTime()
return timeA - timeB // 升序:最早的在前
})
messages.value.set(sessionId, messageList)
} else {
// 加载更多:合并后按时间升序排序
const existing = messages.value.get(sessionId) || []
// 去重合并
const merged = [...existing]
messageList.forEach((newMsg) => {
const exists = merged.some(
(m) =>
m.id === newMsg.id ||
(m.clientId && newMsg.clientId && m.clientId === newMsg.clientId)
)
if (!exists) {
merged.push(newMsg)
}
})
// ⭐ 按时间升序排序(最早的在前,最新的在后)
merged.sort((a, b) => {
const timeA = new Date(a.createTime).getTime()
const timeB = new Date(b.createTime).getTime()
return timeA - timeB // 升序:最早的在前
})
messages.value.set(sessionId, merged)
}
// 5. 更新 hasMore
hasMore.value = messageList.length === pageSize
currentPage.value = page
// 6. 保存到 IndexedDB如果启用
// TODO: 实现 MessageManager.saveMessages
// await MessageManager.saveMessages(messageList)
// console.log(`📥 加载消息完成: sessionId=${sessionId}, page=${page}, count=${messageList.length}`)
} catch (error) {
console.error('加载消息失败:', error)
ElMessage.error('加载消息失败')
} finally {
loading.value = false
}
}
/**
* 转换 API 消息格式为标准 Message 格式
*/
const convertToMessage = (item: any, session: any): Message => {
return {
id: item.id || item.msgId || Date.now(),
clientId: item.clientId || item.clientMsgId,
sessionId: session.id,
wechatAccountId: session.wechatAccountId,
msgType: item.msgType || item.type || 1,
content: item.content || item.text || '',
direction: item.isSend ? 'send' : 'receive',
isSend: item.isSend || false,
createTime:
item.createTime ||
item.time ||
item.wechatTime ||
new Date().toISOString(),
timestamp:
item.timestamp ||
item.wechatTime ||
new Date(item.createTime || item.time).getTime(),
status: item.status || 'success',
sender: item.sender
? {
id: item.sender.id,
wxid: item.sender.wxid || item.senderWechatId,
nickname: item.sender.nickname || item.senderNickname,
avatar: item.sender.avatar,
}
: undefined,
}
}
/**
* 发送消息
*
* @param content 消息内容
* @param msgType 消息类型
* @param seq 消息序列号(用于匹配 CmdSendMessageResp 响应)
*/
const sendMessage = async (
content: string,
msgType: number = 1,
seq?: number
) => {
const sessionStore = useSessionStore()
const session = sessionStore.currentSession
if (!session) {
ElMessage.warning('请先选择会话')
return
}
// ⭐ 如果没有传入 seq生成一个与 MessageInput 中的保持一致)
const messageSeq = seq || Date.now()
// ⭐ 确定用于存储消息的 sessionId
// 对于群聊,使用 chatroomId转换为数字与 WebSocket 发送时一致)
// 对于好友,使用 session.id
// ⚠️ 注意chatroomId 是 string需要转换为 number
let messageSessionId: number = session.id
if (session.type === 'group' && session.chatroomId) {
// 尝试将 chatroomId 转换为数字,如果失败则使用 session.id
const chatroomIdNum = Number(session.chatroomId)
if (!isNaN(chatroomIdNum)) {
messageSessionId = chatroomIdNum
}
}
// 1. 生成临时消息
const tempMessage: Message = {
id: Date.now(),
clientId: `temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
sessionId: messageSessionId, // ⭐ 使用正确的 sessionId
wechatAccountId: session.wechatAccountId,
msgType,
content,
direction: 'send',
isSend: true,
createTime: new Date().toISOString(),
timestamp: Date.now(),
status: 'sending',
seq: messageSeq, // ⭐ 添加 seq 字段,用于匹配 CmdSendMessageResp
sender: {
nickname: '我',
},
}
// 2. 添加到消息列表(乐观更新)
const sessionMessages = messages.value.get(messageSessionId) || []
messages.value.set(messageSessionId, [...sessionMessages, tempMessage])
// 3. 消息已通过 WebSocket 发送(在 MessageInput 中调用)
// WebSocket 收到 CmdSendMessageResp 后会通过 seq 匹配并更新状态
// console.log(`📤 发送消息: ${content}`)
// ⭐ 返回 seq供调用方使用
return messageSeq
}
/**
* 添加消息到列表
* 用于接收新消息或发送消息后添加
*
* @param sessionId 会话ID
* @param message 消息对象
*/
const addMessage = (sessionId: number | string, message: Message) => {
const id = typeof sessionId === 'string' ? Number(sessionId) : sessionId
if (!id) return
const sessionMessages = messages.value.get(id) || []
// 去重检查
const exists = sessionMessages.some(
(m) =>
m.id === message.id ||
(m.clientId && message.clientId && m.clientId === message.clientId) ||
(m.id && message.id && String(m.id) === String(message.id))
)
if (!exists) {
messages.value.set(id, [...sessionMessages, message])
// console.log(`📨 添加消息到列表:`, {
// sessionId: id,
// content: message.content?.substring(0, 20),
// })
}
}
/**
* 接收消息
* WebSocket 推送时调用(兼容旧方法名)
*
* @param message 消息对象
*/
const receiveMessage = (message: Message) => {
const sessionId = message.sessionId
if (!sessionId) return
addMessage(sessionId, message)
}
/**
* 根据 seq 查找消息(仅用于发送消息的状态更新)
* 用于匹配 CmdSendMessageResp 响应
*
* @param sessionId 会话ID
* @param seq 消息序列号
* @returns 找到的消息,如果不存在返回 undefined
*/
const findMessageBySeq = (
sessionId: number,
seq: number
): Message | undefined => {
const sessionMessages = messages.value.get(sessionId)
if (!sessionMessages) return undefined
return sessionMessages.find((m) => m.seq === seq && m.direction === 'send')
}
/**
* 更新消息状态(支持通过 seq 更新)
* 用于更新发送中消息的状态(成功/失败)
*
* @param sessionId 会话ID
* @param messageId 消息ID或 clientId 或 seq
* @param updates 更新内容
*/
const updateMessageStatus = (
sessionId: number,
messageId: number | string,
updates: Partial<Message>
) => {
const sessionMessages = messages.value.get(sessionId)
if (!sessionMessages) return
const index = sessionMessages.findIndex((m) => {
if (typeof messageId === 'number') {
// ⭐ 优先匹配 seq用于发送消息响应然后匹配 id
return (
m.seq === messageId ||
m.id === messageId ||
String(m.id) === String(messageId)
)
} else {
return m.clientId === messageId || m.id === messageId
}
})
if (index !== -1) {
// ⭐ 创建新数组和新消息对象,确保 Vue 响应式系统检测到变化
const newMessages = sessionMessages.map((msg, i) => {
if (i === index) {
// ⭐ 创建新对象,确保响应式更新
return {
...msg,
...updates,
}
}
return msg
})
// ⭐ 重新设置整个数组,触发 Map 的响应式更新
messages.value.set(sessionId, newMessages)
// ⭐ 强制触发响应式更新(确保 computed 重新计算)
// 通过重新创建 Map 来触发响应式
messages.value = new Map(messages.value)
// console.log(`✅ 更新消息状态:`, {
// sessionId,
// messageId,
// oldStatus: sessionMessages[index].status,
// newStatus: updates.status,
// updates
// })
}
}
/**
* 删除消息
*
* @param sessionId 会话ID
* @param messageId 消息ID
*/
const deleteMessage = (sessionId: number, messageId: number) => {
const sessionMessages = messages.value.get(sessionId)
if (!sessionMessages) return
const filtered = sessionMessages.filter((m) => m.id !== messageId)
messages.value.set(sessionId, filtered)
// console.log(`🗑️ 删除消息: ${messageId}`)
}
/**
* 撤回消息
*
* @param sessionId 会话ID
* @param messageId 消息ID
*/
const recallMessage = async (sessionId: number, messageId: number) => {
try {
// TODO: 调用 API 撤回消息
// await recallMessageAPI({ messageId })
// 更新消息状态
const sessionMessages = messages.value.get(sessionId)
if (sessionMessages) {
const message = sessionMessages.find((m) => m.id === messageId)
if (message) {
message.recalled = true
message.content = '你撤回了一条消息'
}
}
ElMessage.success('撤回成功')
// console.log(`↩️ 撤回消息: ${messageId}`)
} catch (error) {
console.error('撤回失败:', error)
ElMessage.error('撤回失败')
}
}
/**
* 转发消息
*
* @param messageId 消息ID
* @param targetIds 目标会话ID列表
*/
const forwardMessage = async (messageId: number, targetIds: number[]) => {
try {
// TODO: 实现转发逻辑
// console.log(`📮 转发消息: ${messageId} → ${targetIds}`)
ElMessage.success('转发成功')
} catch (error) {
console.error('转发失败:', error)
ElMessage.error('转发失败')
}
}
// ==================== AI 功能 ====================
/**
* 请求 AI 回复
*
* @param content 用户输入内容
*/
const requestAIReply = async (content: string) => {
aiLoading.value = true
try {
// TODO: 调用 AI API
// const res = await getAIReply({ content, sessionId })
// aiReplyQueue.value.push(res.reply)
// console.log(`🤖 AI 回复请求: ${content}`)
} catch (error) {
console.error('AI 回复失败:', error)
ElMessage.error('AI 回复失败')
} finally {
aiLoading.value = false
}
}
/**
* 清空 AI 回复队列
*/
const clearAIQueue = () => {
aiReplyQueue.value = []
// console.log('🧹 清空 AI 队列')
}
/**
* 清空指定会话的消息
*
* @param sessionId 会话ID
*/
const clearMessages = (sessionId: number) => {
messages.value.delete(sessionId)
// console.log(`🧹 清空会话消息: ${sessionId}`)
}
// ==================== 返回 ====================
return {
// 状态
messages,
currentMessages,
currentMessageCount,
loading,
hasMore,
currentPage,
aiLoading,
aiReplyQueue,
// 消息操作
loadMessages,
sendMessage,
addMessage,
receiveMessage,
findMessageBySeq,
updateMessageStatus,
deleteMessage,
recallMessage,
forwardMessage,
// AI 功能
requestAIReply,
clearAIQueue,
// 其他
clearMessages,
}
})

View File

@@ -1,983 +0,0 @@
/**
* 会话管理 Store重构版 - 基于 IndexedDB + 订阅机制)
*
* 核心改造:
* ✅ 替换轮询 → 订阅机制SessionManager.onUpdate
* ✅ 缓存优先 → 从 IndexedDB 读取,秒开体验
* ✅ 后台同步 → 分页加载所有会话到本地
* ✅ 自动更新 → WebSocket 推送时自动刷新 UI
*
* @author TouchVueThree Team
* @date 2026-01-13
*/
import { defineStore } from 'pinia'
import { ref, computed, onUnmounted } from 'vue'
import type { ChatSession } from '@/utils/db'
import { SessionManager } from '@/utils/dbManagers/SessionManager'
import { databaseManager } from '@/utils/db'
import {
getSessionList,
clearUnread as clearUnreadAPI,
updateFriendConfig,
getFriendDetail,
getGroupDetail,
} from '@/api'
import { dataProcessing } from '@/api/modules/ai'
import { useUserStore } from '@/stores/modules/user'
import { ElMessage } from 'element-plus'
import type { Contact } from '@/types/wechat'
// ==================== 辅助类型 ====================
/** 会话接口(兼容旧类型) */
interface Session extends ChatSession {
// 兼容字段
latestMessage?: {
content: string
wechatTime: string
}
wechatTime?: number
}
export const useSessionStore = defineStore('wechat-session', () => {
// ==================== 状态 ====================
/** 会话列表 */
const sessions = ref<Session[]>([])
/** 当前选中的会话 */
const currentSession = ref<Session | null>(null)
/** 加载状态 */
const loading = ref(false)
/** 首次加载状态 */
const initialLoading = ref(true)
/** 后台同步状态(用于显示顶部同步提示) */
const syncing = ref(false)
/** 是否还有更多数据(用于滚动加载) */
const hasMore = ref(true)
/** 当前账号ID0表示全部 */
const currentAccountId = ref<number>(0)
/** 订阅取消函数 */
let unsubscribe: (() => void) | null = null
// ==================== 性能监控 ====================
/** 订阅回调触发次数(用于调试) */
const updateCallbackCount = ref(0)
/** 最后一次更新时间(用于调试) */
const lastUpdateTime = ref<number>(0)
// ==================== 计算属性 ====================
/**
* 排序后的会话列表
* ⭐ 数据库已经按 sortKey 降序排序,这里使用 sortKey 字符串比较(与数据库排序一致)
* 如果数据库未排序,这里作为备用排序
*/
const sortedSessions = computed(() => {
// 使用 sortKey 字符串比较(与数据库排序一致)
// 降序排序:置顶在前,时间新的在前,名称小的在前
return [...sessions.value].sort((a, b) => {
// 如果 sortKey 存在,使用 sortKey 排序(与数据库一致)
if (a.sortKey && b.sortKey) {
return b.sortKey.localeCompare(a.sortKey)
}
// 备用排序逻辑(如果 sortKey 不存在)
if (a.config.top && !b.config.top) return -1
if (!a.config.top && b.config.top) return 1
return (b.config.msgTime || 0) - (a.config.msgTime || 0)
})
})
/**
* 总未读数
*/
const totalUnreadCount = computed(() => {
return sessions.value.reduce((sum, session) => {
return sum + (session.config?.unreadCount || 0)
}, 0)
})
// ==================== Actions ====================
/**
* 自动选择第一个会话(如果当前没有选中的会话)
* 用于首次加载时自动打开第一个聊天
*/
const autoSelectFirstSession = () => {
// 如果已经有选中的会话,不自动选择
if (currentSession.value) {
return
}
// 获取排序后的会话列表
const sorted = sortedSessions.value
// 如果有会话,自动选择第一个
if (sorted.length > 0) {
const firstSession = sorted[0]
// console.log(
// '🎯 自动选择第一个会话:',
// firstSession.nickname || firstSession.id
// )
selectSession(firstSession)
}
}
/**
* 初始化会话 Store
*
* @param accountId 账号ID可选0表示全部
*
* 执行流程:
* 1. 确保数据库已初始化
* 2. 从 IndexedDB 读取缓存(立即显示)
* 3. 订阅数据库变更(自动更新 UI
* 4. 后台同步服务器数据(分页加载)
*/
const init = async (accountId: number = 0) => {
try {
// console.log(`📦 初始化会话 Store (账号: ${accountId || '全部'})`)
currentAccountId.value = accountId
// ⭐ 步骤0确保数据库已初始化
const userStore = useUserStore()
const userId = userStore.userId
if (!userId) {
// console.warn('⚠️ 用户未登录,无法初始化数据库')
ElMessage.warning('请先登录')
return
}
// 检查数据库是否已初始化
if (!databaseManager.isInitialized()) {
// console.log('📗 数据库未初始化,正在初始化...')
await databaseManager.ensureDatabase(userId)
// console.log('✅ 数据库初始化完成')
}
// ⭐ 步骤1从 IndexedDB 读取缓存(立即显示)
sessions.value = await SessionManager.getUserSessions(accountId)
// console.log(`✅ 缓存读取完成,共 ${sessions.value.length} 个会话`)
// ⭐ 步骤1.5:如果缓存中有数据且没有选中的会话,自动选择第一个
autoSelectFirstSession()
// ⭐ 步骤2订阅数据库变更自动更新 UI
if (unsubscribe) {
unsubscribe() // 清除旧订阅
}
// ⭐ 优化订阅回调防止频繁覆盖导致UI闪烁
let updateTimer: ReturnType<typeof setTimeout> | null = null
unsubscribe = SessionManager.onUpdate(
(updatedSessions, updatedAccountId) => {
// ⭐ 使用 currentAccountId.value 而不是闭包的 accountId确保切换账号后订阅正确
const currentId = currentAccountId.value
// 只更新当前账号的会话
if (
currentId === 0 ||
updatedAccountId === undefined ||
updatedAccountId === currentId
) {
// 性能监控
updateCallbackCount.value++
const now = Date.now()
const timeSinceLastUpdate = lastUpdateTime.value
? now - lastUpdateTime.value
: 0
lastUpdateTime.value = now
// 防抖:避免在用户操作期间频繁覆盖本地状态
if (updateTimer) {
clearTimeout(updateTimer)
// console.log(
// `⏸️ 订阅回调被防抖 (${timeSinceLastUpdate}ms since last update)`
// )
}
updateTimer = setTimeout(async () => {
// console.log(
// `🔄 会话更新 [#${updateCallbackCount.value}]:`,
// updatedSessions.length,
// 'sessions,',
// timeSinceLastUpdate > 0
// ? `${timeSinceLastUpdate}ms ago`
// : 'first update'
// )
// ⭐ 重新从数据库获取当前账号的会话,确保切换账号后订阅正确
const currentId = currentAccountId.value
sessions.value = await SessionManager.getUserSessions(currentId)
// 如果更新后还没有选中的会话,自动选择第一个
autoSelectFirstSession()
updateTimer = null
}, 100) // 100ms 防抖
}
}
)
// console.log('✅ 订阅机制已启动')
// ⭐ 步骤3后台同步服务器数据不阻塞 UI
syncFromServer(accountId)
} catch (error) {
console.error('初始化会话失败:', error)
ElMessage.error('加载会话失败,请刷新重试')
}
}
/**
* 从服务器同步会话数据
* 分页加载所有会话,保存到 IndexedDB
*
* @param accountId 账号ID
*/
const syncFromServer = async (accountId: number = 0) => {
if (loading.value) return
try {
loading.value = true
initialLoading.value = true
syncing.value = true
// console.log('📥 开始从服务器同步会话...')
// 通知 SessionManager 开始同步(防止竞态条件)
SessionManager.beginSync()
let page = 1
const limit = 200
let hasMore = true
const syncedSessions: Session[] = []
let isFirstPage = true
// 分页加载所有会话
while (hasMore) {
try {
const params: any = {
page,
limit,
}
if (accountId && accountId !== 0) {
params.wechatAccountId = accountId
}
// console.log(`📄 请求第 ${page} 页...`)
const res = await getSessionList(params)
if (!res || !res.list) {
// console.log('接口返回数据为空,停止同步')
break
}
const list: any[] = res.list || []
const total = res.total || 0
// console.log(`✅ 第 ${page} 页:${list.length} 条数据,总数: ${total}`)
// 如果返回空数据,停止同步
if (list.length === 0) {
break
}
// 转换为标准格式并保存
// ⭐ 关键:正确判断群和好友类型
const formattedSessions: Session[] = list.map((item: any) => {
// 判断类型:优先使用 type如果没有则通过 chatroomId 判断
const sessionType =
item.type || (item.chatroomId ? 'group' : 'friend')
return {
id: item.id,
serverId: `${sessionType}_${item.id}`,
type: sessionType,
wechatAccountId: item.wechatAccountId,
nickname: item.nickname,
conRemark: item.conRemark,
avatar: item.avatar,
wxid: item.wxid,
chatroomId: item.chatroomId,
content: item.content || item.latestMessage?.content || '',
lastUpdateTime: item.lastUpdateTime || new Date().toISOString(),
config: {
// ⭐ 确保服务器返回的 boolean 值正确映射
unreadCount: item.config?.unreadCount || 0,
top: Boolean(item.config?.top), // 确保是 boolean 类型
msgTime: item.config?.msgTime || Date.now(),
chat: item.config?.chat !== false,
mute: item.config?.mute || false,
},
sortKey: '', // 临时占位,稍后使用 SessionManager.generateSortKey 生成
}
})
// ⭐ 为每个会话生成正确的 sortKey
formattedSessions.forEach((session) => {
session.sortKey = SessionManager.generateSortKey(session)
})
// 批量保存到 IndexedDB
await SessionManager.syncSessions(formattedSessions)
syncedSessions.push(...formattedSessions)
// ⭐ 第一页加载完成后,立即取消 initialLoading允许渲染
if (isFirstPage && formattedSessions.length > 0) {
initialLoading.value = false
isFirstPage = false
// console.log('✅ 第一页加载完成,开始渲染列表')
// 第一页加载完成后,自动选择第一个会话
autoSelectFirstSession()
}
// 判断是否还有下一页
if (total > 0 && page * limit >= total) {
// console.log(`✅ 已加载完所有数据 (${page * limit} >= ${total})`)
hasMore = false
break
}
page++
} catch (error) {
console.error(`${page} 页同步失败:`, error)
// 单页失败不影响整体,停止同步
hasMore = false
break
}
}
// console.log(`✅ 同步完成,共同步 ${syncedSessions.length} 个会话`)
// 结束同步,应用待处理更新
await SessionManager.endSync(accountId)
// ⭐ 同步完成后,如果还没有选中的会话,自动选择第一个
// 这确保首次加载时(缓存为空)也能自动选择第一个会话
autoSelectFirstSession()
} catch (error) {
console.error('同步会话失败:', error)
ElMessage.error('同步失败,但不影响使用')
} finally {
loading.value = false
initialLoading.value = false
syncing.value = false
}
}
/**
* 手动刷新会话列表
* 用于下拉刷新等场景
*/
const refresh = async () => {
await syncFromServer(currentAccountId.value)
}
/**
* 加载更多会话(滚动加载)
* ⚠️ 注意:新架构在初始化时已加载所有会话,此方法主要用于兼容
*/
const loadMore = async () => {
// 新架构在 init() 时已分页加载所有会话
// 如果确实需要加载更多,可以调用 refresh()
// console.warn('loadMore 已废弃,新架构在初始化时已加载所有会话')
hasMore.value = false
}
/**
* 从本地加载会话列表不请求API
* 用于切换账号时快速过滤显示
*
* @param accountId 账号ID0表示全部
*/
const loadFromLocal = async (accountId: number = 0) => {
try {
// console.log(`🔄 从本地加载会话列表 (账号: ${accountId || '全部'})`)
currentAccountId.value = accountId
// ⭐ 直接从 IndexedDB 读取并过滤
sessions.value = await SessionManager.getUserSessions(accountId)
// console.log(`✅ 本地加载完成,共 ${sessions.value.length} 个会话`)
// 如果还没有选中的会话,自动选择第一个
autoSelectFirstSession()
} catch (error) {
console.error('从本地加载会话失败:', error)
ElMessage.error('加载会话失败')
}
}
/**
* 切换账号
* 只从本地过滤数据不重新请求API
*
* @param accountId 账号ID0表示全部
*/
const switchAccount = async (accountId: number) => {
// console.log(`🔄 切换账号: ${accountId}`)
// 清空当前选中的会话,让新账号自动选择第一个
currentSession.value = null
// ⭐ 只从本地加载不请求API
await loadFromLocal(accountId)
}
/**
* 选择会话
*
* @param session 会话对象
*/
const selectSession = async (session: Session) => {
currentSession.value = session
// 清除未读数
if (session.config?.unreadCount && session.config.unreadCount > 0) {
await clearUnread(session.id)
}
}
/**
* 根据联系人ID选择会话
*
* @param contactId 联系人ID
*/
const selectSessionByContact = (contactId: string) => {
const session = sessions.value.find((s) => s.id.toString() === contactId)
if (session) {
selectSession(session)
}
}
/**
* 处理搜索结果点击
*
* 流程:
* 1. 检查会话是否存在
* 2. 如果存在:移动到对应区域顶部(置顶/非置顶)
* 3. 如果不存在:获取详情并创建会话,插入到对应位置
* 4. 选中会话
*
* @param contact 搜索结果中的联系人
*/
const handleSearchResultClick = async (contact: Contact) => {
try {
const sessionId = contact.id
const sessionType = contact.type
const accountId = contact.wechatAccountId || currentAccountId.value
// 1. 检查会话是否存在
const existingSession = sessions.value.find(
(s) => s.id === sessionId && s.type === sessionType
)
if (existingSession) {
// 场景A会话已存在 - 移动到顶部
const updatedSession = await moveSessionToTop(existingSession)
// ⭐ 使用更新后的会话对象
await selectSession(updatedSession)
} else {
// 场景B会话不存在 - 获取详情并创建
const newSession = await createSessionFromContact(contact, accountId)
await selectSession(newSession)
}
} catch (error) {
console.error('处理搜索结果点击失败:', error)
ElMessage.error('打开会话失败')
throw error
}
}
/**
* 移动会话到顶部
* 根据置顶状态,移动到对应区域的第一个位置
*
* @param session 要移动的会话
* @returns 更新后的会话对象
*/
const moveSessionToTop = async (session: Session): Promise<Session> => {
try {
const now = Date.now()
// 更新会话的 msgTime 为当前时间,使其排到对应区域的顶部
const updatedSession: Session = {
...session,
config: {
...session.config,
msgTime: now, // 更新时间戳,使其排到顶部
},
sortKey: '', // 临时占位,稍后生成
lastUpdateTime: new Date().toISOString(),
}
// ⭐ 使用 SessionManager.generateSortKey 生成正确的 sortKey
updatedSession.sortKey = SessionManager.generateSortKey(updatedSession)
// 更新数据库(会触发订阅回调,自动更新 sessions.value
await SessionManager.upsertSession(updatedSession)
// ⭐ 立即更新本地状态,确保 UI 立即响应
// 注意:直接替换对象引用,确保 Vue 响应式系统检测到变化
const index = sessions.value.findIndex((s) => s.id === session.id)
if (index !== -1) {
// 直接替换对象,触发响应式更新
sessions.value[index] = updatedSession
// 强制触发数组更新Vue 3 通常会自动检测,但为了保险)
sessions.value = [...sessions.value]
} else {
// 如果没找到,直接添加(理论上不应该发生)
sessions.value.push(updatedSession)
}
// console.log(`✅ 会话 ${session.id} 已移动到顶部msgTime: ${now}`)
// 返回更新后的会话对象
return updatedSession
} catch (error) {
console.error('移动会话到顶部失败:', error)
throw error
}
}
/**
* 从联系人创建新会话
*
* @param contact 联系人信息
* @param accountId 账号ID
* @returns 新创建的会话
*/
const createSessionFromContact = async (
contact: Contact,
accountId: number
): Promise<Session> => {
try {
const sessionId = contact.id
const sessionType = contact.type
// 1. 获取联系人详情(确保数据完整)
let contactInfo: any
if (sessionType === 'friend') {
contactInfo = await getFriendDetail({ friendId: sessionId })
} else {
contactInfo = await getGroupDetail({ groupId: sessionId })
}
// 2. 构建新会话数据
const now = Date.now()
const newSession: Session = {
id: sessionId, // number 类型
serverId: `${sessionType}_${sessionId}`,
type: sessionType,
wechatAccountId: accountId,
nickname:
contactInfo.nickname ||
contactInfo.name ||
contact.nickname ||
'未知用户',
conRemark:
contactInfo.conRemark || contactInfo.remark || contact.remark,
avatar:
contactInfo.avatar ||
contact.avatar ||
contact.chatroomAvatar ||
'/default-avatar.png',
wxid: contactInfo.wxid || contact.wxid,
chatroomId:
sessionType === 'group'
? contactInfo.chatroomId || contact.chatroomId
: undefined,
content: '', // 新会话暂无消息
lastUpdateTime: new Date().toISOString(),
config: {
unreadCount: 0,
top: Boolean(
contactInfo.isTop || contactInfo.config?.top || contact.isTop
), // 从详情中获取置顶状态
msgTime: now, // 当前时间,使其排到对应区域的顶部
chat: true,
mute: false,
},
sortKey: '', // 临时占位,稍后生成
}
// ⭐ 使用 SessionManager.generateSortKey 生成正确的 sortKey
newSession.sortKey = SessionManager.generateSortKey(newSession)
// 3. 保存到数据库
await SessionManager.upsertSession(newSession)
// console.log(`✅ 新会话已创建: ${newSession.nickname || sessionId}`)
return newSession
} catch (error) {
console.error('创建会话失败:', error)
throw error
}
}
/**
* 清除未读数
*
* @param sessionId 会话ID
*/
/**
* 增加未读数
*
* @param sessionId 会话ID
* @param count 增加的未读数默认1
*/
const increaseUnreadCount = (sessionId: number, count: number = 1) => {
const session = sessions.value.find((s) => s.id === sessionId)
if (session) {
const currentUnread = session.config?.unreadCount || 0
session.config.unreadCount = currentUnread + count
// 更新数据库
SessionManager.upsertSession(session).catch((err) => {
console.error('更新未读数失败:', err)
})
// console.log(`📬 增加未读数: sessionId=${sessionId}, count=${count}, total=${session.config.unreadCount}`)
}
}
const clearUnread = async (sessionId: number) => {
try {
const session = sessions.value.find((s) => s.id === sessionId)
if (!session) return
// 调用 API 清除未读
// ⭐ 根据会话类型传递相应的参数好友ID或群ID
const params: any = {
wechatAccountId: session.wechatAccountId,
}
if (session.type === 'group' && session.chatroomId) {
// 群聊:传递 wechatChatroomId
params.wechatChatroomId = session.chatroomId
} else if (session.type === 'friend') {
// 好友:传递 wechatFriendId
params.wechatFriendId = session.id
}
await clearUnreadAPI(params)
// 更新本地数据库
await SessionManager.clearUnread(sessionId, currentAccountId.value)
// console.log(`✅ 已清除会话 ${sessionId} 的未读数`)
} catch (error) {
console.error('清除未读失败:', error)
}
}
/**
* 添加新消息到会话
* ⚠️ 不再需要手动调用WebSocket 会自动调用 SessionManager.updateOnNewMessage
*
* @deprecated 使用 SessionManager.updateOnNewMessage 代替
*/
const addMessage = (sessionId: number, content: string) => {
// console.warn('addMessage 已废弃,请使用 SessionManager.updateOnNewMessage')
const session = sessions.value.find((s) => s.id === sessionId)
if (session) {
session.content = content
session.config.msgTime = Date.now()
}
}
/**
* 置顶/取消置顶会话
*
* @param sessionId 会话ID
*/
const togglePin = async (sessionId: number) => {
try {
const session = sessions.value.find((s) => s.id === sessionId)
if (!session) {
ElMessage.error('会话不存在')
return
}
const currentPinned = session.config?.top ? 1 : 0
const newPinned = currentPinned === 1 ? 0 : 1
const now = Date.now()
// 1. 立即更新本地状态(乐观更新)
// ⭐ 关键:更新 msgTime使其移动到对应区域的顶部
const updatedSession: Session = {
...session,
config: {
...session.config,
top: newPinned === 1,
msgTime: now, // ⭐ 更新时间戳,使其移动到对应区域顶部
},
sortKey: '', // 临时占位,稍后生成
lastUpdateTime: new Date().toISOString(),
}
// ⭐ 使用 SessionManager.generateSortKey 生成正确的 sortKey
updatedSession.sortKey = SessionManager.generateSortKey(updatedSession)
// 立即更新本地状态在数据库更新之前确保UI立即响应
const index = sessions.value.findIndex((s) => s.id === sessionId)
if (index !== -1) {
sessions.value[index] = updatedSession
// 强制触发响应式更新
sessions.value = [...sessions.value]
}
// 2. 调用 dataProcessing API 更新服务器先调用API
const isGroup =
session.type === 'group' ||
('chatroomId' in session && session.chatroomId)
const params: any = {
type: 'CmdPinToTop',
wechatAccountId: String(session.wechatAccountId),
isTop: String(newPinned),
}
if (isGroup) {
params.wechatChatroomId = String(session.id)
} else {
params.wechatFriendId = String(session.id)
}
await dataProcessing(params)
// 3. 更新数据库在API调用成功后确保持久化
// ⭐ 延迟更新数据库避免立即触发订阅回调覆盖UI
setTimeout(async () => {
await SessionManager.upsertSession(updatedSession)
}, 200)
ElMessage.success(newPinned === 1 ? '置顶成功' : '取消置顶成功')
// console.log(`✅ 已切换会话 ${sessionId} 的置顶状态msgTime: ${now}`)
} catch (error) {
console.error('切换置顶失败:', error)
// 失败时回滚UI - 重新从数据库加载
const session = await SessionManager.getSession(sessionId)
if (session) {
const index = sessions.value.findIndex((s) => s.id === sessionId)
if (index !== -1) {
sessions.value[index] = session
sessions.value = [...sessions.value]
}
}
ElMessage.error('操作失败')
}
}
/**
* 更新会话备注
*
* @param sessionId 会话ID
* @param remark 备注名
*/
const updateSessionRemark = async (sessionId: number, remark: string) => {
try {
const session = sessions.value.find((s) => s.id === sessionId)
if (!session) {
throw new Error('会话不存在')
}
// TODO: 调用 API 更新备注
// if (session.type === 'friend') {
// await updateFriendConfig({
// wechatAccountId: session.wechatAccountId,
// friendId: sessionId,
// conRemark: remark,
// })
// } else if (session.type === 'group') {
// // 群聊备注更新 API如果存在
// // await updateGroupConfig({ groupId: sessionId, conRemark: remark })
// }
// 更新本地数据库
const dbSession = await SessionManager.getSession(sessionId)
if (dbSession) {
await SessionManager.upsertSession({
...dbSession,
conRemark: remark,
})
}
// console.log(`✅ 已更新会话 ${sessionId} 的备注`)
} catch (error) {
console.error('更新备注失败:', error)
throw error
}
}
/**
* 删除会话
* ⭐ 参考旧项目逻辑立即UI更新 → API调用 → 数据库删除 → 失败时恢复UI
*
* @param sessionId 会话ID
*/
const deleteSession = async (sessionId: number) => {
// ⭐ 先保存完整的 session 对象用于失败时恢复UI
const session = sessions.value.find((s) => s.id === sessionId)
if (!session) {
ElMessage.error('会话不存在')
return
}
// ⭐ 保存 session 的索引位置,用于失败时恢复到原位置
const sessionIndex = sessions.value.findIndex((s) => s.id === sessionId)
try {
// 1. 立即从UI移除乐观更新
if (sessionIndex !== -1) {
sessions.value.splice(sessionIndex, 1)
}
// 如果是当前会话,清空选中
if (currentSession.value?.id === sessionId) {
currentSession.value = null
}
// 2. 后台调用API更新服务器设置 chat: false
// ⭐ 参考旧项目:使用 updateFriendConfig传递 id 和 config
await updateFriendConfig({
id: sessionId,
config: { chat: false },
})
// 3. 后台从数据库删除
// ⭐ 参考旧项目:调用 SessionManager.deleteSession
await SessionManager.deleteSession(sessionId, currentAccountId.value)
// console.log(`✅ 已删除会话 ${sessionId}`)
ElMessage.success('删除成功')
} catch (error) {
console.error('删除会话失败:', error)
// 4. 失败时恢复UI参考旧项目逻辑
// ⭐ 恢复到原来的位置,而不是简单 push
if (sessionIndex !== -1) {
sessions.value.splice(sessionIndex, 0, session)
} else {
// 如果找不到原位置,则追加到末尾
sessions.value.push(session)
}
ElMessage.error('删除失败')
}
}
/**
* 清空会话列表
* 用于登出或切换用户
*/
const clearSessions = () => {
sessions.value = []
currentSession.value = null
currentAccountId.value = 0
// 取消订阅
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
// console.log('✅ 会话列表已清空')
}
/**
* 获取会话统计信息
*/
const getStatistics = async () => {
return await SessionManager.getStatistics(currentAccountId.value)
}
// ==================== 生命周期 ====================
/**
* 组件卸载时清理
*/
const cleanup = () => {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
// console.log('✅ 会话 Store 已清理')
}
// 自动清理(在组件使用 onUnmounted 时触发)
onUnmounted(cleanup)
// ==================== 返回 ====================
/**
* 获取性能统计信息(调试用)
*/
const getPerformanceStats = () => {
return {
updateCallbackCount: updateCallbackCount.value,
lastUpdateTime: lastUpdateTime.value,
sessionCount: sessions.value.length,
currentAccountId: currentAccountId.value,
}
}
/**
* 重置性能统计(调试用)
*/
const resetPerformanceStats = () => {
updateCallbackCount.value = 0
lastUpdateTime.value = 0
// console.log('✅ 性能统计已重置')
}
return {
// State
sessions,
sortedSessions,
currentSession,
loading,
initialLoading,
syncing,
hasMore,
totalUnreadCount,
currentAccountId,
// Actions
increaseUnreadCount,
init,
loadFromLocal,
refresh,
loadMore, // 兼容旧代码
switchAccount,
selectSession,
selectSessionByContact,
handleSearchResultClick,
moveSessionToTop,
createSessionFromContact,
clearUnread,
addMessage, // 已废弃,保留兼容性
togglePin,
updateSessionRemark,
deleteSession,
clearSessions,
getStatistics,
cleanup,
// Performance Monitoring (Debug)
getPerformanceStats,
resetPerformanceStats,
}
})

View File

@@ -1,238 +0,0 @@
/**
* UI状态管理 Store
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { SidebarTab, ModalType } from '@/types/wechat'
export const useUIStore = defineStore('wechat-ui', () => {
// ==================== 状态 ====================
/** 是否显示资料卡 */
const showProfileCard = ref(true)
/** 是否显示聊天记录搜索 */
const showChatRecordSearch = ref(false)
/** 当前活动的侧边栏标签页 */
const activeTab = ref<SidebarTab>('chats')
/** 选中的消息ID集合 */
const selectedMessages = ref<Set<string>>(new Set())
/** 是否显示消息复选框 */
const showCheckbox = ref(false)
/** 当前显示的模态框 */
const currentModal = ref<ModalType>(null)
/** 模态框数据 */
const modalData = ref<any>(null)
/** 是否正在加载 */
const loading = ref(false)
/** 加载提示文本 */
const loadingText = ref('')
// ==================== 计算属性 ====================
/** 是否有选中的消息 */
const hasSelectedMessages = computed(() => selectedMessages.value.size > 0)
/** 选中的消息数量 */
const selectedMessageCount = computed(() => selectedMessages.value.size)
/** 是否显示模态框 */
const hasModal = computed(() => currentModal.value !== null)
// ==================== Actions ====================
/**
* 切换资料卡显示状态
*/
const toggleProfileCard = () => {
showProfileCard.value = !showProfileCard.value
}
/**
* 显示资料卡
*/
const openProfileCard = () => {
showProfileCard.value = true
}
/**
* 隐藏资料卡
*/
const closeProfileCard = () => {
showProfileCard.value = false
}
/**
* 打开聊天记录搜索
*/
const openChatRecordSearch = () => {
showChatRecordSearch.value = true
}
/**
* 关闭聊天记录搜索
*/
const closeChatRecordSearch = () => {
showChatRecordSearch.value = false
}
/**
* 切换标签页
*/
const switchTab = (tab: SidebarTab) => {
activeTab.value = tab
}
/**
* 切换消息选择
*/
const toggleMessageSelection = (messageId: string) => {
if (selectedMessages.value.has(messageId)) {
selectedMessages.value.delete(messageId)
} else {
selectedMessages.value.add(messageId)
}
}
/**
* 选中所有消息
*/
const selectAllMessages = (messageIds: string[]) => {
messageIds.forEach((id) => selectedMessages.value.add(id))
}
/**
* 取消选中所有消息
*/
const deselectAllMessages = () => {
selectedMessages.value.clear()
}
/**
* 清除选择
*/
const clearSelection = () => {
selectedMessages.value.clear()
showCheckbox.value = false
}
/**
* 切换复选框显示
*/
const toggleCheckbox = () => {
showCheckbox.value = !showCheckbox.value
if (!showCheckbox.value) {
selectedMessages.value.clear()
}
}
/**
* 显示复选框
*/
const showMessageCheckbox = () => {
showCheckbox.value = true
}
/**
* 隐藏复选框
*/
const hideMessageCheckbox = () => {
showCheckbox.value = false
selectedMessages.value.clear()
}
/**
* 打开模态框
*/
const openModal = (modalType: Exclude<ModalType, null>, data?: any) => {
currentModal.value = modalType
modalData.value = data
}
/**
* 关闭模态框
*/
const closeModal = () => {
currentModal.value = null
modalData.value = null
}
/**
* 显示加载状态
*/
const showLoading = (text = '加载中...') => {
loading.value = true
loadingText.value = text
}
/**
* 隐藏加载状态
*/
const hideLoading = () => {
loading.value = false
loadingText.value = ''
}
/**
* 重置状态
*/
const reset = () => {
showProfileCard.value = true
showChatRecordSearch.value = false
activeTab.value = 'chats'
selectedMessages.value.clear()
showCheckbox.value = false
currentModal.value = null
modalData.value = null
loading.value = false
loadingText.value = ''
}
// ==================== 返回 ====================
return {
// 状态
showProfileCard,
showChatRecordSearch,
activeTab,
selectedMessages,
showCheckbox,
currentModal,
modalData,
loading,
loadingText,
// 计算属性
hasSelectedMessages,
selectedMessageCount,
hasModal,
// Actions
toggleProfileCard,
openProfileCard,
closeProfileCard,
openChatRecordSearch,
closeChatRecordSearch,
switchTab,
toggleMessageSelection,
selectAllMessages,
deselectAllMessages,
clearSelection,
toggleCheckbox,
showMessageCheckbox,
hideMessageCheckbox,
openModal,
closeModal,
showLoading,
hideLoading,
reset,
}
})

View File

@@ -1,321 +0,0 @@
/**
* 微信相关类型定义
*/
// ==================== 基础类型 ====================
/** 微信账号 */
export interface WeChatAccount {
id: number
name: string
avatar: string
isOnline: boolean
loginStatus?: number
deviceType?: string
wechatId?: string
nickname?: string
}
/** 联系人类型 */
export type ContactType = 'friend' | 'group'
/** AI 模式类型 */
export type AIType = 0 | 1 | 2 // 0-人工 1-AI辅助 2-AI接管
// ==================== 联系人 ====================
/** 联系人基础信息 */
export interface Contact {
id: number
type: ContactType
wechatAccountId: number
avatar: string
nickname: string
conRemark?: string // ⭐ 微信备注remark 字段已废弃
wxid?: string
chatroomId?: string
chatroomAvatar?: string
aiType?: AIType
labels?: string[]
isTop?: boolean
groupId?: number // 所属分组ID
createdAt?: number
updatedAt?: number
}
/** 群聊信息 */
export interface Group extends Contact {
type: 'group'
chatroomId: string
chatroomAvatar: string
memberCount?: number
members?: GroupMember[]
}
/** 好友信息 */
export interface Friend extends Contact {
type: 'friend'
wxid: string
}
/** 群成员 */
export interface GroupMember {
id: number
chatroomId: string
wxid: string
nickname: string
avatar?: string
displayName?: string
}
/** 联系人分组 */
export interface ContactGroup {
id: number
groupName: string
groupType: 1 | 2 // 1-好友分组, 2-群分组
count: number
sort: number
groupMemo?: string
contacts?: Contact[]
}
/** 分组联系人数据 */
export interface GroupContactData {
contacts: Contact[]
page: number
pageSize: number
hasMore: boolean
loading: boolean
loaded: boolean
lastLoadTime?: number
}
// ==================== 会话 ====================
/** 会话信息 */
export interface Session {
id: string // 联系人ID
type: ContactType
wechatAccountId: number
contact: Contact
lastMessage?: Message
lastMessageTime?: number
unreadCount: number
isTop: boolean
isMuted: boolean
aiType?: AIType
createdAt: number
updatedAt: number
}
// ==================== 消息 ====================
/** 消息类型枚举 */
export enum MessageType {
TEXT = 1, // 文本
IMAGE = 3, // 图片
AUDIO = 34, // 语音
VIDEO = 43, // 视频
EMOJI = 47, // 表情
LOCATION = 48, // 位置
FILE = 49, // 文件
LINK = 49, // 链接也是49
MINI_PROGRAM = 4901, // 小程序
RED_PACKET = 4902, // 红包
TRANSFER = 4903, // 转账
SYSTEM = 10000, // 系统消息
TIME_DIVIDER = -10001, // 时间分隔
RECALL = 10002, // 撤回消息
RECOMMEND_REMARK = 570425393, // 推荐备注
GROUP_INVITE = 90000, // 群邀请
}
/** 消息发送状态 */
export enum MessageStatus {
SENDING = 'sending', // 发送中
SUCCESS = 'success', // 发送成功
FAILED = 'failed', // 发送失败
RECALLED = 'recalled', // 已撤回
}
/** 消息基础信息 */
export interface Message {
id: number | string
clientId?: string // 客户端临时ID
seq?: number // ⭐ 消息序列号(仅发送消息有,用于匹配 CmdSendMessageResp
sessionId?: number
wechatAccountId?: number
msgType: number
content: string
direction: 'send' | 'receive' // 消息方向
isSend?: boolean // 是否是自己发送(兼容旧字段)
sender?: {
id?: string
wxid?: string
nickname: string
avatar?: string
}
createTime: string // 创建时间
timestamp?: number // 时间戳(兼容)
status?: 'sending' | 'success' | 'failed' // 发送状态
serverId?: string // 服务器返回的真实ID
recalled?: boolean // 是否已撤回
recalledBy?: string
replyTo?: string // 回复的消息ID
extra?: Record<string, any>
}
/** 文件消息内容 */
export interface FileMessageContent {
type: 'file'
url: string
name: string
size: number
ext?: string
isDownloading?: boolean
}
/** 图片消息内容 */
export interface ImageMessageContent {
type: 'image'
url: string
thumbUrl?: string
width?: number
height?: number
}
/** 视频消息内容 */
export interface VideoMessageContent {
type: 'video'
url: string
thumbUrl?: string
duration?: number
width?: number
height?: number
}
/** 语音消息内容 */
export interface AudioMessageContent {
type: 'audio'
url: string
duration: number
isPlaying?: boolean
text?: string // 语音转文字
}
/** 位置消息内容 */
export interface LocationMessageContent {
type: 'location'
label: string
lat: number
lng: number
poiName?: string
}
/** 消息分组(按时间) */
export interface MessageGroup {
time: string
messages: Message[]
}
// ==================== AI ====================
/** AI 配置 */
export interface AIConfig {
contactId: string
type: AIType
enabled: boolean
autoReply?: boolean
replyDelay?: number
customPrompt?: string
}
/** AI 生成请求 */
export interface AIGenerateRequest {
messages: Message[]
contactId: string
accountId: number
customPrompt?: string
}
/** AI 生成响应 */
export interface AIGenerateResponse {
content: string
confidence?: number
suggestions?: string[]
}
// ==================== WebSocket ====================
/** WebSocket 消息类型 */
export interface WebSocketMessage {
cmdType: string
seq?: number
wechatAccountIds?: number[]
content?: any
data?: any
[key: string]: any
}
/** WebSocket 连接状态 */
export enum WebSocketStatus {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
ERROR = 'error',
}
/** WebSocket 配置 */
export interface WebSocketConfig {
url: string
client: string
accountId: number
accessToken: string
autoReconnect: boolean
cmdType: string
seq: number
reconnectInterval: number
maxReconnectAttempts: number
heartbeatInterval: number
}
// ==================== UI 相关 ====================
/** 侧边栏标签页 */
export type SidebarTab = 'chats' | 'contacts' | 'moments'
/** 模态框类型 */
export type ModalType =
| 'add-friend'
| 'create-group'
| 'followup-reminder'
| 'todo-list'
| 'chat-record-search'
| 'profile-card'
| 'forward-message'
| null
// ==================== API 响应 ====================
/** 通用API响应 */
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
/** 分页参数 */
export interface PageParams {
pageNum: number
pageSize: number
}
/** 分页响应 */
export interface PageResponse<T> {
list: T[]
total: number
pageNum: number
pageSize: number
hasMore: boolean
}

View File

@@ -1,113 +0,0 @@
/**
* 认证工具函数
*
* 提供统一的登录过期处理和退出登录逻辑
*
* @author TouchVueThree Team
* @date 2026-01-16
*/
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/modules/user'
// 防止重复处理标志位
let isHandlingLogout = false
/**
* 统一的登录过期处理401 错误)
*
* 执行流程:
* 1. 关闭 WebSocket 连接
* 2. 关闭 IndexedDB 数据库
* 3. 清除登录缓存token、用户信息等
* 4. 显示提示消息
* 5. 跳转到登录页(保存当前路径用于重新登录后跳转)
*
* ⚠️ 注意:使用防重复处理机制,避免多个 401 错误同时触发
*/
export async function handleLoginExpired() {
// 防止重复处理
if (isHandlingLogout) {
console.warn('[Auth] 正在处理登录过期,跳过重复调用')
return
}
isHandlingLogout = true
console.log('[Auth] 🔐 开始处理登录过期')
try {
const userStore = useUserStore()
// 保存当前路径(用于重新登录后跳转)
const currentPath = window.location.pathname + window.location.search
// 调用 logout 方法(会关闭 WebSocket 和数据库)
// skipRouterPush = true避免重复跳转
await userStore.logout(true)
// 显示提示
ElMessage.warning('登录已过期,请重新登录')
// 延迟跳转(避免与其他操作冲突)
setTimeout(() => {
// 只有当前不在登录页时才跳转
if (router.currentRoute.value.path !== '/login') {
router.push({
path: '/login',
query: { redirect: currentPath },
})
}
// 重置标志位
isHandlingLogout = false
}, 500)
} catch (error) {
console.error('[Auth] ❌ 处理登录过期失败:', error)
// 确保重置标志位
isHandlingLogout = false
// 确保跳转到登录页
if (router.currentRoute.value.path !== '/login') {
router.push('/login')
}
}
}
/**
* 手动退出登录
*
* 用于用户主动点击"退出登录"按钮
*/
export async function handleManualLogout() {
try {
const userStore = useUserStore()
// 调用 logout 方法(会关闭 WebSocket 和数据库,并跳转到登录页)
await userStore.logout()
ElMessage.success('已退出登录')
} catch (error) {
console.error('[Auth] ❌ 退出登录失败:', error)
ElMessage.error('退出登录失败,请稍后重试')
}
}
/**
* 检查登录状态
*
* @returns 是否已登录
*/
export function isLoggedIn(): boolean {
const userStore = useUserStore()
return userStore.isLoggedIn
}
/**
* 获取当前用户信息
*/
export function getCurrentUser() {
const userStore = useUserStore()
return userStore.user
}

View File

@@ -1,503 +0,0 @@
/**
* 数据库管理器 - 多账户隔离的 IndexedDB 管理
*
* 核心特性:
* - 一号一库:每个账户独立数据库,彻底隔离
* - 自动切换:账户切换时自动切换数据库
* - 防泄漏:旧数据库自动关闭
*
* @author TouchVueThree Team
* @date 2026-01-13
*/
import Dexie, { Table } from 'dexie'
const DB_NAME_PREFIX = 'TouchChatDB'
const DB_VERSION = 1
// ==================== 数据表接口 ====================
/**
* 会话表
* 存储:会话列表、最新消息、未读数等
*/
export interface ChatSession {
// 主键和标识
id: number // 主键好友ID或群ID
serverId: string // 唯一标识: friend_123 / group_456
type: 'friend' | 'group' // 类型
wechatAccountId: number // 所属客服账号
// 联系人信息
nickname: string
conRemark?: string // 备注名
avatar: string
wxid?: string // 微信ID
chatroomId?: string // 群聊ID
// 消息信息
content: string // 最新消息内容
lastUpdateTime: string // 最后更新时间 ISO8601
// 配置
config: {
unreadCount: number // 未读数
top: boolean // 置顶
msgTime: number // 消息时间戳
chat?: boolean // 是否开启聊天
mute?: boolean // 是否免打扰
}
// 索引字段
sortKey: string // 排序键 (msgTime_id)
}
/**
* 消息表(可选)
* 存储:聊天记录,限制数量和时间
*/
export interface ChatMessage {
// 主键和标识
id: number // 主键消息ID
clientId: string // 客户端临时ID (nanoid)
serverId?: string // 服务器消息ID
sessionId: number // 所属会话
sessionType: 'friend' | 'group'
wechatAccountId: number
// 消息内容
content: string
msgType: number // 1=文本, 3=图片, 34=语音...
direction: 'send' | 'receive'
// 发送者信息
sender?: {
id: string
wxid: string
nickname: string
avatar?: string
}
// 时间戳
createTime: string // ISO8601
wechatTime: number // 微信时间戳(毫秒)
// 状态
status: 'sending' | 'success' | 'failed'
isRead?: boolean
isRecalled?: boolean
// 序列号(保证消息顺序)
sequence?: number
// 扩展信息
extra?: string // JSON 字符串
}
/**
* 联系人表
* 存储:好友和群聊列表
*/
export interface Contact {
// 主键和标识
id: number
serverId: string
type: 'friend' | 'group'
wechatAccountId: number
// 基本信息
nickname: string
conRemark?: string
avatar: string
wxid?: string
chatroomId?: string
// 搜索字段
searchKey: string // 拼音首字母等,用于搜索
// 时间戳
lastUpdateTime: string
}
// ==================== 数据库类 ====================
/**
* 聊天数据库类
* 使用 Dexie 封装 IndexedDB
*/
class ChatDatabase extends Dexie {
sessions!: Table<ChatSession, number>
messages!: Table<ChatMessage, number>
contacts!: Table<Contact, number>
constructor(dbName: string) {
super(dbName)
// 定义数据表和索引
this.version(DB_VERSION).stores({
// 会话表索引 - 添加 sortKey 索引用于数据库层面排序
sessions: 'id, serverId, wechatAccountId, type, lastUpdateTime, sortKey, [wechatAccountId+sortKey]',
// 消息表索引
messages: 'id, clientId, serverId, sessionId, [sessionId+createTime], [sessionId+sequence], createTime, wechatAccountId',
// 联系人表索引
contacts: 'id, serverId, wechatAccountId, type, searchKey',
})
}
}
// ==================== 数据库管理器 ====================
/**
* 数据库管理器类
* 负责管理多个数据库实例(一号一库)
*/
class DatabaseManager {
/** 当前数据库实例 */
private currentDb: ChatDatabase | null = null
/** 当前用户ID */
private currentUserId: number | null = null
/**
* 获取数据库名称
*/
private getDatabaseName(userId: number): string {
return `${DB_NAME_PREFIX}_${userId}`
}
/**
* 打开数据库
*/
private async openDatabase(dbName: string): Promise<ChatDatabase> {
try {
const instance = new ChatDatabase(dbName)
await instance.open()
console.log(`📗 数据库已打开: ${dbName}`)
return instance
} catch (error: any) {
// 数据库损坏检测
if (
error.name === 'DatabaseClosedError' ||
error.name === 'InvalidStateError' ||
error.message?.includes('corrupt')
) {
console.error('⚠️ 数据库已损坏,尝试修复...', error)
try {
// 删除损坏的数据库
await Dexie.delete(dbName)
console.log('已删除损坏的数据库')
// 重新创建
const instance = new ChatDatabase(dbName)
await instance.open()
console.log('✅ 数据库已修复')
// TODO: 这里可以添加 Sentry 上报
// Sentry.captureException(new Error('IndexedDB 损坏'), {
// extra: { dbName, originalError: error }
// })
return instance
} catch (retryError) {
console.error('❌ 数据库修复失败:', retryError)
throw retryError
}
}
throw error
}
}
/**
* 确保数据库已初始化
* 如果数据库已存在且用户ID匹配直接返回
* 否则关闭旧数据库,打开新数据库
*
* @param userId 用户ID
* @returns 数据库实例
*/
async ensureDatabase(userId: number): Promise<ChatDatabase> {
if (!userId) {
throw new Error('Invalid userId: userId cannot be empty or 0')
}
// 如果当前数据库已打开且用户ID匹配直接返回
if (
this.currentDb &&
this.currentUserId === userId &&
this.currentDb.isOpen()
) {
return this.currentDb
}
// 关闭旧数据库
await this.closeCurrentDatabase()
// 打开新数据库
const dbName = this.getDatabaseName(userId)
this.currentDb = await this.openDatabase(dbName)
this.currentUserId = userId
console.log(`✅ 已切换到用户 ${userId} 的数据库: ${dbName}`)
return this.currentDb
}
/**
* 获取当前数据库
* 如果数据库未初始化,抛出异常
*
* @returns 数据库实例
*/
getCurrentDatabase(): ChatDatabase {
if (!this.currentDb || !this.currentDb.isOpen()) {
throw new Error('Database not initialized. Please call ensureDatabase() first.')
}
return this.currentDb
}
/**
* 获取当前用户ID
*/
getCurrentUserId(): number | null {
return this.currentUserId
}
/**
* 检查数据库是否已初始化
*/
isInitialized(): boolean {
return !!this.currentDb && this.currentDb.isOpen()
}
/**
* 关闭当前数据库
*/
async closeCurrentDatabase(): Promise<void> {
if (this.currentDb) {
try {
this.currentDb.close()
console.log(`📕 已关闭用户 ${this.currentUserId} 的数据库`)
} catch (error) {
console.warn('关闭数据库失败:', error)
}
this.currentDb = null
this.currentUserId = null
}
}
/**
* 删除用户数据库
* 用于登出或清除数据
*
* @param userId 用户ID
*/
async deleteUserDatabase(userId: number): Promise<void> {
const dbName = this.getDatabaseName(userId)
// 如果是当前数据库,先关闭
if (this.currentUserId === userId) {
await this.closeCurrentDatabase()
}
try {
await Dexie.delete(dbName)
console.log(`🗑️ 已删除用户 ${userId} 的数据库: ${dbName}`)
} catch (error) {
console.error('删除数据库失败:', error)
throw error
}
}
/**
* 列出所有用户数据库
*
* @returns 用户ID列表
*/
async listUserDatabases(): Promise<number[]> {
try {
const databases = await Dexie.getDatabaseNames()
const userIds: number[] = []
databases.forEach((dbName) => {
if (dbName.startsWith(DB_NAME_PREFIX)) {
const userId = parseInt(dbName.replace(`${DB_NAME_PREFIX}_`, ''))
if (!isNaN(userId)) {
userIds.push(userId)
}
}
})
return userIds
} catch (error) {
console.error('列出数据库失败:', error)
return []
}
}
/**
* 检查存储配额
* 返回配额使用情况和是否可以继续写入
*/
async checkStorageQuota(): Promise<{
usage: number
quota: number
usagePercent: number
canWrite: boolean
}> {
if ('storage' in navigator && 'estimate' in navigator.storage) {
try {
const estimate = await navigator.storage.estimate()
const usage = estimate.usage || 0
const quota = estimate.quota || 0
const usagePercent = quota > 0 ? (usage / quota) * 100 : 0
console.log(
`💾 存储使用情况: ${(usage / 1024 / 1024).toFixed(2)} MB / ${(quota / 1024 / 1024).toFixed(2)} MB (${usagePercent.toFixed(1)}%)`
)
// 超过 95% 禁止写入
const canWrite = usagePercent < 95
return {
usage,
quota,
usagePercent,
canWrite,
}
} catch (error) {
console.error('检查存储配额失败:', error)
}
}
// 不支持配额检测的浏览器,默认允许写入
return {
usage: 0,
quota: 0,
usagePercent: 0,
canWrite: true,
}
}
/**
* 清理旧数据
* 删除 30 天前的消息和孤儿消息
*/
async cleanOldData(): Promise<void> {
if (!this.isInitialized()) {
console.warn('数据库未初始化,无法清理数据')
return
}
try {
const db = this.getCurrentDatabase()
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000
console.log('开始清理旧数据...')
// 1. 删除 30 天前的消息
const deletedCount = await db.messages
.where('wechatTime')
.below(thirtyDaysAgo)
.delete()
console.log(`删除了 ${deletedCount} 条旧消息`)
// 2. 删除孤儿消息(没有对应会话的消息)
const sessionIds = await db.sessions.toCollection().primaryKeys()
const orphanCount = await db.messages
.where('sessionId')
.noneOf(sessionIds as number[])
.delete()
console.log(`删除了 ${orphanCount} 条孤儿消息`)
console.log('✅ 旧数据清理完成')
} catch (error) {
console.error('清理旧数据失败:', error)
throw error
}
}
}
// ==================== 导出 ====================
/**
* 数据库管理器单例
*/
export const databaseManager = new DatabaseManager()
/**
* 获取当前数据库的快捷函数
*
* @example
* const sessions = await db().sessions.toArray()
* await db().sessions.put(session)
*/
export const db = () => databaseManager.getCurrentDatabase()
/**
* 检查浏览器兼容性
*/
export const checkBrowserSupport = (): {
supported: boolean
message?: string
} => {
// 1. 检查 IndexedDB
if (!('indexedDB' in window)) {
return {
supported: false,
message: '您的浏览器不支持本地存储功能,请升级到最新版本',
}
}
// 2. 检查 WebSocket
if (!('WebSocket' in window)) {
return {
supported: false,
message: '您的浏览器不支持实时通信功能,请升级到最新版本',
}
}
// 3. 检查隐私模式Safari
try {
const testDb = indexedDB.open('test')
testDb.onerror = () => {
return {
supported: false,
message: '检测到您正在使用隐私模式,某些功能可能无法使用',
}
}
} catch (error) {
return {
supported: false,
message: '浏览器存储功能已被禁用',
}
}
return { supported: true }
}
/**
* 安全写入(带配额检查)
*/
export const safeWrite = async (operation: () => Promise<void>) => {
const quota = await databaseManager.checkStorageQuota()
if (!quota.canWrite) {
console.error('存储空间不足,无法写入')
throw new Error('存储空间不足')
}
// 如果超过 80% 开始清理
if (quota.usagePercent > 80) {
console.warn('存储空间不足,开始清理旧数据...')
await databaseManager.cleanOldData()
}
await operation()
}

View File

@@ -1,537 +0,0 @@
/**
* 消息管理器 - 负责消息数据的存储和查询(可选功能)
*
* 核心特性:
* - 混合策略:缓存 + 实时加载
* - 自动清理:限制数量和时间
* - 消息去重:防止重复消息
* - 消息排序:按序列号和时间戳
*
* 存储限制:
* - 每个会话最多 500 条消息
* - 保留最近 30 天
* - 超过限制自动清理
*
* @author TouchVueThree Team
* @date 2026-01-13
*/
import { db } from '../db'
import type { ChatMessage } from '../db'
// ==================== 配置常量 ====================
/** 每个会话最多缓存的消息数量 */
const MAX_MESSAGES_PER_SESSION = 500
/** 消息保留时间(天) */
const MESSAGE_RETENTION_DAYS = 30
/** 内存去重缓存大小 */
const DEDUP_CACHE_SIZE = 1000
// ==================== 类型定义 ====================
/** 消息更新回调函数 */
type MessageUpdateCallback = (messages: ChatMessage[], sessionId: number) => void
// ==================== 消息管理器类 ====================
export class MessageManager {
/** 回调函数集合按会话ID分组 */
private static updateCallbacks = new Map<number, Set<MessageUpdateCallback>>()
/** 消息去重表(内存级别,快速检查) */
private static messageClientIdSet = new Set<string>()
// ==================== 回调管理 ====================
/**
* 订阅指定会话的消息变更
*
* @param sessionId 会话ID
* @param callback 回调函数
* @returns 取消订阅函数
*
* @example
* const unsubscribe = MessageManager.onUpdate(123, (messages) => {
* console.log('消息更新:', messages)
* })
* // 取消订阅
* unsubscribe()
*/
static onUpdate(sessionId: number, callback: MessageUpdateCallback) {
if (!this.updateCallbacks.has(sessionId)) {
this.updateCallbacks.set(sessionId, new Set())
}
this.updateCallbacks.get(sessionId)!.add(callback)
return () => {
const callbacks = this.updateCallbacks.get(sessionId)
if (callbacks) {
callbacks.delete(callback)
if (callbacks.size === 0) {
this.updateCallbacks.delete(sessionId)
}
}
}
}
/**
* 触发指定会话的回调
*
* @param sessionId 会话ID
*/
private static async triggerCallbacks(sessionId: number) {
const callbacks = this.updateCallbacks.get(sessionId)
if (!callbacks || callbacks.size === 0) return
try {
const messages = await this.getMessages(sessionId)
callbacks.forEach((callback) => {
try {
callback(messages, sessionId)
} catch (error) {
console.error('消息更新回调执行失败:', error)
}
})
} catch (error) {
console.error('触发回调失败:', error)
}
}
// ==================== 消息去重 ====================
/**
* 检查消息是否重复
*
* @param clientId 客户端消息ID
* @returns 是否重复
*/
static async checkDuplicate(clientId: string): Promise<boolean> {
// 1. 内存级别快速检查
if (this.messageClientIdSet.has(clientId)) {
return true
}
// 2. IndexedDB 检查(防止刷新后重复)
try {
const existing = await db()
.messages.where('clientId')
.equals(clientId)
.first()
if (existing) {
// 补充到内存缓存
this.messageClientIdSet.add(clientId)
return true
}
} catch (error) {
console.error('检查重复消息失败:', error)
}
return false
}
// ==================== 消息查询 ====================
/**
* 获取会话的消息列表
*
* @param sessionId 会话ID
* @param offset 偏移量(分页)
* @param limit 数量限制
* @returns 消息列表(已排序)
*/
static async getMessages(
sessionId: number,
offset = 0,
limit = 50
): Promise<ChatMessage[]> {
try {
const messages = await db()
.messages.where('sessionId')
.equals(sessionId)
.offset(offset)
.limit(limit)
.toArray()
// 排序:优先按序列号,其次按时间戳
return messages.sort((a, b) => {
// 1. 按序列号排序
if (a.sequence !== undefined && b.sequence !== undefined) {
if (a.sequence !== b.sequence) {
return a.sequence - b.sequence
}
}
// 2. 按时间戳排序
if (a.wechatTime !== b.wechatTime) {
return a.wechatTime - b.wechatTime
}
// 3. 按ID排序兜底
return a.id - b.id
})
} catch (error) {
console.error('获取消息列表失败:', error)
return []
}
}
/**
* 获取会话的最新消息
*
* @param sessionId 会话ID
* @param limit 数量限制
* @returns 消息列表
*/
static async getLatestMessages(
sessionId: number,
limit = 50
): Promise<ChatMessage[]> {
try {
const messages = await db()
.messages.where('sessionId')
.equals(sessionId)
.reverse()
.limit(limit)
.toArray()
// 反转顺序(从旧到新)
return messages.reverse()
} catch (error) {
console.error('获取最新消息失败:', error)
return []
}
}
/**
* 获取消息总数
*
* @param sessionId 会话ID
* @returns 消息总数
*/
static async getMessageCount(sessionId: number): Promise<number> {
try {
return await db().messages.where('sessionId').equals(sessionId).count()
} catch (error) {
console.error('获取消息数量失败:', error)
return 0
}
}
// ==================== 消息操作 ====================
/**
* 添加新消息
* 自动检查重复、限制数量、触发回调
*
* @param message 消息对象
*/
static async addMessage(message: ChatMessage) {
try {
// 1. 检查重复
if (await this.checkDuplicate(message.clientId)) {
console.log('⚠️ 重复消息,跳过:', message.clientId)
return
}
// 2. 保存到数据库
await db().messages.put(message)
// 3. 添加到内存缓存
this.messageClientIdSet.add(message.clientId)
// 4. 限制内存缓存大小
if (this.messageClientIdSet.size > DEDUP_CACHE_SIZE) {
const toRemove = Array.from(this.messageClientIdSet).slice(
0,
this.messageClientIdSet.size - DEDUP_CACHE_SIZE
)
toRemove.forEach((id) => this.messageClientIdSet.delete(id))
}
// 5. 检查是否超过数量限制
const count = await this.getMessageCount(message.sessionId)
if (count > MAX_MESSAGES_PER_SESSION) {
await this.cleanOldMessages(message.sessionId)
}
// 6. 触发回调
await this.triggerCallbacks(message.sessionId)
} catch (error) {
console.error('添加消息失败:', error)
throw error
}
}
/**
* 批量缓存消息
* 用于从服务器加载历史消息
*
* @param sessionId 会话ID
* @param messages 消息列表
*/
static async cacheMessages(sessionId: number, messages: ChatMessage[]) {
try {
await db().transaction('rw', db().messages, async () => {
for (const message of messages) {
// 检查重复
if (!(await this.checkDuplicate(message.clientId))) {
await db().messages.put(message)
this.messageClientIdSet.add(message.clientId)
}
}
})
// 检查是否超过限制
const count = await this.getMessageCount(sessionId)
if (count > MAX_MESSAGES_PER_SESSION) {
await this.cleanOldMessages(sessionId)
}
await this.triggerCallbacks(sessionId)
} catch (error) {
console.error('批量缓存消息失败:', error)
throw error
}
}
/**
* 更新消息状态
*
* @param messageId 消息ID
* @param updates 更新字段
*/
static async updateMessage(
messageId: number,
updates: Partial<ChatMessage>
) {
try {
const message = await db().messages.get(messageId)
if (!message) {
console.warn('消息不存在:', messageId)
return
}
await db().messages.update(messageId, updates)
await this.triggerCallbacks(message.sessionId)
} catch (error) {
console.error('更新消息失败:', error)
throw error
}
}
/**
* 撤回消息
*
* @param messageId 消息ID
*/
static async recallMessage(messageId: number) {
try {
await this.updateMessage(messageId, {
content: '[消息已撤回]',
isRecalled: true,
status: 'success',
})
} catch (error) {
console.error('撤回消息失败:', error)
throw error
}
}
/**
* 标记消息已读
*
* @param messageIds 消息ID列表
*/
static async markAsRead(messageIds: number[]) {
try {
await db().transaction('rw', db().messages, async () => {
for (const id of messageIds) {
await db().messages.update(id, { isRead: true })
}
})
} catch (error) {
console.error('标记已读失败:', error)
throw error
}
}
// ==================== 清理操作 ====================
/**
* 清理会话的旧消息
* 保留最新的 MAX_MESSAGES_PER_SESSION 条
*
* @param sessionId 会话ID
*/
static async cleanOldMessages(sessionId: number) {
try {
const messages = await db()
.messages.where('sessionId')
.equals(sessionId)
.toArray()
if (messages.length <= MAX_MESSAGES_PER_SESSION) {
return
}
// 按时间排序
messages.sort((a, b) => a.wechatTime - b.wechatTime)
// 删除旧消息
const toDelete = messages
.slice(0, messages.length - MAX_MESSAGES_PER_SESSION)
.map((m) => m.id)
await db().messages.bulkDelete(toDelete)
console.log(`🗑️ 清理了会话 ${sessionId}${toDelete.length} 条旧消息`)
} catch (error) {
console.error('清理旧消息失败:', error)
}
}
/**
* 清理过期消息
* 删除超过 MESSAGE_RETENTION_DAYS 天的消息
*/
static async cleanExpiredMessages() {
try {
const expireTime = Date.now() - MESSAGE_RETENTION_DAYS * 24 * 60 * 60 * 1000
const deletedCount = await db()
.messages.where('wechatTime')
.below(expireTime)
.delete()
console.log(`🗑️ 清理了 ${deletedCount} 条过期消息`)
} catch (error) {
console.error('清理过期消息失败:', error)
}
}
/**
* 清空会话的所有消息
*
* @param sessionId 会话ID
*/
static async clearSessionMessages(sessionId: number) {
try {
await db().messages.where('sessionId').equals(sessionId).delete()
await this.triggerCallbacks(sessionId)
} catch (error) {
console.error('清空会话消息失败:', error)
throw error
}
}
/**
* 清空所有消息(慎用)
*/
static async clearAll() {
try {
await db().messages.clear()
this.messageClientIdSet.clear()
this.updateCallbacks.clear()
} catch (error) {
console.error('清空所有消息失败:', error)
throw error
}
}
// ==================== 搜索功能 ====================
/**
* 搜索消息
*
* @param sessionId 会话ID
* @param keyword 关键词
* @param limit 数量限制
* @returns 消息列表
*/
static async searchMessages(
sessionId: number,
keyword: string,
limit = 50
): Promise<ChatMessage[]> {
try {
const messages = await db()
.messages.where('sessionId')
.equals(sessionId)
.toArray()
// 过滤包含关键词的消息
const results = messages.filter((m) =>
m.content.toLowerCase().includes(keyword.toLowerCase())
)
// 按时间倒序
results.sort((a, b) => b.wechatTime - a.wechatTime)
return results.slice(0, limit)
} catch (error) {
console.error('搜索消息失败:', error)
return []
}
}
// ==================== 统计功能 ====================
/**
* 获取会话的消息统计
*
* @param sessionId 会话ID
* @returns 统计信息
*/
static async getStatistics(sessionId: number): Promise<{
total: number
unreadCount: number
sendCount: number
receiveCount: number
todayCount: number
}> {
try {
const messages = await db()
.messages.where('sessionId')
.equals(sessionId)
.toArray()
const todayStart = new Date().setHours(0, 0, 0, 0)
return {
total: messages.length,
unreadCount: messages.filter((m) => !m.isRead).length,
sendCount: messages.filter((m) => m.direction === 'send').length,
receiveCount: messages.filter((m) => m.direction === 'receive').length,
todayCount: messages.filter((m) => m.wechatTime >= todayStart).length,
}
} catch (error) {
console.error('获取统计信息失败:', error)
return {
total: 0,
unreadCount: 0,
sendCount: 0,
receiveCount: 0,
todayCount: 0,
}
}
}
/**
* 是否应该缓存消息
* 根据会话类型和配置决定
*
* @param sessionId 会话ID
* @returns 是否缓存
*/
static shouldCacheMessage(sessionId: number): boolean {
// 默认缓存所有消息
// 可以根据业务需求添加更多判断逻辑
// 例如:只缓存重要会话、最近活跃会话等
return true
}
}

View File

@@ -1,715 +0,0 @@
/**
* 会话管理器 - 负责会话数据的 CRUD 和订阅机制
*
* 核心特性:
* - 订阅机制:替代轮询,数据变更时自动通知
* - 同步控制:防止竞态条件
* - 动态创建:陌生好友消息自动获取详情并创建会话
* - 批量同步:从服务器批量同步会话数据
*
* @author TouchVueThree Team
* @date 2026-01-13
*/
import { db } from '../db'
import type { ChatSession } from '../db'
import { getFriendDetail, getGroupDetail } from '@/api/modules/wechat'
// ==================== 类型定义 ====================
/** 会话更新回调函数 */
type SessionUpdateCallback = (sessions: ChatSession[], accountId?: number) => void
// ==================== 会话管理器类 ====================
export class SessionManager {
/** 回调函数集合 */
private static updateCallbacks = new Set<SessionUpdateCallback>()
/** 是否正在同步(防止竞态条件) */
private static isSyncing = false
/** 同步期间的待处理更新 */
private static pendingUpdates = new Map<number, Partial<ChatSession>>()
// ==================== 排序键生成 ====================
/**
* 生成会话排序键(与旧项目一致)
* 格式: ${isTop}|${timestamp}|${displayName}
*
* @param session 会话数据
* @returns 排序键字符串
*/
static generateSortKey(session: ChatSession): string {
// 1. 置顶标识置顶为1普通为0降序时置顶在前
const isTop = session.config?.top ? 1 : 0
// 2. 时间戳:使用 msgTime 或 lastUpdateTime
const timestamp = session.config?.msgTime ||
new Date(session.lastUpdateTime || new Date()).getTime()
// 3. 显示名称:用于稳定排序
const displayName = (
session.conRemark ||
session.nickname ||
''
).toLowerCase()
// 格式:置顶标识|时间戳|显示名称
// 降序排序:置顶(1)在前,时间大的在前,名称小的在前
return `${isTop}|${timestamp}|${displayName}`
}
// ==================== 回调管理 ====================
/**
* 订阅会话变更
* 当会话数据发生变化时,会调用回调函数
*
* @param callback 回调函数
* @returns 取消订阅函数
*
* @example
* const unsubscribe = SessionManager.onUpdate((sessions) => {
* console.log('会话更新:', sessions)
* })
* // 取消订阅
* unsubscribe()
*/
static onUpdate(callback: SessionUpdateCallback) {
this.updateCallbacks.add(callback)
return () => this.updateCallbacks.delete(callback)
}
/**
* 触发所有回调
*
* @param accountId 账号ID可选用于过滤
*/
private static async triggerCallbacks(accountId?: number) {
try {
const sessions = await this.getUserSessions(accountId)
this.updateCallbacks.forEach((callback) => {
try {
callback(sessions, accountId)
} catch (error) {
console.error('会话更新回调执行失败:', error)
}
})
} catch (error) {
console.error('触发回调失败:', error)
}
}
// ==================== 同步控制 ====================
/**
* 开始同步
* 同步期间的更新会暂存,同步结束后统一应用
*/
static beginSync() {
this.isSyncing = true
this.pendingUpdates.clear()
console.log('📥 开始同步会话...')
}
/**
* 结束同步
* 应用同步期间的待处理更新
*
* @param accountId 账号ID
*/
static async endSync(accountId?: number) {
this.isSyncing = false
if (this.pendingUpdates.size > 0) {
console.log(`📤 应用 ${this.pendingUpdates.size} 个待处理更新`)
for (const [sessionId, update] of this.pendingUpdates) {
try {
await db().sessions.update(sessionId, update)
} catch (error) {
console.error(`更新会话 ${sessionId} 失败:`, error)
}
}
this.pendingUpdates.clear()
await this.triggerCallbacks(accountId)
}
console.log('✅ 同步完成')
}
// ==================== 数据查询 ====================
/**
* 获取用户的所有会话
* 支持按账号ID过滤
*
* @param accountId 账号ID可选0或undefined表示全部
* @returns 会话列表(已排序)
*/
static async getUserSessions(accountId?: number): Promise<ChatSession[]> {
try {
let query = db().sessions.toCollection()
// 如果指定了账号ID进行过滤
if (accountId && accountId !== 0) {
query = db().sessions.where('wechatAccountId').equals(accountId)
}
const sessions = await query.toArray()
// 排序:置顶在前,时间倒序
return sessions.sort((a, b) => {
// 置顶优先
if (a.config.top && !b.config.top) return -1
if (!a.config.top && b.config.top) return 1
// 按消息时间倒序
return b.config.msgTime - a.config.msgTime
})
} catch (error) {
console.error('获取会话列表失败:', error)
throw error
}
}
/**
* 获取单个会话
*
* @param sessionId 会话ID
* @returns 会话信息或 undefined
*/
static async getSession(sessionId: number): Promise<ChatSession | undefined> {
try {
return await db().sessions.get(sessionId)
} catch (error) {
console.error('获取会话失败:', error)
return undefined
}
}
// ==================== 数据操作 ====================
/**
* 添加或更新会话(自动更新 sortKey
*
* @param session 会话信息
*/
static async upsertSession(session: ChatSession) {
try {
// ⭐ 自动生成并更新 sortKey
const sessionWithSortKey = {
...session,
sortKey: this.generateSortKey(session),
}
await db().sessions.put(sessionWithSortKey)
await this.triggerCallbacks(session.wechatAccountId)
} catch (error) {
console.error('保存会话失败:', error)
throw error
}
}
/**
* 批量同步会话
* 用于从服务器批量同步数据
*
* @param sessions 会话列表
*/
static async syncSessions(sessions: ChatSession[]) {
try {
await db().transaction('rw', db().sessions, async () => {
for (const session of sessions) {
// ⭐ 确保每个会话都有正确的 sortKey
const sessionWithSortKey = {
...session,
sortKey: session.sortKey || this.generateSortKey(session),
}
await db().sessions.put(sessionWithSortKey)
}
})
await this.triggerCallbacks()
} catch (error) {
console.error('批量同步会话失败:', error)
throw error
}
}
/**
* 收到新消息时更新会话
*
* ⭐ 关键功能:如果会话不存在,自动获取好友详情并创建
*
* @param sessionId 会话ID好友ID或群ID
* @param sessionType 会话类型
* @param content 消息内容
* @param wechatAccountId 账号ID
*/
static async updateOnNewMessage(
sessionId: number,
sessionType: 'friend' | 'group',
content: string,
wechatAccountId?: number
) {
try {
const update: Partial<ChatSession> = {
content,
lastUpdateTime: new Date().toISOString(),
sortKey: `${Date.now()}_${sessionId}`,
}
// 如果正在同步,暂存更新
if (this.isSyncing) {
console.log('⏸️ 同步进行中,暂存更新:', sessionId)
const existing = this.pendingUpdates.get(sessionId)
this.pendingUpdates.set(sessionId, { ...existing, ...update })
return
}
// 检查会话是否存在
const existing = await db().sessions.get(sessionId)
if (existing) {
// ✅ 会话已存在,直接更新
const updatedSession = {
...existing,
...update,
config: {
...existing.config,
msgTime: Date.now(),
unreadCount: (existing.config.unreadCount || 0) + 1,
},
}
// ⭐ 自动更新 sortKey
updatedSession.sortKey = this.generateSortKey(updatedSession)
await db().sessions.update(sessionId, updatedSession)
await this.triggerCallbacks(wechatAccountId)
return
}
// ⭐ 会话不存在 - 动态创建
console.warn(`⚠️ 会话 ${sessionId} 不存在,动态创建...`)
await this.createSessionFromMessage(
sessionId,
sessionType,
content,
wechatAccountId
)
} catch (error) {
console.error('更新会话失败:', error)
throw error
}
}
/**
* 根据消息创建新会话(陌生好友/群聊)
*
* @param sessionId 会话ID
* @param sessionType 会话类型
* @param content 消息内容
* @param wechatAccountId 账号ID
*/
private static async createSessionFromMessage(
sessionId: number,
sessionType: 'friend' | 'group',
content: string,
wechatAccountId?: number
) {
try {
// 1. 根据类型调用不同接口获取详情
console.log(`🔍 获取${sessionType === 'friend' ? '好友' : '群聊'}详情...`)
const contactInfo =
sessionType === 'friend'
? await getFriendDetail({ friendId: sessionId })
: await getGroupDetail({ groupId: sessionId })
// 2. 创建新会话
const newSession: ChatSession = {
id: sessionId,
serverId: `${sessionType}_${sessionId}`,
type: sessionType,
wechatAccountId: wechatAccountId || 0,
nickname: contactInfo.nickname || contactInfo.name || '未知用户',
conRemark: contactInfo.conRemark || contactInfo.remark,
avatar: contactInfo.avatar || '/default-avatar.png',
wxid: contactInfo.wxid,
chatroomId: sessionType === 'group' ? contactInfo.chatroomId : undefined,
content,
lastUpdateTime: new Date().toISOString(),
config: {
unreadCount: 1,
top: false,
msgTime: Date.now(),
chat: true,
},
sortKey: '', // 临时占位,稍后生成
}
// ⭐ 使用 generateSortKey 生成正确的 sortKey
newSession.sortKey = this.generateSortKey(newSession)
// 3. 保存到数据库
await db().sessions.put(newSession)
console.log('✅ 新会话已创建:', newSession.nickname || sessionId)
// 4. 触发回调,更新 UI
await this.triggerCallbacks(wechatAccountId)
} catch (error) {
console.error('获取好友详情失败,使用降级方案:', error)
// ⚠️ 降级方案:创建临时会话
await this.createFallbackSession(
sessionId,
sessionType,
content,
wechatAccountId
)
}
}
/**
* 创建临时会话(降级方案)
* 当获取好友详情失败时使用
*
* @param sessionId 会话ID
* @param sessionType 会话类型
* @param content 消息内容
* @param wechatAccountId 账号ID
*/
private static async createFallbackSession(
sessionId: number,
sessionType: 'friend' | 'group',
content: string,
wechatAccountId?: number
) {
const tempSession: ChatSession = {
id: sessionId,
serverId: `${sessionType}_${sessionId}`,
type: sessionType,
wechatAccountId: wechatAccountId || 0,
nickname: '未知用户',
avatar: '/default-avatar.png',
content,
lastUpdateTime: new Date().toISOString(),
config: {
unreadCount: 1,
top: false,
msgTime: Date.now(),
chat: true,
},
sortKey: '', // 临时占位,稍后生成
}
// ⭐ 使用 generateSortKey 生成正确的 sortKey
tempSession.sortKey = this.generateSortKey(tempSession)
await db().sessions.put(tempSession)
await this.triggerCallbacks(wechatAccountId)
console.log('⚠️ 已创建临时会话,后台继续重试获取详情')
// 后台继续重试
this.retryGetContactInfo(sessionId, sessionType, wechatAccountId)
}
/**
* 重试获取联系人信息
* 3次重试指数退避5s、10s、15s
*
* @param sessionId 会话ID
* @param sessionType 会话类型
* @param wechatAccountId 账号ID
*/
private static async retryGetContactInfo(
sessionId: number,
sessionType: 'friend' | 'group',
wechatAccountId?: number
) {
let retryCount = 0
const maxRetries = 3
while (retryCount < maxRetries) {
try {
// 指数退避5秒、10秒、15秒
await new Promise((resolve) =>
setTimeout(resolve, 5000 * (retryCount + 1))
)
console.log(`🔄 重试获取联系人信息 (${retryCount + 1}/${maxRetries})`)
const contactInfo =
sessionType === 'friend'
? await getFriendDetail({ friendId: sessionId })
: await getGroupDetail({ groupId: sessionId })
// 更新会话详情
await db().sessions.update(sessionId, {
nickname: contactInfo.nickname || contactInfo.name,
conRemark: contactInfo.conRemark || contactInfo.remark,
avatar: contactInfo.avatar,
wxid: contactInfo.wxid,
chatroomId:
sessionType === 'group' ? contactInfo.chatroomId : undefined,
})
console.log(`✅ 重试成功,已更新会话 ${sessionId} 的详细信息`)
await this.triggerCallbacks(wechatAccountId)
break // 成功则退出循环
} catch (error) {
retryCount++
console.warn(
`重试获取联系人信息失败 (${retryCount}/${maxRetries})`,
error
)
if (retryCount >= maxRetries) {
// 达到最大重试次数,记录错误
console.error(`❌ 无法获取会话${sessionId}的详细信息`)
// TODO: 上报到 Sentry
// Sentry.captureException(
// new Error(`无法获取会话${sessionId}的详细信息`),
// { extra: { sessionId, sessionType, retryCount } }
// )
}
}
}
}
/**
* 清除未读数
*
* @param sessionId 会话ID
* @param accountId 账号ID
*/
static async clearUnread(sessionId: number, accountId?: number) {
try {
await db().sessions.update(sessionId, {
'config.unreadCount': 0,
})
await this.triggerCallbacks(accountId)
} catch (error) {
console.error('清除未读失败:', error)
}
}
/**
* 删除会话
*
* @param sessionId 会话ID
* @param accountId 账号ID
*/
static async deleteSession(sessionId: number, accountId?: number) {
try {
await db().sessions.delete(sessionId)
await this.triggerCallbacks(accountId)
} catch (error) {
console.error('删除会话失败:', error)
throw error
}
}
/**
* 置顶/取消置顶会话(自动更新 sortKey
*
* @param sessionId 会话ID
* @param accountId 账号ID
*/
static async togglePin(sessionId: number, accountId?: number) {
try {
const session = await db().sessions.get(sessionId)
if (session) {
const updatedSession = {
...session,
config: {
...session.config,
top: !session.config.top,
},
}
// ⭐ 自动更新 sortKey置顶状态变化会影响排序
updatedSession.sortKey = this.generateSortKey(updatedSession)
await db().sessions.update(sessionId, updatedSession)
await this.triggerCallbacks(accountId)
}
} catch (error) {
console.error('切换置顶状态失败:', error)
throw error
}
}
/**
* 清空所有会话(慎用)
*/
static async clearAll() {
try {
await db().sessions.clear()
await this.triggerCallbacks()
} catch (error) {
console.error('清空会话失败:', error)
throw error
}
}
/**
* 获取会话统计信息
*/
static async getStatistics(accountId?: number): Promise<{
total: number
unreadCount: number
topCount: number
}> {
try {
const sessions = await this.getUserSessions(accountId)
return {
total: sessions.length,
unreadCount: sessions.reduce(
(sum, s) => sum + (s.config.unreadCount || 0),
0
),
topCount: sessions.filter((s) => s.config.top).length,
}
} catch (error) {
console.error('获取统计信息失败:', error)
return { total: 0, unreadCount: 0, topCount: 0 }
}
}
// ==================== 数据联动同步 ====================
/**
* 从详情数据同步到本地数据库
* ⭐ 核心方法:确保 API 返回的最新数据同步到本地
*
* 使用场景:
* - 修改好友备注后,重新获取详情并同步到本地
* - 修改扩展字段后,重新获取详情并同步到本地
* - 修改标签后,重新获取详情并同步到本地
* - 修改群聊名称后,重新获取详情并同步到本地
*
* @param detail API 返回的详情数据
* @param sessionId 会话 ID
* @param sessionType 会话类型 ('friend' | 'group')
*/
static async syncSessionFromDetail(
detail: any,
sessionId: number,
sessionType: 'friend' | 'group'
): Promise<void> {
try {
// 1. 从数据库获取现有会话
const existingSession = await db().sessions.get(sessionId)
if (!existingSession) {
console.warn(`[SessionManager] 会话 ${sessionId} 不存在,跳过同步`)
return
}
// 2. 构建更新数据
const updates: Partial<ChatSession> = {}
let hasChanges = false
if (sessionType === 'friend') {
// 好友类型:同步备注、扩展字段等
if (detail.conRemark !== undefined && detail.conRemark !== existingSession.conRemark) {
updates.conRemark = detail.conRemark
hasChanges = true
}
if (detail.nickname !== undefined && detail.nickname !== existingSession.nickname) {
updates.nickname = detail.nickname
hasChanges = true
}
if (detail.avatar !== undefined && detail.avatar !== existingSession.avatar) {
updates.avatar = detail.avatar
hasChanges = true
}
if (detail.wxid !== undefined && detail.wxid !== existingSession.wxid) {
updates.wxid = detail.wxid
hasChanges = true
}
// 同步扩展字段
if (detail.extendFields !== undefined && detail.extendFields !== existingSession.extendFields) {
updates.extendFields = detail.extendFields
hasChanges = true
}
// 同步标签
if (detail.labels !== undefined) {
const detailLabels = Array.isArray(detail.labels) ? detail.labels : (detail.labels || '').split(',').filter(Boolean)
const existingLabels = existingSession.labels || []
if (JSON.stringify(detailLabels) !== JSON.stringify(existingLabels)) {
updates.labels = detailLabels
hasChanges = true
}
}
} else {
// 群聊类型:同步群名、群主等
if (detail.nickname !== undefined && detail.nickname !== existingSession.nickname) {
updates.nickname = detail.nickname
hasChanges = true
}
if (detail.chatroomName !== undefined && detail.chatroomName !== existingSession.nickname) {
updates.nickname = detail.chatroomName
hasChanges = true
}
if (detail.avatar !== undefined && detail.avatar !== existingSession.avatar) {
updates.avatar = detail.avatar
hasChanges = true
}
if (detail.chatroomId !== undefined && detail.chatroomId !== existingSession.chatroomId) {
updates.chatroomId = detail.chatroomId
hasChanges = true
}
}
// 3. 如果有更新,重新生成 sortKey
if (hasChanges) {
const updatedSession = { ...existingSession, ...updates }
updates.sortKey = this.generateSortKey(updatedSession)
updates.lastUpdateTime = new Date().toISOString()
// 4. 更新到数据库
await db().sessions.update(sessionId, updates)
console.log(`[SessionManager] ✅ 从详情同步会话数据: ${sessionId}`, updates)
// 5. 触发订阅回调(自动更新 UI
await this.triggerCallbacks(existingSession.wechatAccountId)
} else {
console.log(`[SessionManager] 会话 ${sessionId} 数据无变化,跳过更新`)
}
} catch (error) {
console.error('[SessionManager] ❌ 从详情同步会话失败:', error)
throw error
}
}
/**
* 快捷方法:从好友详情同步
* @param friendId 好友ID
* @param detail API 返回的好友详情数据
*/
static async syncSessionFromFriendDetail(friendId: number, detail: any): Promise<void> {
return this.syncSessionFromDetail(detail, friendId, 'friend')
}
/**
* 快捷方法:从群聊详情同步
* @param groupId 群聊ID
* @param detail API 返回的群聊详情数据
*/
static async syncSessionFromGroupDetail(groupId: number, detail: any): Promise<void> {
return this.syncSessionFromDetail(detail, groupId, 'group')
}
}

View File

@@ -1,142 +0,0 @@
/**
* 微信表情包工具函数
* 提供表情解析、路径获取等功能
*/
// 导入表情映射数据(从 wechatEmoji 模块)
import { getEmojiPath, getEmojiNameByPath } from '@/utils/wechatEmoji'
import type { EmojiName } from '@/utils/wechatEmoji'
/**
* 解析文本中的表情标记 [表情名称] 并替换为 HTML img 标签
* @param text 原始文本
* @returns 解析后的 HTML 字符串
*
* @example
* ```typescript
* const html = parseEmojiText('你好[微笑]')
* // 返回: '你好<img src="/assets/face/smile.png" alt="微笑" class="emoji-image" />'
* ```
*/
export function parseEmojiText(text: string): string {
if (!text || typeof text !== 'string') {
return text || ''
}
// 匹配 [表情名称] 格式
const emojiRegex = /\[([^\]]+)\]/g
let result = text
let match: RegExpExecArray | null
while ((match = emojiRegex.exec(text)) !== null) {
const emojiName = match[1] as EmojiName
const emojiPath = getEmojiPath(emojiName)
if (emojiPath) {
// 转义 HTML 特殊字符(避免 XSS
const escapedName = escapeHtml(emojiName)
const escapedPath = escapeHtml(emojiPath)
// 替换为 img 标签,添加 data-emoji-name 标识属性
const imgTag = `<img src="${escapedPath}" alt="${escapedName}" data-emoji-name="${escapedName}" class="emoji-image" style="width: 20px; height: 20px; margin: 0 2px; display: inline-block; vertical-align: middle;" />`
result = result.replace(match[0], imgTag)
}
}
return result
}
/**
* HTML 转义
*/
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
}
return text.replace(/[&<>"']/g, m => map[m])
}
/**
* 判断是否为表情包 URL
* @param content 内容字符串
* @returns 是否为表情包 URL
*/
export function isEmojiUrl(content: string): boolean {
if (!content || typeof content !== 'string') {
return false
}
return (
content.includes('ac-weremote-s2.oss-cn-shenzhen.aliyuncs.com') ||
/\.(gif|webp|png|jpg|jpeg)$/i.test(content) ||
content.includes('emoji') ||
content.includes('sticker') ||
content.includes('expression')
)
}
/**
* 将 HTML 内容转换为文本格式(包含 [表情名称]
* @param html HTML 内容
* @returns 文本格式(表情用 [表情名称] 表示)
*/
export function htmlToText(html: string): string {
if (!html) return ''
// 创建一个临时 DOM 元素来解析 HTML
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
// 遍历所有 img 标签,替换为 [表情名称]
const images = tempDiv.querySelectorAll('img.emoji-image')
images.forEach((img) => {
// 优先使用 data-emoji-name 属性
let emojiName = img.getAttribute('data-emoji-name')
// 如果没有 data-emoji-name使用 alt 属性
if (!emojiName) {
emojiName = img.getAttribute('alt')
}
// 如果还是没有,根据图片路径查找表情名称
if (!emojiName) {
const src = img.getAttribute('src')
if (src) {
emojiName = getEmojiNameByPath(src)
}
}
if (emojiName) {
const textNode = document.createTextNode(`[${emojiName}]`)
img.parentNode?.replaceChild(textNode, img)
}
})
// 获取纯文本内容
return tempDiv.textContent || tempDiv.innerText || ''
}
/**
* 将文本格式转换为 HTML将 [表情名称] 转换为 <img> 标签)
* @param text 文本内容
* @returns HTML 内容
*/
export function textToHtml(text: string): string {
if (!text) return ''
// 使用 parseEmojiText 函数将 [表情名称] 转换为 <img> 标签
return parseEmojiText(text)
}
/**
* 获取 HTML 内容的纯文本长度(不包括 HTML 标签)
* @param html HTML 内容
* @returns 纯文本长度
*/
export function getHtmlTextLength(html: string): number {
return htmlToText(html).length
}

View File

@@ -1,931 +0,0 @@
/**
* 微信表情包 TypeScript 模块
* 提供类型安全的表情访问和图片路径获取功能
*/
/**
* 表情类别枚举
*/
export enum EmojiCategory {
/** 人脸表情 */
FACE = 'face',
/** 手势表情 */
GESTURE = 'gesture',
/** 动物表情 */
ANIMAL = 'animal',
/** 祝福表情 */
BLESSING = 'blessing',
/** 其他表情 */
OTHER = 'other',
}
/**
* 表情信息接口
*/
export interface EmojiInfo {
/** 表情名称 */
name: string
/** 表情类别 */
category: EmojiCategory
/** 图片文件路径 */
path: string
/** 英文名称(可选) */
englishName?: string
}
/**
* 表情名称类型
*/
export type EmojiName =
// 人脸表情
| '微笑'
| '撇嘴'
| '色'
| '发呆'
| '得意'
| '流泪'
| '害羞'
| '闭嘴'
| '睡'
| '大哭'
| '尴尬'
| '发怒'
| '调皮'
| '呲牙'
| '惊讶'
| '难过'
| '囧'
| '抓狂'
| '吐'
| '偷笑'
| '愉快'
| '白眼'
| '傲慢'
| '困'
| '惊恐'
| '憨笑'
| '悠闲'
| '咒骂'
| '疑问'
| '嘘'
| '晕'
| '衰'
| '骷髅'
| '敲打'
| '再见'
| '擦汗'
| '抠鼻'
| '鼓掌'
| '坏笑'
| '右哼哼'
| '鄙视'
| '委屈'
| '快哭了'
| '阴险'
| '亲亲'
| '可怜'
| '笑脸'
| '生病'
| '脸红'
| '破涕为笑'
| '恐惧'
| '失望'
| '无语'
| '嘿哈'
| '捂脸'
| '机智'
| '皱眉'
| '耶'
| '吃瓜'
| '加油'
| '汗'
| '天啊'
| 'Emm'
| '社会社会'
| '旺柴'
| '好的'
| '打脸'
| '哇'
| '翻白眼'
| '666'
| '让我看看'
| '叹气'
| '苦涩'
| '裂开'
| '奸笑'
// 手势表情
| '握手'
| '胜利'
| '抱拳'
| '勾引'
| '拳头'
| 'OK'
| '合十'
| '强'
| '拥抱'
| '弱'
// 动物表情
| '猪头'
| '跳跳'
| '发抖'
| '转圈'
// 祝福表情
| '庆祝'
| '礼物'
| '红包'
| '發'
| '福'
| '烟花'
| '爆竹'
// 其他表情
| '嘴唇'
| '爱心'
| '心碎'
| '啤酒'
| '咖啡'
| '蛋糕'
| '凋谢'
| '菜刀'
| '炸弹'
| '便便'
| '太阳'
| '月亮'
| '玫瑰'
/**
* 表情数据映射
* 将表情名称映射到完整的表情信息
*/
const EMOJI_DATA: Record<EmojiName, EmojiInfo> = {
// 人脸表情
: {
name: '微笑',
category: EmojiCategory.FACE,
path: '/assets/face/smile.png',
},
: {
name: '撇嘴',
category: EmojiCategory.FACE,
path: '/assets/face/pout.png',
},
: {
name: '色',
category: EmojiCategory.FACE,
path: '/assets/face/lustful.png',
},
: {
name: '发呆',
category: EmojiCategory.FACE,
path: '/assets/face/daze.png',
},
: {
name: '得意',
category: EmojiCategory.FACE,
path: '/assets/face/smug.png',
},
: {
name: '流泪',
category: EmojiCategory.FACE,
path: '/assets/face/crying.png',
},
: {
name: '害羞',
category: EmojiCategory.FACE,
path: '/assets/face/shy.png',
},
: {
name: '闭嘴',
category: EmojiCategory.FACE,
path: '/assets/face/shut-up.png',
},
: {
name: '睡',
category: EmojiCategory.FACE,
path: '/assets/face/sleep.png',
},
: {
name: '大哭',
category: EmojiCategory.FACE,
path: '/assets/face/wail.png',
},
: {
name: '尴尬',
category: EmojiCategory.FACE,
path: '/assets/face/awkward.png',
},
: {
name: '发怒',
category: EmojiCategory.FACE,
path: '/assets/face/angry.png',
},
: {
name: '调皮',
category: EmojiCategory.FACE,
path: '/assets/face/naughty.png',
},
: {
name: '呲牙',
category: EmojiCategory.FACE,
path: '/assets/face/grin.png',
},
: {
name: '惊讶',
category: EmojiCategory.FACE,
path: '/assets/face/surprised.png',
},
: {
name: '难过',
category: EmojiCategory.FACE,
path: '/assets/face/sad.png',
},
: {
name: '囧',
category: EmojiCategory.FACE,
path: '/assets/face/embarrassed.png',
},
: {
name: '抓狂',
category: EmojiCategory.FACE,
path: '/assets/face/crazy.png',
},
: {
name: '吐',
category: EmojiCategory.FACE,
path: '/assets/face/vomit.png',
},
: {
name: '偷笑',
category: EmojiCategory.FACE,
path: '/assets/face/snicker.png',
},
: {
name: '愉快',
category: EmojiCategory.FACE,
path: '/assets/face/happy.png',
},
: {
name: '白眼',
category: EmojiCategory.FACE,
path: '/assets/face/roll-eyes.png',
},
: {
name: '傲慢',
category: EmojiCategory.FACE,
path: '/assets/face/arrogant.png',
},
: {
name: '困',
category: EmojiCategory.FACE,
path: '/assets/face/sleepy.png',
},
: {
name: '惊恐',
category: EmojiCategory.FACE,
path: '/assets/face/panic.png',
},
: {
name: '憨笑',
category: EmojiCategory.FACE,
path: '/assets/face/silly-smile.png',
},
: {
name: '悠闲',
category: EmojiCategory.FACE,
path: '/assets/face/leisurely.png',
},
: {
name: '咒骂',
category: EmojiCategory.FACE,
path: '/assets/face/curse.png',
},
: {
name: '疑问',
category: EmojiCategory.FACE,
path: '/assets/face/question.png',
},
: {
name: '嘘',
category: EmojiCategory.FACE,
path: '/assets/face/shush.png',
},
: {
name: '晕',
category: EmojiCategory.FACE,
path: '/assets/face/dizzy.png',
},
: {
name: '衰',
category: EmojiCategory.FACE,
path: '/assets/face/unlucky.png',
},
: {
name: '骷髅',
category: EmojiCategory.FACE,
path: '/assets/face/skull.png',
},
: {
name: '敲打',
category: EmojiCategory.FACE,
path: '/assets/face/knock.png',
},
: {
name: '再见',
category: EmojiCategory.FACE,
path: '/assets/face/goodbye.png',
},
: {
name: '擦汗',
category: EmojiCategory.FACE,
path: '/assets/face/wipe-sweat.png',
},
: {
name: '抠鼻',
category: EmojiCategory.FACE,
path: '/assets/face/pick-nose.png',
},
: {
name: '鼓掌',
category: EmojiCategory.FACE,
path: '/assets/face/clap.png',
},
: {
name: '坏笑',
category: EmojiCategory.FACE,
path: '/assets/face/evil-smile.png',
},
: {
name: '右哼哼',
category: EmojiCategory.FACE,
path: '/assets/face/right-hum.png',
},
: {
name: '鄙视',
category: EmojiCategory.FACE,
path: '/assets/face/despise.png',
},
: {
name: '委屈',
category: EmojiCategory.FACE,
path: '/assets/face/wronged.png',
},
: {
name: '快哭了',
category: EmojiCategory.FACE,
path: '/assets/face/about-to-cry.png',
},
: {
name: '阴险',
category: EmojiCategory.FACE,
path: '/assets/face/sinister.png',
},
: {
name: '亲亲',
category: EmojiCategory.FACE,
path: '/assets/face/kiss.png',
},
: {
name: '可怜',
category: EmojiCategory.FACE,
path: '/assets/face/pitiful.png',
},
: {
name: '笑脸',
category: EmojiCategory.FACE,
path: '/assets/face/smiley.png',
},
: {
name: '生病',
category: EmojiCategory.FACE,
path: '/assets/face/sick.png',
},
: {
name: '脸红',
category: EmojiCategory.FACE,
path: '/assets/face/blush.png',
},
: {
name: '破涕为笑',
category: EmojiCategory.FACE,
path: '/assets/face/tears-to-smile.png',
},
: {
name: '恐惧',
category: EmojiCategory.FACE,
path: '/assets/face/fear.png',
},
: {
name: '失望',
category: EmojiCategory.FACE,
path: '/assets/face/disappointed.png',
},
: {
name: '无语',
category: EmojiCategory.FACE,
path: '/assets/face/speechless.png',
},
: {
name: '嘿哈',
category: EmojiCategory.FACE,
path: '/assets/face/hey-ha.png',
},
: {
name: '捂脸',
category: EmojiCategory.FACE,
path: '/assets/face/facepalm.png',
},
: {
name: '机智',
category: EmojiCategory.FACE,
path: '/assets/face/smart.png',
},
: {
name: '皱眉',
category: EmojiCategory.FACE,
path: '/assets/face/frown.png',
},
: {
name: '耶',
category: EmojiCategory.FACE,
path: '/assets/face/yeah.png',
},
: {
name: '吃瓜',
category: EmojiCategory.FACE,
path: '/assets/face/eat-melon.png',
},
: {
name: '加油',
category: EmojiCategory.FACE,
path: '/assets/face/cheer-up.png',
},
: {
name: '汗',
category: EmojiCategory.FACE,
path: '/assets/face/sweat.png',
},
: {
name: '天啊',
category: EmojiCategory.FACE,
path: '/assets/face/oh-my.png',
},
Emm: {
name: 'Emm',
category: EmojiCategory.FACE,
path: '/assets/face/Emm.png',
},
: {
name: '社会社会',
category: EmojiCategory.FACE,
path: '/assets/face/social.png',
},
: {
name: '旺柴',
category: EmojiCategory.FACE,
path: '/assets/face/doge.png',
},
: {
name: '好的',
category: EmojiCategory.FACE,
path: '/assets/face/good.png',
},
: {
name: '打脸',
category: EmojiCategory.FACE,
path: '/assets/face/slap-face.png',
},
: {
name: '哇',
category: EmojiCategory.FACE,
path: '/assets/face/wow.png',
},
: {
name: '翻白眼',
category: EmojiCategory.FACE,
path: '/assets/face/eye-roll.png',
},
'666': {
name: '666',
category: EmojiCategory.FACE,
path: '/assets/face/666.png',
},
: {
name: '让我看看',
category: EmojiCategory.FACE,
path: '/assets/face/let-me-see.png',
},
: {
name: '叹气',
category: EmojiCategory.FACE,
path: '/assets/face/sigh.png',
},
: {
name: '苦涩',
category: EmojiCategory.FACE,
path: '/assets/face/bitter.png',
},
: {
name: '裂开',
category: EmojiCategory.FACE,
path: '/assets/face/crack.png',
},
: {
name: '奸笑',
category: EmojiCategory.FACE,
path: '/assets/face/sly-smile.png',
},
// 手势表情
: {
name: '握手',
category: EmojiCategory.GESTURE,
path: '/assets/gesture/handshake.png',
},
: {
name: '胜利',
category: EmojiCategory.GESTURE,
path: '/assets/gesture/victory.png',
},
: {
name: '抱拳',
category: EmojiCategory.GESTURE,
path: '/assets/gesture/fist-salute.png',
},
: {
name: '勾引',
category: EmojiCategory.GESTURE,
path: '/assets/gesture/beckon.png',
},
: {
name: '拳头',
category: EmojiCategory.GESTURE,
path: '/assets/gesture/fist.png',
},
OK: {
name: 'OK',
category: EmojiCategory.GESTURE,
path: '/assets/gesture/OK.png',
},
: {
name: '合十',
category: EmojiCategory.GESTURE,
path: '/assets/gesture/pray.png',
},
: {
name: '强',
category: EmojiCategory.GESTURE,
path: '/assets/gesture/strong.png',
},
: {
name: '拥抱',
category: EmojiCategory.GESTURE,
path: '/assets/gesture/hug.png',
},
: {
name: '弱',
category: EmojiCategory.GESTURE,
path: '/assets/gesture/weak.png',
},
// 动物表情
: {
name: '猪头',
category: EmojiCategory.ANIMAL,
path: '/assets/animal/pig-head.png',
},
: {
name: '跳跳',
category: EmojiCategory.ANIMAL,
path: '/assets/animal/jump.png',
},
: {
name: '发抖',
category: EmojiCategory.ANIMAL,
path: '/assets/animal/shiver.png',
},
: {
name: '转圈',
category: EmojiCategory.ANIMAL,
path: '/assets/animal/spin.png',
},
// 祝福表情
: {
name: '庆祝',
category: EmojiCategory.BLESSING,
path: '/assets/blessing/celebrate.png',
},
: {
name: '礼物',
category: EmojiCategory.BLESSING,
path: '/assets/blessing/gift.png',
},
: {
name: '红包',
category: EmojiCategory.BLESSING,
path: '/assets/blessing/red-envelope.png',
},
: {
name: '發',
category: EmojiCategory.BLESSING,
path: '/assets/blessing/fortune.png',
},
: {
name: '福',
category: EmojiCategory.BLESSING,
path: '/assets/blessing/blessing.png',
},
: {
name: '烟花',
category: EmojiCategory.BLESSING,
path: '/assets/blessing/fireworks.png',
},
: {
name: '爆竹',
category: EmojiCategory.BLESSING,
path: '/assets/blessing/firecrackers.png',
},
// 其他表情
: {
name: '嘴唇',
category: EmojiCategory.OTHER,
path: '/assets/other/lips.png',
},
: {
name: '爱心',
category: EmojiCategory.OTHER,
path: '/assets/other/heart.png',
},
: {
name: '心碎',
category: EmojiCategory.OTHER,
path: '/assets/other/broken-heart.png',
},
: {
name: '啤酒',
category: EmojiCategory.OTHER,
path: '/assets/other/beer.png',
},
: {
name: '咖啡',
category: EmojiCategory.OTHER,
path: '/assets/other/coffee.png',
},
: {
name: '蛋糕',
category: EmojiCategory.OTHER,
path: '/assets/other/cake.png',
},
: {
name: '凋谢',
category: EmojiCategory.OTHER,
path: '/assets/other/wither.png',
},
: {
name: '菜刀',
category: EmojiCategory.OTHER,
path: '/assets/other/knife.png',
},
: {
name: '炸弹',
category: EmojiCategory.OTHER,
path: '/assets/other/bomb.png',
},
便便: {
name: '便便',
category: EmojiCategory.OTHER,
path: '/assets/other/poop.png',
},
: {
name: '太阳',
category: EmojiCategory.OTHER,
path: '/assets/other/sun.png',
},
: {
name: '月亮',
category: EmojiCategory.OTHER,
path: '/assets/other/moon.png',
},
: {
name: '玫瑰',
category: EmojiCategory.OTHER,
path: '/assets/other/rose.png',
},
}
/**
* 获取所有表情数据的辅助函数
*/
function getAllEmojiData(): EmojiInfo[] {
const result: EmojiInfo[] = []
for (const key in EMOJI_DATA) {
if (Object.prototype.hasOwnProperty.call(EMOJI_DATA, key)) {
result.push(EMOJI_DATA[key as EmojiName])
}
}
return result
}
/**
* 按类别分组的表情数据
*/
export const EMOJI_CATEGORIES = {
[EmojiCategory.FACE]: getAllEmojiData().filter(
(emoji) => emoji.category === EmojiCategory.FACE
),
[EmojiCategory.GESTURE]: getAllEmojiData().filter(
(emoji) => emoji.category === EmojiCategory.GESTURE
),
[EmojiCategory.ANIMAL]: getAllEmojiData().filter(
(emoji) => emoji.category === EmojiCategory.ANIMAL
),
[EmojiCategory.BLESSING]: getAllEmojiData().filter(
(emoji) => emoji.category === EmojiCategory.BLESSING
),
[EmojiCategory.OTHER]: getAllEmojiData().filter(
(emoji) => emoji.category === EmojiCategory.OTHER
),
} as const
/**
* 获取表情图片路径
* @param name 表情名称
* @returns 图片路径,如果表情不存在则返回 null
*
* @example
* ```typescript
* const path = getEmojiPath('微笑'); // 'assets/face/微笑.png'
* const invalidPath = getEmojiPath('不存在'); // null
* ```
*/
export function getEmojiPath(name: EmojiName): string | null {
const emoji = EMOJI_DATA[name]
return emoji ? emoji.path : null
}
/**
* 获取表情信息
* @param name 表情名称
* @returns 表情信息对象,如果表情不存在则返回 null
*
* @example
* ```typescript
* const emoji = getEmojiInfo('微笑');
* // { name: '微笑', category: EmojiCategory.FACE, path: 'assets/face/微笑.png' }
* ```
*/
export function getEmojiInfo(name: EmojiName): EmojiInfo | null {
return EMOJI_DATA[name] || null
}
/**
* 根据类别获取表情列表
* @param category 表情类别
* @returns 该类别下的所有表情信息
*
* @example
* ```typescript
* const faceEmojis = getEmojisByCategory(EmojiCategory.FACE);
* ```
*/
export function getEmojisByCategory(category: EmojiCategory): EmojiInfo[] {
return EMOJI_CATEGORIES[category]
}
/**
* 获取所有表情信息
* @returns 所有表情的信息数组
*
* @example
* ```typescript
* const allEmojis = getAllEmojis();
* console.log(`总共有 ${allEmojis.length} 个表情`);
* ```
*/
export function getAllEmojis(): EmojiInfo[] {
return getAllEmojiData()
}
/**
* 搜索表情
* @param keyword 搜索关键词
* @returns 匹配的表情信息数组
*
* @example
* ```typescript
* const results = searchEmojis('笑');
* // 返回包含 '微笑', '偷笑', '坏笑' 等的表情
* ```
*/
export function searchEmojis(keyword: string): EmojiInfo[] {
return getAllEmojiData().filter((emoji) => emoji.name.indexOf(keyword) !== -1)
}
/**
* 检查表情是否存在
* @param name 表情名称
* @returns 是否存在该表情
*
* @example
* ```typescript
* const exists = hasEmoji('微笑'); // true
* const notExists = hasEmoji('不存在的表情'); // false
* ```
*/
export function hasEmoji(name: EmojiName): boolean {
return name in EMOJI_DATA
}
/**
* 获取表情名称列表
* @param category 可选的类别筛选
* @returns 表情名称数组
*
* @example
* ```typescript
* const allNames = getEmojiNames();
* const faceNames = getEmojiNames(EmojiCategory.FACE);
* ```
*/
export function getEmojiNames(category?: EmojiCategory): string[] {
if (category) {
return getEmojisByCategory(category).map((emoji) => emoji.name)
}
const names: string[] = []
for (const key in EMOJI_DATA) {
if (Object.prototype.hasOwnProperty.call(EMOJI_DATA, key)) {
names.push(key)
}
}
return names
}
/**
* 随机获取表情
* @param category 可选的类别筛选
* @returns 随机表情信息
*
* @example
* ```typescript
* const randomEmoji = getRandomEmoji();
* const randomFaceEmoji = getRandomEmoji(EmojiCategory.FACE);
* ```
*/
export function getRandomEmoji(category?: EmojiCategory): EmojiInfo {
const emojis = category ? getEmojisByCategory(category) : getAllEmojis()
const randomIndex = Math.floor(Math.random() * emojis.length)
return emojis[randomIndex]
}
/**
* 根据路径查找表情名称
* @param path 表情图片路径
* @returns 表情名称,如果未找到则返回 null
*
* @example
* ```typescript
* const name = getEmojiNameByPath('/face/smile.png'); // '微笑'
* ```
*/
export function getEmojiNameByPath(path: string): string | null {
if (!path) return null
// 规范化路径(移除开头的 / 和 /assets/
const normalizedPath = path.replace(/^\/assets?\//, '/')
// 遍历所有表情数据查找匹配的路径
for (const key in EMOJI_DATA) {
if (Object.prototype.hasOwnProperty.call(EMOJI_DATA, key)) {
const emoji = EMOJI_DATA[key as EmojiName]
if (emoji.path === normalizedPath || emoji.path === path) {
return emoji.name
}
}
}
return null
}
/**
* 默认导出对象,包含所有主要功能
*/
const WeChatEmojis = {
// 枚举和类型
EmojiCategory,
// 数据
EMOJI_CATEGORIES,
// 工具函数
getEmojiPath,
getEmojiInfo,
getEmojisByCategory,
getAllEmojis,
searchEmojis,
hasEmoji,
getEmojiNames,
getRandomEmoji,
} as const
export default WeChatEmojis

View File

@@ -1,46 +0,0 @@
<template>
<div class="not-found-page">
<div class="not-found-content">
<h1>404</h1>
<p>页面不存在</p>
<el-button type="primary" @click="goHome">返回首页</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
const goHome = () => {
router.push('/chat')
}
</script>
<style scoped lang="scss">
.not-found-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
}
.not-found-content {
text-align: center;
h1 {
font-size: 72px;
font-weight: bold;
color: #409eff;
margin-bottom: 20px;
}
p {
font-size: 18px;
color: #666;
margin-bottom: 30px;
}
}
</style>

View File

@@ -1,168 +0,0 @@
<template>
<div class="account-list">
<div class="account-list-header">
<div class="header-title">微信号</div>
</div>
<el-scrollbar class="account-list-body">
<!-- 全部账号 -->
<div
class="account-item"
:class="{ active: currentAccount?.id === 0 }"
@click="handleSelectAccount(0)"
>
<el-badge :value="totalUnreadCount" :hidden="totalUnreadCount === 0" :max="99">
<div class="account-all">全部</div>
</el-badge>
</div>
<!-- 账号列表 -->
<div
v-for="account in accountList"
:key="account.id"
class="account-item"
:class="{ active: currentAccount?.id === account.id, offline: !account?.isOnline }"
@click="handleSelectAccount(account?.id)"
>
<el-badge :value="getUnreadCount(account?.id || 0)" :hidden="getUnreadCount(account?.id || 0) === 0" :max="99">
<div class="account-avatar-wrapper">
<el-avatar :src="account?.avatar || ''" :size="50">
{{ account?.name?.charAt(0) || '?' }}
</el-avatar>
<span v-if="account?.isOnline" class="online-indicator" />
</div>
</el-badge>
</div>
<!-- 加载中骨架 -->
<template v-if="loading">
<div v-for="i in 3" :key="`skeleton-${i}`" class="account-item-skeleton">
<el-skeleton animated>
<template #template>
<el-skeleton-item variant="circle" style="width: 50px; height: 50px" />
</template>
</el-skeleton>
</div>
</template>
</el-scrollbar>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useAccountStore, useSessionStore, useContactStore } from '@/stores/modules/wechat'
const accountStore = useAccountStore()
const sessionStore = useSessionStore()
const contactStore = useContactStore()
const { accountList, currentAccount, totalUnreadCount, loading } = storeToRefs(accountStore)
/**
* 获取账号未读数
*/
const getUnreadCount = (accountId: number) => {
return accountStore.getUnreadCount(accountId)
}
/**
* 选择账号
*/
const handleSelectAccount = async (accountId: number) => {
if (accountId === 0) {
accountStore.switchToAllAccounts()
} else {
accountStore.switchAccount(accountId)
}
// 切换账号时重新加载会话列表和联系人列表
await Promise.all([
sessionStore.switchAccount(accountId),
contactStore.switchAccount(accountId),
])
}
</script>
<style scoped lang="scss">
.account-list {
display: flex;
flex-direction: column;
height: 100%;
&-header {
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.header-title {
color: #fff;
font-size: 14px;
text-align: center;
}
}
&-body {
flex: 1;
padding: 16px 0;
}
}
.account-item {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
&.active {
background-color: rgba(255, 255, 255, 0.2);
}
&.offline {
opacity: 0.5;
}
.account-all {
width: 50px;
height: 50px;
line-height: 50px;
text-align: center;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #fff;
font-size: 14px;
}
.account-avatar-wrapper {
position: relative;
.online-indicator {
position: absolute;
bottom: 2px;
right: 2px;
width: 12px;
height: 12px;
background-color: #67c23a;
border: 2px solid #2e2e2e;
border-radius: 50%;
}
}
}
.account-item-skeleton {
display: flex;
justify-content: center;
align-items: center;
padding: 12px 0;
:deep(.el-skeleton) {
display: flex;
justify-content: center;
align-items: center;
}
}
</style>

View File

@@ -1,87 +0,0 @@
<template>
<el-dropdown trigger="click" @command="handleCommand">
<el-button :type="buttonType" :icon="buttonIcon">
{{ currentLabel }}
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="option in aiTypeOptions"
:key="option.value"
:command="option.value"
:disabled="option.value === modelValue"
>
<el-icon>
<component :is="option.icon" />
</el-icon>
{{ option.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
ArrowDown,
User,
MagicStick,
Setting,
} from '@element-plus/icons-vue'
interface AITypeOption {
value: 0 | 1 | 2
label: string
icon: any
type: 'default' | 'warning' | 'success'
}
const props = defineProps<{
modelValue: 0 | 1 | 2 // 0-人工 1-AI辅助 2-AI接管
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: 0 | 1 | 2): void
}>()
const aiTypeOptions: AITypeOption[] = [
{
value: 0,
label: '人工接待',
icon: User,
type: 'default',
},
{
value: 1,
label: 'AI辅助',
icon: MagicStick,
type: 'warning',
},
{
value: 2,
label: 'AI接管',
icon: Setting,
type: 'success',
},
]
const currentOption = computed(() => {
return aiTypeOptions.find(opt => opt.value === props.modelValue) || aiTypeOptions[0]
})
const currentLabel = computed(() => currentOption.value.label)
const buttonType = computed(() => currentOption.value.type)
const buttonIcon = computed(() => currentOption.value.icon)
const handleCommand = (command: 0 | 1 | 2) => {
emit('update:modelValue', command)
}
</script>
<style scoped lang="scss">
.el-icon--right {
margin-left: 4px;
}
</style>

View File

@@ -1,185 +0,0 @@
<template>
<div class="chat-header">
<!-- 左侧用户信息 -->
<div class="header-info">
<el-avatar :src="currentSession?.avatar || ''" :size="40">
<el-icon v-if="isGroup">
<UserFilled />
</el-icon>
<span v-else>
{{
currentSession?.nickname?.charAt(0) ||
currentSession?.conRemark?.charAt(0) ||
'?'
}}
</span>
</el-avatar>
<div class="header-details">
<div class="header-name">
{{ displayName }}
</div>
<div class="header-meta">
<el-tag v-if="isGroup" size="small" type="info"> 群聊 </el-tag>
<span v-if="isOnline" class="online-status">
<el-icon color="#67c23a"><CircleCheckFilled /></el-icon>
在线
</span>
</div>
</div>
</div>
<!-- 右侧操作按钮 -->
<div class="header-actions">
<!-- AI 模式切换 -->
<AITypeSelector
:model-value="currentAIType"
@update:model-value="handleAITypeChange"
/>
<!-- 快捷操作按钮 -->
<el-tooltip content="跟进提醒" placement="bottom">
<el-button :icon="Bell" circle @click="handleFollowUpClick" />
</el-tooltip>
<el-tooltip content="待办事项" placement="bottom">
<el-button :icon="Checked" circle @click="handleTodoClick" />
</el-tooltip>
<el-tooltip content="客户资料" placement="bottom">
<el-button :icon="User" type="primary" @click="handleProfileClick" />
</el-tooltip>
</div>
<!-- 跟进提醒对话框 -->
<FollowUpReminder v-model="showFollowUpDialog" />
<!-- 待办事项对话框 -->
<TodoList v-model="showTodoDialog" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { storeToRefs } from 'pinia'
import {
UserFilled,
CircleCheckFilled,
Bell,
Checked,
User,
} from '@element-plus/icons-vue'
import {
useSessionStore,
useChatWindowStore,
useUIStore,
} from '@/stores/modules/wechat'
import AITypeSelector from './components/AITypeSelector.vue'
import FollowUpReminder from '../FollowUpReminder/index.vue'
import TodoList from '../TodoList/index.vue'
const sessionStore = useSessionStore()
const chatWindowStore = useChatWindowStore()
const { currentAIType } = storeToRefs(chatWindowStore)
const { currentSession } = storeToRefs(sessionStore)
// 对话框状态
const showFollowUpDialog = ref(false)
const showTodoDialog = ref(false)
// 计算属性
const isGroup = computed(() => currentSession.value?.type === 'group')
const displayName = computed(() => {
const session = currentSession.value
if (!session) return '未知'
return session.conRemark || session.nickname || '未知'
})
const isOnline = computed(() => {
// TODO: 实现在线状态判断
return false
})
// 事件处理
const handleAITypeChange = async (type: 0 | 1 | 2) => {
const sessionId = currentSession.value?.id
if (!sessionId) return
try {
await chatWindowStore.updateAIType(sessionId, type)
} catch (error) {
// 错误已在 Store 中处理
}
}
const handleFollowUpClick = () => {
showFollowUpDialog.value = true
}
const handleTodoClick = () => {
showTodoDialog.value = true
}
const handleProfileClick = () => {
chatWindowStore.toggleProfileCard()
}
</script>
<style scoped lang="scss">
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid var(--el-border-color-light);
min-height: 72px;
.header-info {
display: flex;
align-items: center;
flex: 1;
min-width: 0; // 防止 flex 溢出
.header-details {
margin-left: 12px;
flex: 1;
min-width: 0;
.header-name {
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
.online-status {
display: flex;
align-items: center;
gap: 4px;
color: #67c23a;
}
}
}
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
</style>

View File

@@ -1,357 +0,0 @@
<template>
<el-dialog
v-model="visible"
title="跟进提醒设置"
width="600px"
:close-on-click-modal="false"
>
<template #header>
<div class="dialog-header">
<div class="dialog-title">跟进提醒设置</div>
<div class="dialog-subtitle">设置客户跟进时间和方式</div>
</div>
</template>
<div class="reminder-content">
<!-- 添加新提醒区域 -->
<div class="add-section">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<div class="form-row">
<el-form-item label="跟进方式" prop="type" class="form-item">
<el-select v-model="form.type" placeholder="请选择">
<el-option
v-for="method in followupMethods"
:key="method.value"
:label="method.label"
:value="method.value"
/>
</el-select>
</el-form-item>
<el-form-item
label="提醒时间"
prop="reminderTime"
class="form-item"
>
<el-date-picker
v-model="form.reminderTime"
type="datetime"
placeholder="选择日期时间"
format="YYYY/MM/DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
</div>
<el-form-item label="提醒内容" prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="3"
placeholder="请输入提醒内容..."
/>
</el-form-item>
<el-button
type="primary"
:icon="Plus"
:loading="addLoading"
@click="handleAddReminder"
style="width: 100%"
>
添加提醒
</el-button>
</el-form>
</div>
<!-- 现有提醒列表 -->
<div class="reminder-list">
<el-scrollbar max-height="300px">
<el-skeleton :loading="loading" animated :rows="3">
<el-empty
v-if="reminders.length === 0"
description="暂无跟进提醒"
/>
<div v-else>
<div
v-for="reminder in reminders"
:key="reminder.id"
class="reminder-item"
>
<div class="reminder-header">
<el-space>
<el-tag :icon="getTypeIcon(reminder.type)" type="primary">
{{ reminder.type }}
</el-tag>
<el-tag :type="getStatusType(reminder.status)">
{{ reminder.status }}
</el-tag>
</el-space>
</div>
<div class="reminder-body">
{{ reminder.content }}
</div>
<div class="reminder-footer">
<el-space>
<el-icon><Clock /></el-icon>
<span class="time">{{ reminder.scheduledTime }}</span>
<el-button
v-if="reminder.status === '待处理'"
type="primary"
size="small"
link
@click="handleProcessReminder(reminder.id)"
>
处理
</el-button>
</el-space>
</div>
</div>
</div>
</el-skeleton>
</el-scrollbar>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Plus, Clock, Phone, Message } from '@element-plus/icons-vue'
import {
getFollowUpList,
addFollowUp,
processFollowUp,
} from '@/api/modules/wechat'
import { storeToRefs } from 'pinia'
import { useSessionStore } from '@/stores/modules/wechat'
const sessionStore = useSessionStore()
const { currentSession } = storeToRefs(sessionStore)
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
// 类型映射
const typeMap: Record<string, string> = {
'1': '电话',
'2': '消息',
'3': '会议',
'4': '邮件',
'0': '其他',
}
// 跟进方式选项
const followupMethods = [
{ value: '1', label: '电话回访' },
{ value: '2', label: '发送消息' },
{ value: '3', label: '安排会议' },
{ value: '4', label: '发送邮件' },
{ value: '0', label: '其他' },
]
interface Reminder {
id: string
type: string
status: string
content: string
scheduledTime: string
recipient: string
}
const formRef = ref<FormInstance>()
const form = ref({
type: '1',
reminderTime: '',
content: '',
})
const rules: FormRules = {
type: [{ required: true, message: '请选择跟进方式', trigger: 'change' }],
reminderTime: [
{ required: true, message: '请选择提醒时间', trigger: 'change' },
],
content: [{ required: true, message: '请输入提醒内容', trigger: 'blur' }],
}
const reminders = ref<Reminder[]>([])
const loading = ref(false)
const addLoading = ref(false)
// 加载跟进提醒列表
const loadFollowUpList = async () => {
if (!currentSession.value) return
loading.value = true
try {
const response = await getFollowUpList({
friendId: currentSession.value.id.toString(),
limit: '50',
page: '1',
})
if (response && response.list) {
reminders.value = response.list.map((item: any) => ({
id: item.id?.toString() || '',
type: typeMap[item.type] || '其他',
status: item.isProcess === 1 ? '已完成' : '待处理',
content: item.description || item.title || '',
scheduledTime: item.reminderTime || '',
recipient: currentSession.value?.nickname || '客户',
}))
}
} catch (error) {
console.error('加载跟进提醒列表失败:', error)
ElMessage.error('加载跟进提醒列表失败')
} finally {
loading.value = false
}
}
// 添加提醒
const handleAddReminder = async () => {
if (!currentSession.value || !formRef.value) return
try {
await formRef.value.validate()
addLoading.value = true
const params = {
friendId: currentSession.value.id.toString(),
type: form.value.type,
title: form.value.content,
description: form.value.content,
reminderTime: form.value.reminderTime,
}
await addFollowUp(params)
ElMessage.success('添加跟进提醒成功')
// 重置表单
formRef.value.resetFields()
// 重新加载列表
await loadFollowUpList()
} catch (error: any) {
if (error !== 'cancel') {
console.error('添加跟进提醒失败:', error)
ElMessage.error('添加跟进提醒失败')
}
} finally {
addLoading.value = false
}
}
// 处理提醒
const handleProcessReminder = async (id: string) => {
try {
await processFollowUp({ ids: id })
ElMessage.success('处理成功')
await loadFollowUpList()
} catch (error) {
console.error('处理跟进提醒失败:', error)
ElMessage.error('处理跟进提醒失败')
}
}
// 获取状态类型
const getStatusType = (status: string) => {
return status === '待处理' ? 'warning' : 'success'
}
// 获取类型图标
const getTypeIcon = (type: string) => {
return type === '电话' ? Phone : Message
}
// 监听对话框打开
watch(visible, (val) => {
if (val) {
loadFollowUpList()
}
})
</script>
<style scoped lang="scss">
.dialog-header {
.dialog-title {
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.dialog-subtitle {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
}
.reminder-content {
.add-section {
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.form-item {
margin-bottom: 18px;
}
}
}
.reminder-list {
margin-top: 16px;
.reminder-item {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.reminder-header {
margin-bottom: 8px;
}
.reminder-body {
font-size: 14px;
color: var(--el-text-color-regular);
margin-bottom: 8px;
line-height: 1.5;
}
.reminder-footer {
display: flex;
align-items: center;
font-size: 12px;
color: var(--el-text-color-secondary);
.time {
color: var(--el-text-color-secondary);
}
}
}
}
}
</style>

View File

@@ -1,274 +0,0 @@
<template>
<div class="message-content">
<!-- 文本消息 -->
<div v-if="isTextMessage" class="text-message" v-html="parsedContent" />
<!-- 图片消息 -->
<div v-else-if="isImageMessage" class="image-message">
<el-image
:src="message.content"
:preview-src-list="[message.content]"
fit="cover"
style="max-width: 200px; max-height: 200px; border-radius: 4px"
/>
</div>
<!-- 语音消息 -->
<div v-else-if="isAudioMessage" class="audio-message">
<el-icon><Headset /></el-icon>
<span>语音消息</span>
</div>
<!-- 视频消息 -->
<div v-else-if="isVideoMessage" class="video-message">
<el-icon><VideoPlay /></el-icon>
<span>视频消息</span>
</div>
<!-- 文件消息 -->
<div v-else-if="isFileMessage" class="file-message">
<el-icon class="file-icon"><Document /></el-icon>
<div class="file-info">
<div class="file-name" :title="fileName">
{{ fileName }}
</div>
<el-button
text
type="primary"
size="small"
@click="handleDownloadFile"
>
下载
</el-button>
</div>
</div>
<!-- 位置消息 -->
<div v-else-if="isLocationMessage" class="location-message">
<el-icon><Location /></el-icon>
<span>位置消息</span>
</div>
<!-- 撤回消息 -->
<div v-else-if="message.recalled" class="recalled-message">
{{ message.direction === 'send' ? '' : '对方' }}撤回了一条消息
</div>
<!-- 系统消息 -->
<div v-else-if="isSystemMessage" class="system-message">
{{ message.content }}
</div>
<!-- 未知类型 -->
<div v-else class="unknown-message">
<el-icon><QuestionFilled /></el-icon>
<span>暂不支持的消息类型</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
Headset,
VideoPlay,
Document,
Location,
QuestionFilled,
} from '@element-plus/icons-vue'
import type { Message } from '@/types/wechat'
import { parseEmojiText } from '@/utils/emoji'
interface Props {
message: Message
}
const props = defineProps<Props>()
// ==================== 消息类型判断 ====================
// 消息类型常量
const MSG_TYPE = {
TEXT: 1, // 文本
IMAGE: 3, // 图片
AUDIO: 34, // 语音
VIDEO: 43, // 视频
FILE: 49, // 文件
LOCATION: 48, // 位置
SYSTEM: 10000, // 系统
}
const isTextMessage = computed(() => props.message.msgType === MSG_TYPE.TEXT)
const isImageMessage = computed(() => props.message.msgType === MSG_TYPE.IMAGE)
const isAudioMessage = computed(() => props.message.msgType === MSG_TYPE.AUDIO)
const isVideoMessage = computed(() => props.message.msgType === MSG_TYPE.VIDEO)
const isFileMessage = computed(() => props.message.msgType === MSG_TYPE.FILE)
const isLocationMessage = computed(() => props.message.msgType === MSG_TYPE.LOCATION)
const isSystemMessage = computed(() => props.message.msgType === MSG_TYPE.SYSTEM)
/**
* 文件名(从 URL 提取)
*/
const fileName = computed(() => {
const url = props.message.content || ''
if (!url) return '未知文件'
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const name = pathname.split('/').pop() || '未知文件'
return decodeURIComponent(name)
} catch {
// 如果不是完整 URL尝试从路径提取
const name = url.split('/').pop() || url.split('\\').pop() || '未知文件'
return decodeURIComponent(name)
}
})
/**
* 下载文件
*/
const handleDownloadFile = () => {
const url = props.message.content
if (!url) return
// 创建临时链接下载
const link = document.createElement('a')
link.href = url
link.download = fileName.value
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// ==================== 内容解析 ====================
/**
* 解析文本消息
* - 转义 HTML
* - 解析表情
* - 解析链接
* - 解析 @ 提及
*/
const parsedContent = computed(() => {
if (!isTextMessage.value) return ''
let content = props.message.content || ''
// 1. HTML 转义
content = escapeHtml(content)
// 2. 解析表情(微信表情)
content = parseEmojiText(content)
// 3. 解析链接
content = parseLinks(content)
// 4. 解析 @ 提及
// TODO: 实现 @ 解析
// content = parseMentions(content)
// 5. 换行符转 <br>
content = content.replace(/\n/g, '<br>')
return content
})
/**
* HTML 转义
*/
const escapeHtml = (text: string): string => {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
}
return text.replace(/[&<>"']/g, m => map[m])
}
/**
* 解析链接
*/
const parseLinks = (text: string): string => {
const urlRegex = /(https?:\/\/[^\s]+)/g
return text.replace(
urlRegex,
'<a href="$1" target="_blank" style="color: #409eff; text-decoration: underline;">$1</a>'
)
}
</script>
<style scoped lang="scss">
.message-content {
.text-message {
:deep(a) {
color: #409eff;
text-decoration: underline;
&:hover {
opacity: 0.8;
}
}
:deep(.emoji-image) {
width: 20px;
height: 20px;
margin: 0 2px;
display: inline-block;
vertical-align: middle;
}
}
.audio-message,
.video-message,
.location-message,
.unknown-message {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
.el-icon {
font-size: 18px;
}
}
.file-message {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
min-width: 200px;
.file-icon {
font-size: 24px;
color: var(--el-color-primary);
}
.file-info {
flex: 1;
min-width: 0;
.file-name {
font-size: 14px;
color: var(--el-text-color-primary);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.recalled-message,
.system-message {
color: var(--el-text-color-secondary);
font-style: italic;
}
}
</style>

View File

@@ -1,252 +0,0 @@
<template>
<div
class="message-item"
:class="{
'message-send': isSend,
'message-receive': !isSend,
}"
@contextmenu.prevent="handleContextMenu"
>
<!-- 接收消息左侧布局 -->
<template v-if="!isSend">
<!-- 头像 -->
<el-avatar :src="senderAvatar" :size="40" class="message-avatar">
<el-icon><User /></el-icon>
</el-avatar>
<div class="message-content-wrapper">
<!-- 发送者昵称群聊时显示 -->
<div v-if="isGroupChat" class="message-sender">
{{ senderName }}
</div>
<!-- 消息气泡 -->
<div class="message-bubble receive-bubble">
<MessageContent :message="message" />
</div>
</div>
</template>
<!-- 发送消息右侧布局 -->
<template v-else>
<!-- 消息状态 -->
<div v-if="message.status" class="message-status">
<el-icon v-if="message.status === 'sending'" class="is-loading">
<Loading />
</el-icon>
<el-tooltip v-else-if="message.status === 'failed'" content="发送失败,点击重试">
<el-icon color="#f56c6c" class="clickable">
<CircleClose />
</el-icon>
</el-tooltip>
<el-icon v-else-if="message.status === 'success'" color="#67c23a">
<CircleCheck />
</el-icon>
</div>
<div class="message-content-wrapper">
<!-- 消息气泡 -->
<div class="message-bubble send-bubble">
<MessageContent :message="message" />
</div>
</div>
<!-- 头像 -->
<el-avatar :src="senderAvatar" :size="40" class="message-avatar">
<el-icon><User /></el-icon>
</el-avatar>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { User, Loading, CircleClose, CircleCheck } from '@element-plus/icons-vue'
import { useSessionStore, useAccountStore } from '@/stores/modules/wechat'
import MessageContent from './MessageContent.vue'
import type { Message } from '@/types/wechat'
interface Props {
message: Message
isGroupChat?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'contextMenu', message: Message): void
}>()
const sessionStore = useSessionStore()
const accountStore = useAccountStore()
// ==================== 计算属性 ====================
/**
* 是否为发送消息
*/
const isSend = computed(() => props.message.direction === 'send')
/**
* 发送者头像
*/
const senderAvatar = computed(() => {
if (isSend.value) {
// 自己的头像:根据 wechatAccountId 从账号列表获取
const wechatAccountId = props.message.wechatAccountId
if (wechatAccountId) {
const account = accountStore.accountList.find(
(acc) => acc.id === wechatAccountId
)
if (account?.avatar) {
return account.avatar
}
}
// 如果没有找到,使用当前账号的头像
return accountStore.currentAccount?.avatar || ''
} else {
// 对方的头像
return sessionStore.currentSession?.avatar || ''
}
})
/**
* 发送者昵称
*/
const senderName = computed(() => {
if (isSend.value) {
return '我'
} else {
// 群聊时显示发送者昵称
return props.message.sender?.nickname || '未知'
}
})
// ==================== 事件处理 ====================
/**
* 右键菜单
*/
const handleContextMenu = () => {
emit('contextMenu', props.message)
}
</script>
<style scoped lang="scss">
.message-item {
display: flex;
margin-bottom: 16px;
animation: fadeIn 0.3s ease-in-out;
&.message-send {
flex-direction: row;
justify-content: flex-end;
}
&.message-receive {
flex-direction: row;
justify-content: flex-start;
}
.message-avatar {
flex-shrink: 0;
}
.message-content-wrapper {
max-width: 60%;
margin: 0 12px;
display: flex;
flex-direction: column;
}
.message-sender {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
padding: 0 4px;
}
.message-bubble {
padding: 10px 14px;
border-radius: 8px;
word-break: break-word;
font-size: 14px;
line-height: 1.6;
position: relative;
&.receive-bubble {
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
// 左侧小三角
&::before {
content: '';
position: absolute;
left: -6px;
top: 12px;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-right: 6px solid #fff;
}
}
&.send-bubble {
background: #95ec69;
// 右侧小三角
&::before {
content: '';
position: absolute;
right: -6px;
top: 12px;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-left: 6px solid #95ec69;
}
}
}
.message-status {
display: flex;
align-items: center;
justify-content: center;
margin: 0 8px;
flex-shrink: 0;
.is-loading {
animation: rotating 1s linear infinite;
}
.clickable {
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,30 +0,0 @@
<template>
<div class="time-message">
<div class="time-text">{{ time }}</div>
</div>
</template>
<script setup lang="ts">
interface Props {
time: string
}
defineProps<Props>()
</script>
<style scoped lang="scss">
.time-message {
display: flex;
justify-content: center;
align-items: center;
margin: 16px 0;
.time-text {
padding: 4px 12px;
background: rgba(0, 0, 0, 0.1);
border-radius: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
</style>

View File

@@ -1,429 +0,0 @@
<template>
<div class="message-list">
<!-- 加载状态 -->
<div v-if="loading && messages.length === 0" class="loading-container">
<el-skeleton animated :rows="5" />
</div>
<!-- 消息列表 -->
<el-scrollbar
v-else
ref="scrollbarRef"
class="message-scrollbar"
@scroll="handleScroll"
>
<div ref="messageContainerRef" class="message-container">
<!-- 加载更多按钮 -->
<div v-if="hasMore" class="load-more">
<el-button
:loading="loading"
text
type="primary"
size="small"
@click="handleLoadMore"
>
加载更多
</el-button>
</div>
<!-- 消息项 -->
<div
v-for="(group, groupIndex) in groupedMessages"
:key="`group-${groupIndex}`"
class="message-group"
>
<!-- 时间分隔 -->
<TimeMessage :time="group.time" />
<!-- 消息列表 -->
<MessageItem
v-for="message in group.messages"
:key="message.id"
:message="message"
:is-group-chat="isGroupChat"
@context-menu="handleContextMenu"
/>
</div>
<!-- 底部锚点用于自动滚动 -->
<div ref="bottomRef" class="message-bottom" />
</div>
</el-scrollbar>
<!-- 空状态 -->
<div v-if="!loading && messages.length === 0" class="empty-container">
<el-empty description="暂无消息" :image-size="100" />
</div>
<!-- 新消息提示 -->
<transition name="fade">
<div
v-if="showNewMessageTip"
class="new-message-tip"
@click="scrollToBottom"
>
<el-icon><ArrowDown /></el-icon>
有新消息
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { ArrowDown } from '@element-plus/icons-vue'
import { useSessionStore, useMessageStore } from '@/stores/modules/wechat'
import { useMessageSubscription } from '@/composables/business/wechat'
import MessageItem from './components/MessageItem.vue'
import TimeMessage from './components/TimeMessage.vue'
import type { Message } from '@/types/wechat'
// ==================== Store ====================
const sessionStore = useSessionStore()
const messageStore = useMessageStore()
const { onNewMessage, onMessageUpdate } = useMessageSubscription()
const { currentSession } = storeToRefs(sessionStore)
const { currentMessages, loading, hasMore } = storeToRefs(messageStore)
// ==================== 引用 ====================
const scrollbarRef = ref()
const messageContainerRef = ref<HTMLElement>()
const bottomRef = ref<HTMLElement>()
// ==================== 状态 ====================
const showNewMessageTip = ref(false)
const isAtBottom = ref(true)
const userScrolling = ref(false)
// ==================== 计算属性 ====================
/**
* 是否为群聊
*/
const isGroupChat = computed(() => currentSession.value?.type === 'group')
/**
* 当前消息列表
*/
const messages = computed(() => currentMessages.value)
/**
* 消息分组(按时间)
*/
const groupedMessages = computed(() => {
const groups: Array<{ time: string; messages: Message[] }> = []
const msgs = messages.value
if (msgs.length === 0) return groups
let currentGroup: Message[] = []
let lastTime = ''
msgs.forEach((msg, index) => {
const msgTime = formatMessageTime(msg.createTime)
// 如果时间不同或者是第一条消息,创建新分组
if (msgTime !== lastTime || index === 0) {
if (currentGroup.length > 0) {
groups.push({
time: lastTime,
messages: currentGroup,
})
}
currentGroup = [msg]
lastTime = msgTime
} else {
currentGroup.push(msg)
}
})
// 添加最后一组
if (currentGroup.length > 0) {
groups.push({
time: lastTime,
messages: currentGroup,
})
}
return groups
})
// ==================== 时间格式化 ====================
/**
* 格式化消息时间
* 规则:
* - 今天HH:mm
* - 昨天:昨天 HH:mm
* - 今年MM-DD HH:mm
* - 往年YYYY-MM-DD HH:mm
*/
const formatMessageTime = (timeStr: string): string => {
if (!timeStr) return ''
const msgDate = new Date(timeStr)
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000)
const thisYear = now.getFullYear()
const formatTime = (date: Date) => {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
const formatDate = (date: Date, includeYear = false) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return includeYear
? `${year}-${month}-${day} ${formatTime(date)}`
: `${month}-${day} ${formatTime(date)}`
}
if (msgDate >= today) {
return formatTime(msgDate)
} else if (msgDate >= yesterday) {
return `昨天 ${formatTime(msgDate)}`
} else if (msgDate.getFullYear() === thisYear) {
return formatDate(msgDate, false)
} else {
return formatDate(msgDate, true)
}
}
/**
* 判断两条消息时间是否应该显示分隔超过5分钟
*/
const shouldShowTimeDivider = (
prevTime: string,
currentTime: string
): boolean => {
const prev = new Date(prevTime).getTime()
const curr = new Date(currentTime).getTime()
return curr - prev > 5 * 60 * 1000 // 5分钟
}
// ==================== 滚动相关 ====================
/**
* 滚动到底部
*/
const scrollToBottom = (smooth = true) => {
nextTick(() => {
if (scrollbarRef.value?.wrapRef) {
const wrap = scrollbarRef.value.wrapRef
wrap.scrollTo({
top: wrap.scrollHeight,
behavior: smooth ? 'smooth' : 'auto',
})
isAtBottom.value = true
showNewMessageTip.value = false
}
})
}
/**
* 处理滚动事件
*/
const handleScroll = () => {
if (!scrollbarRef.value?.wrapRef) return
const wrap = scrollbarRef.value.wrapRef
const scrollTop = wrap.scrollTop
const scrollHeight = wrap.scrollHeight
const clientHeight = wrap.clientHeight
// 判断是否在底部允许10px误差
const atBottom = scrollHeight - scrollTop - clientHeight < 10
isAtBottom.value = atBottom
// 如果滚动到底部,隐藏新消息提示
if (atBottom) {
showNewMessageTip.value = false
}
// 用户主动滚动
if (!userScrolling.value) {
userScrolling.value = true
setTimeout(() => {
userScrolling.value = false
}, 1000)
}
}
/**
* 加载更多消息
*/
const handleLoadMore = async () => {
const sessionId = currentSession.value?.id
if (!sessionId || loading.value) return
const currentPage = messageStore.currentPage
await messageStore.loadMessages(sessionId, currentPage + 1)
}
/**
* 处理右键菜单
*/
const handleContextMenu = (message: Message) => {
console.log('右键菜单:', message)
// TODO: 显示右键菜单
}
// ==================== 监听器 ====================
/**
* 监听消息变化
*/
watch(
() => messages.value.length,
(newLen, oldLen) => {
// 有新消息
if (newLen > oldLen) {
// 如果在底部,自动滚动
if (isAtBottom.value) {
scrollToBottom()
} else {
// 不在底部,显示新消息提示
showNewMessageTip.value = true
}
}
}
)
/**
* 监听会话切换
*/
watch(
() => currentSession.value?.id,
async (newId, oldId) => {
if (newId && newId !== oldId) {
// 加载消息
await messageStore.loadMessages(newId, 1)
// 滚动到底部
scrollToBottom(false)
}
},
{ immediate: true }
)
// ==================== 生命周期 ====================
// ==================== 消息订阅 ====================
/**
* 订阅新消息
*/
onMounted(() => {
// 初始加载
const sessionId = currentSession.value?.id
if (sessionId) {
messageStore.loadMessages(sessionId, 1).then(() => {
scrollToBottom(false)
})
}
// 订阅新消息
onNewMessage((message: Message) => {
// 如果是当前会话的消息,自动滚动到底部
if (currentSession.value?.id === message.sessionId) {
nextTick(() => {
scrollToBottom()
})
}
})
// 订阅消息状态更新
onMessageUpdate(({ messageId, updates }) => {
const sessionId = currentSession.value?.id
if (sessionId) {
messageStore.updateMessageStatus(sessionId, messageId, updates)
}
})
})
onBeforeUnmount(() => {
// 清理useMessageSubscription 会自动清理)
})
</script>
<style scoped lang="scss">
.message-list {
position: relative;
height: 100%;
background: #f5f5f5;
.loading-container {
padding: 20px;
}
.message-scrollbar {
height: 100%;
:deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
}
.message-container {
padding: 16px;
min-height: 100%;
}
.load-more {
text-align: center;
padding: 16px 0;
}
.message-group {
margin-bottom: 8px;
}
.message-bottom {
height: 1px;
}
.empty-container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.new-message-tip {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
background: var(--el-color-primary);
color: #fff;
border-radius: 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 10;
&:hover {
background: var(--el-color-primary-light-3);
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,56 +0,0 @@
<template>
<div class="basic-info">
<el-avatar :size="80" :src="contact.avatar" class="avatar">
<el-icon :size="40"><User /></el-icon>
</el-avatar>
<div class="info">
<h4 class="nickname">{{ contact.nickname || '未知' }}</h4>
<div v-if="contact.conRemark" class="remark">{{ contact.conRemark }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { User } from '@element-plus/icons-vue'
import type { Contact } from '@/types/wechat'
interface Props {
contact: Contact
}
defineProps<Props>()
</script>
<style scoped lang="scss">
.basic-info {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px;
text-align: center;
.avatar {
margin-bottom: 16px;
}
.info {
.nickname {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.remark {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 0;
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More