代码更新
This commit is contained in:
0
old/.gitattributes → .gitattributes
vendored
0
old/.gitattributes → .gitattributes
vendored
0
old/.gitignore → .gitignore
vendored
0
old/.gitignore → .gitignore
vendored
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -41,6 +41,5 @@
|
||||
"typescript.suggest.autoImports": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.detectIndentation": false,
|
||||
"git.ignoreLimitWarning": true
|
||||
"editor.detectIndentation": false
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
33
TouchVueThree/.gitignore
vendored
33
TouchVueThree/.gitignore
vendored
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
# npm 配置
|
||||
registry=https://registry.npmmirror.com
|
||||
save-exact=false
|
||||
engine-strict=false
|
||||
@@ -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
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
8
TouchVueThree/.vscode/extensions.json
vendored
8
TouchVueThree/.vscode/extensions.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
@@ -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 // 账号ID(0表示全部)
|
||||
groupId?: number // 分组ID(0表示未分组/默认分组)
|
||||
keyword?: string // 搜索关键词
|
||||
}
|
||||
```
|
||||
|
||||
### 分组列表参数
|
||||
```typescript
|
||||
{
|
||||
wechatAccountId?: number // 账号ID(可选)
|
||||
page?: number // 页码(可选)
|
||||
limit?: number // 每页数量(可选)
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ 修正完成
|
||||
|
||||
所有API接口已按照旧项目的实际实现进行修正,确保:
|
||||
1. ✅ HTTP方法正确(GET/POST/DELETE)
|
||||
2. ✅ 路径正确
|
||||
3. ✅ 参数格式正确
|
||||
4. ✅ 参数名称正确(page/limit vs pageNum/pageSize)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
- 编辑器配置文件
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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. 🔨 开始编写代码
|
||||
|
||||
祝您开发愉快! 🚀
|
||||
@@ -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(已重构)
|
||||
```
|
||||
|
||||
**开始使用吧!** 🚀
|
||||
@@ -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
|
||||
@@ -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 使用方法 🎉
|
||||
@@ -1,562 +0,0 @@
|
||||
# WebSocket 架构优化方案
|
||||
|
||||
## 📊 旧项目问题分析
|
||||
|
||||
### 1. **代码组织混乱**
|
||||
|
||||
#### 问题表现:
|
||||
```typescript
|
||||
// ❌ 旧项目:websocket.ts (615行)
|
||||
// - WebSocket 连接管理
|
||||
// - 消息发送/接收
|
||||
// - 心跳/重连逻辑
|
||||
// - 客服状态查询
|
||||
// - 持久化存储逻辑
|
||||
// 所有功能杂糅在一起,职责不清晰
|
||||
|
||||
// ❌ 旧项目:msgManage.ts (469行+)
|
||||
// - 40+ 个消息类型处理函数
|
||||
// - 直接调用多个 Store(weChat, 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
|
||||
@@ -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
21
TouchVueThree/env.d.ts
vendored
21
TouchVueThree/env.d.ts
vendored
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
3469
TouchVueThree/pnpm-lock.yaml
generated
3469
TouchVueThree/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
# Public 目录
|
||||
# 此目录用于存放静态资源文件,如 favicon.ico、图片等
|
||||
# 目录中的文件会直接被复制到构建输出的根目录
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
312
TouchVueThree/src/auto-imports.d.ts
vendored
312
TouchVueThree/src/auto-imports.d.ts
vendored
@@ -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')
|
||||
}
|
||||
52
TouchVueThree/src/components.d.ts
vendored
52
TouchVueThree/src/components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
/**
|
||||
* WebSocket 连接管理 Composable(重构版 - 集成 IndexedDB)
|
||||
*
|
||||
* 核心改造:
|
||||
* ✅ 收到新消息 → 更新 IndexedDB(SessionManager.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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
21
TouchVueThree/src/env.d.ts
vendored
21
TouchVueThree/src/env.d.ts
vendored
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* 布局组件统一导出
|
||||
*/
|
||||
|
||||
export { default as MainLayout } from './MainLayout.vue'
|
||||
export { default as PowerLayout } from './PowerLayout.vue'
|
||||
@@ -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')
|
||||
@@ -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(() => {
|
||||
// 路由切换后的处理
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Token2(WebSocket 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'],
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
/** 当前选中的账号ID(0表示全部) */
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
/** 当前账号ID(0表示全部) */
|
||||
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 账号ID(0表示全部)
|
||||
*/
|
||||
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 账号ID(0表示全部)
|
||||
*/
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user