diff --git a/.env.development b/.env.development index 412c509..c05393b 100644 --- a/.env.development +++ b/.env.development @@ -12,6 +12,10 @@ VITE_APP_BASE_API='/dev-api' VITE_LOCALHOUSET=true # 本地开发环境 VITE_BASE_API='http://192.168.0.212:8082' +#VITE_BASE_API='http://192.168.0.212:8082' +VITE_WS_URL='ws://192.168.0.212:8082/cs/chat' + + # 默认头像 VITE_DEFAULT_USER_IMAGE='https://ts3.tc.mm.bing.net/th?id=ORMS.48a27ca8353fa47bf0dc8df38854d0e1&pid=Wdp&w=312&h=172&qlt=90&c=1&rs=1&dpr=1&p=0' @@ -38,6 +42,8 @@ VITE_APP_RSA_PRIVATE_KEY='MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAObq7y # 客户端id VITE_APP_CLIENT_ID='e5cd7e4891bf95d1d19206ce24a7b32e' +# 客服 客户端id +VITE_CS_AGENT_CLIENT_ID='3a7c1e5b9d2f4688b0c4e17a9f63d582' # 统一消息推送开关 VITE_APP_MESSAGE_ENABLED=true diff --git a/src/api/system/csSession/index.ts b/src/api/system/csSession/index.ts new file mode 100644 index 0000000..73f9fa4 --- /dev/null +++ b/src/api/system/csSession/index.ts @@ -0,0 +1,86 @@ +import request from '@/utils/request'; +import {deskSessionType, listUserType} from "@/api/system/csSession/type"; +import type {AxiosPromise} from '@/utils/api-types'; + +/** + * 查询客服负责用户列表 + */ +export const csAgentDesk = (userType) => { + return request({ + url: '/cs/agent/desk', + method: 'get', + params: { + type: userType, + } + }); +}; +/** + * 查询客服负责用户 的用户信息 + * @param {string} sessionId + */ +export const deskCsAgentDeskBySessionId = (sessionId: string): AxiosPromise => { + return request({ + url: `/cs/agent/desk/session/${sessionId}`, + method: 'get', + }); +}; + +/** + * 查询客服负责用户 的用户 历史记录 + * @param {string} sessionId + * @param {number} afterSeq 从哪个 seq 开始拉取 + * @param endSeq + */ +export const getDeskCsAgentHistory = (sessionId: string, afterSeq: number = 0, endSeq: number | undefined = undefined): AxiosPromise => { + return request({ + url: `/cs/agent/desk/session/${sessionId}/messages`, + method: 'get', + params: { + afterSeq: afterSeq, + endSeq: endSeq, + } + }); +}; +/** + * 查询可以转接的客服列表 + */ +export const getAgentPresence = () => { + return request({ + url: `/cs/agent/presence`, + method: 'get', + }); +}; +/** + * 转接客服 + */ +export const transferAgentPresence = (form: { sessionId: string, agentId: string }) => { + return request({ + url: `/cs/agent/desk/session/${form.sessionId}/transfer`, + method: 'post', + data: { + targetAgentId: form.agentId, + } + }); +}; + +type uploadAgentOssType = { + ossId: string, + url: string, + fileName: string, +} +/** + * 客服上传图片,发送图片 + * @description 上传图片到OSS,返回图片URL (支持jpg/jpeg/png/gif/webp/bmp格式图片) + * @returns {Promise} 图片上传结果 + * @param FormData + */ +export const uploadAgentOss = (FormData: FormData): AxiosPromise => { + return request({ + url: `/cs/agent/upload`, + method: 'post', + data: FormData, + headers: { + 'Content-Type': 'multipart/form-data', + } + }); +}; diff --git a/src/api/system/csSession/type.ts b/src/api/system/csSession/type.ts new file mode 100644 index 0000000..a5a274a --- /dev/null +++ b/src/api/system/csSession/type.ts @@ -0,0 +1,52 @@ +export type deskSessionType = { + customer: Customer; + devices: Device[]; + session: Session; +} + +export type Customer = { + avatarUrl: string; + createTime: string; + nickName: string; + phonenumber: string; + status: string; + statusName: string; + userNo: string; +} + +export type Device = { + deviceId?: string; + locationName?: string; + name?: string; + productName?: string; + status?: string; + statusName?: string; +} + +export type Session = { + agentId: null; + agentName: null; + queueNo: number; + sessionId: string; + startTime: string; + status: string; + statusName: string; + summary: string; +} + + +export type listUserType = { + "avatarUrl": string, + "aheadCount": number, + "lastMessage": string, + "lastMessageTime": string, + "latestSeq": number, + "queueNo": number, + "queueTime": string, + "sessionId": string, + "status": string, + "statusName": string, + "summary": string, + "unreadCount": number, + "userNickName": string +} diff --git a/src/api/system/role/types.ts b/src/api/system/role/types.ts index 7dbb6ff..2406b22 100644 --- a/src/api/system/role/types.ts +++ b/src/api/system/role/types.ts @@ -32,9 +32,9 @@ export interface RoleVO extends BaseEntity { } export interface RoleQuery extends PageQuery { - roleName: string; - roleKey: string; - status: string; + roleName?: string; + roleKey?: string; + status?: string; } export interface RoleForm { diff --git a/src/assets/images/avatar.png b/src/assets/images/avatar.png new file mode 100644 index 0000000..263c9c2 Binary files /dev/null and b/src/assets/images/avatar.png differ diff --git a/src/components/FormItemTip/index.vue b/src/components/FormItemTip/index.vue index d9124de..e403a5e 100644 --- a/src/components/FormItemTip/index.vue +++ b/src/components/FormItemTip/index.vue @@ -58,13 +58,14 @@ > {{ item.label }} + >{{ item[labelKey] }} @@ -104,18 +105,38 @@ + + + + + @@ -125,11 +146,14 @@ v-else-if="type === 'password'" :model-value="modelValue" class="inputItem" - show-password + :type="showPassword ? 'text' : 'password'" v-bind="inputAttrs" @input="$emit('update:modelValue', $event)" > @@ -192,14 +216,41 @@ const props = defineProps({ default: () => ({}) }, radioOptions: { - type: Array as PropType>, + type: Array as PropType>>, default: () => [] }, + labelKey: { + type: String, + default: 'label' + }, + valueKey: { + type: String, + default: 'value' + }, + disabledValues: { + type: Array as PropType>, + default: () => [] + }, + disabledPredicate: { + type: Function as PropType<(item: Record) => boolean>, + default: undefined + }, }) const emit = defineEmits(['update:modelValue', 'file-change']) +const isOptionDisabled = (item: Record) => { + if (props.disabledPredicate) { + return props.disabledPredicate(item) + } + if (props.disabledValues.length) { + return props.disabledValues.includes(item[props.valueKey]) + } + return false +} + +const showPassword = ref(false) const upload = ref() const defaultImag = import.meta.env.VITE_DEFAULT_USER_IMAGE; const imageUrl = ref('') @@ -377,6 +428,24 @@ const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => { border-radius: 12px 12px 12px 12px; background-color: rgba(247, 249, 252, 1); border: 1px solid rgba(229, 231, 235, 1); + + + &.selectBox { + .el-select__wrapper { + min-height: 44px; + } + } +} + +.password-toggle { + font-size: 13px; + cursor: pointer; + user-select: none; + white-space: nowrap; + + &:hover { + color: rgba(29, 78, 216, 1); + } } .radioGroup { diff --git a/src/components/cardNav/index.vue b/src/components/cardNav/index.vue index c9d3229..53419da 100644 --- a/src/components/cardNav/index.vue +++ b/src/components/cardNav/index.vue @@ -39,6 +39,9 @@ const props = defineProps({
{{ props.subTitle }}
+
+ +
diff --git a/src/layout/components/AppMain.vue b/src/layout/components/AppMain.vue index 0d237d0..7d0a8a8 100644 --- a/src/layout/components/AppMain.vue +++ b/src/layout/components/AppMain.vue @@ -67,6 +67,7 @@ function addIframe() { .app-main:not(.with-fixed-header) { min-height: calc(100vh - 64px); max-height: calc(100vh - 64px); + height: calc(100vh - 64px); } .app-main.with-tags-view:not(.with-fixed-header) { diff --git a/src/permission.ts b/src/permission.ts index 9d0c8b4..53fbdd4 100644 --- a/src/permission.ts +++ b/src/permission.ts @@ -55,14 +55,14 @@ router.beforeEach(async (to, from) => { if (res.data.roles.includes('customer_service')) { if (to.path === '/index') { return { - path: '/conversation/conversationList', + path: '/cs/session', replace: true } } // 客服登录默认首页:如果原本跳转目标是根路径 /,也跳会话列表 if (to.path === '/') { return { - path: '/conversation/conversationList', + path: '/cs/session', replace: true } } @@ -88,7 +88,7 @@ router.beforeEach(async (to, from) => { } else { const redirect = encodeURIComponent(to.fullPath || '/'); NProgress.done(); - return `/login?redirect=${redirect}`; // 否则全部重定向到登录页 + return `/login`; // 否则全部重定向到登录页 } } }); diff --git a/src/store/modules/csSession.ts b/src/store/modules/csSession.ts new file mode 100644 index 0000000..4eb2a33 --- /dev/null +++ b/src/store/modules/csSession.ts @@ -0,0 +1,383 @@ +import {defineStore} from 'pinia'; +import {reactive, ref} from 'vue'; +import {WSClient} from '@/utils/webSocket'; +import {getToken} from '@/utils/auth'; +import {deskSessionType} from "@/api/system/csSession/type"; +import {csAgentDesk} from "@/api/system/csSession"; + +/** 最大消息数 */ +export const MAX_MESSAGES = 30; + +/** 客服负责用户列表的项类型 */ +export type listUserType = { + "avatarUrl": string, + "aheadCount": number, + "lastMessage": string, + "lastMessageTime": string, + "latestSeq": number, + "queueNo": number, + "queueTime": string, + "sessionId": string, + "status": string, + "statusName": string, + "summary": string, + "unreadCount": number, + "userNickName": string +} +/** 客服列表的 key 类型 */ +export type UserListType = 'all' | 'queue' | 'recentEnded' | 'serving' +/** 客服负责用户列表的初始值 */ +const initUserLists: Record = { + all: [], + queue: [], + recentEnded: [], + serving: [] +}; + + +type CsQueueType = { + data: { + "sessionId": string, + "status": string, + "statusName": string, + "queueNo": number + }, + type: 'cs:queue' +} + + +/** ws 消息 → 业务侧消费的消息结构(与 chat UI 对齐) */ +export interface ChatMessage { + id: string; + role: 'user' | 'agent' | 'robot' | 'system'; + type: 'chat' | 'typing' | 'system'; + msgType?: 'text' | 'image' | 'rich'; + content: any; + time: string; + status?: 'sending' | 'sent' | 'failed'; + sessionId?: string; +} + +export const userTypeList: { label: string, value: UserListType }[] = [ + {label: '全部', value: 'all'}, + {label: '接待中', value: 'serving'}, + {label: '排队中', value: 'queue'}, + {label: '已结束', value: 'recentEnded'}, +] +export const useCsSessionStore = defineStore('csSession', () => { + /* ===================== 基础 state ===================== */ + const state = reactive({ + isLogin: false, + userInfo: {} as listUserType, + userInfoMain: null as deskSessionType | null, + userType: '', + }); + // 拉去了多少条消息 用于判断是否需要加载更多 + const loadedMessages = ref(1); // 已加载的消息数量 * 最大消息数 + // 发送消息 状态 true 发送中 + const sending = ref(false); + + function setSending(value: boolean) { + sending.value = value + } + + // 当前客服负责用户 会话id + const currentSessionId = ref(''); + // 当前客服负责用户 客户端会话id (唯一标识) 发送消息用 + const currentClientSessionId = () => { + return state.userInfoMain.customer.userNo + '-' + Date.now() + '-' + localStorage.getItem('userInfo') + }; + // 客服负责所有用户的消息 + const allUsersMessages = reactive>({}); + + // ⚠️ computed 不能嵌套在 reactive 里,追踪链会断 + const userInfoMessage = computed(() => allUsersMessages[currentSessionId.value] || []); + + // 初始化客服负责所有用户的消息 + function initAllUsersMessages(list: listUserType[], isClear = false) { + if (isClear) { + allUsersMessages[currentSessionId.value] = [] + return + } + allUsersMessages[currentSessionId.value] = [ + ...(allUsersMessages[currentSessionId.value] || []), + ...list + ] + } + + // 上滑加载 更早的历史记录 + function handleLoadMoreMessages(list: listUserType[]) { + if (!currentSessionId.value) return + allUsersMessages[currentSessionId.value] = [ + ...list, + ...(allUsersMessages[currentSessionId.value] || []), + ] + } + + // 设置客服负责所有用户的消息 + function setAllUsersMessages(sessionId: string, data) { + allUsersMessages[sessionId] = [ + ...(allUsersMessages[sessionId] || []), + data + ] + // 保留最新 80 条消息 + if (allUsersMessages[sessionId].length > MAX_MESSAGES) { + allUsersMessages[sessionId] = allUsersMessages[sessionId].slice(-MAX_MESSAGES) + } + userLists.value[activeUserType.value].forEach((item) => { + // 消息会话 是 当前会话时,才更新最后消息到侧边栏 + if (item.sessionId === sessionId) { + + + if (data.msgType === 'image') { + item.lastMessage = '[图片]' + } else { + item.lastMessage = data.content; + } + // 消息会话 不是 当前会话,且是 指定会话id 时,才增加未读消息数量 + if (currentSessionId.value !== item.sessionId) { + item.unreadCount = (item.unreadCount || 0) + 1 + } + } + }); + } + + // 设置当前客服负责用户 id + function setCurrentSessionId(userId: string) { + console.log('设置当前客服负责用户 id', userId) + currentSessionId.value = userId + } + + // 设置当前客服负责用户信息 + function setCurrentUser(userInfo: listUserType) { + state.userInfo = { + ...userInfo + } + getRightUserList() + } + + // 设置当前客服负责用户 主信息 + function setCurrentUserInfoMain(userInfoMain: deskSessionType | null) { + state.userInfoMain = userInfoMain + } + + + // 当前客服负责用户列表的类型(全部/接待中/排队中/已结束) + const activeUserType = ref('all') + + // 切换客服负责用户列表的类型(全部/接待中/排队中/已结束) + function handleActiveUserType(value: UserListType) { + activeUserType.value = value + getRightUserList() + } + + // 客服负责用户列表 + const userLists = ref>({...initUserLists}); + + // 左侧用户列表刷新 + function getRightUserList() { + return new Promise(resolve => { + csAgentDesk(activeUserType.value).then((res) => { + userLists.value = res.data + resolve(res.data) + }) + }) + } + + /* ===================== 当前会话相关 ===================== */ + /** 当前会话开始的会话 ID 列表 */ + const currentStartSessionIdList = ref([]) + /** 当前会话的消息列表(直接给 ChatMessageList 组件用) */ + const currentMessages = ref([]); + + /* ===================== ws 相关 ===================== */ + /** 全局共享的 ws 单例 */ + let ws: WSClient | null = null; + /** ws 当前连接状态(给 UI 显示"连接中/重连中...") */ + const wsStatus = ref('idle'); + + /** 给业务层用的 ws 实例访问器(只读) */ + function getWs(): WSClient | null { + return ws; + } + + /** + * 建立 ws 连接(整个客服工作台调用一次即可)。 + * 放 store 里的原因: + * - ws 是昂贵资源(心跳/重连/鉴权),不能每个组件都 new 一个 + * - store 是全局单例,组件销毁时不会跟着销毁 + * - 所有收到的消息都进 store,组件只管消费和发送 + */ + function ensureWs(): WSClient { + if (ws) return ws; + const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; + const wsPath = '/cs/chat'; + const wsUrl = `${protocol}192.168.0.212:8082${wsPath}?token=${getToken()}`; + ws = new WSClient({ + url: wsUrl, + heartbeatInterval: 15_000, + maxReconnectAttempts: 10, + debug: true, + }); + + // ── 业务消息 handler:收到什么类型就做什么事 ── + + // cs:queue 排队变化,刷新左侧用户列表 + ws.registerHandler('cs:queue', (pkt: CsQueueType) => { + // 修改列表中的用户状态 + userLists.value[activeUserType.value].forEach(item => { + if (item.sessionId === pkt.data.sessionId) { + Object.assign(item, pkt.data) + } + }) + + // 如果当前页是 当前会话 + if (currentSessionId.value === pkt.data.sessionId) { + // 修改当前会话 用户状态 + state.userInfo = { + ...state.userInfo, + ...pkt.data + } + // 修改当前会话 的详细信息 + const obj = { + customer: state.userInfoMain.customer, + devices: state.userInfoMain.devices, + session: state.userInfoMain.session, + }; + obj.session = { + ...obj.session, + ...pkt.data + } + setCurrentUserInfoMain(obj as deskSessionType) + } + }); + ws.registerHandler('cs:session', (pkt) => { + getRightUserList(); + }); + // cs:message 接受消息 + ws.registerHandler('cs:message', (pkt) => { + console.log('接受消息:', pkt.data.content) + setAllUsersMessages(pkt.data.sessionId, pkt.data) + }); + // cs:saved 发送消息成功 + ws.registerHandler('cs:saved', (pkt) => { + setSending(false) + }); + // cs:ERROR 失败 + ws.registerHandler('cs:error', (pkt) => { + setSending(false) + }); + + + // ── 生命周期事件 ── + ws.on('status', (next) => { + wsStatus.value = next; + }); + + ws.on('ready', () => { + // 重连成功后,如果当前有会话,需要告诉服务端恢复 + if (currentSessionId.value && ws) { + ws.send({ + type: 'resume_session', + data: {sessionId: currentSessionId.value} + }); + } + }); + + // close 事件统一处理:被踢、鉴权失败、网络断开都走这里 + ws.on('close', (code, reason) => { + console.warn(`[ws] 连接关闭 code=${code} reason=${reason}`); + // 业务层可以根据 code 区分:1000=主动关闭, 4000=心跳超时, 4001=鉴权/被踢 + }); + + ws.on('reconnect', (attempt, delay) => { + console.log(`[ws] 第 ${attempt} 次重连,${delay}ms 后`); + }); + + ws.on('reconnectFailed', () => { + console.error('[ws] 重连失败'); + }); + + // 发起连接 + void ws.connect(); + return ws; + } + + /** 彻底销毁 ws(登出时调) */ + function destroyWs() { + ws?.destroy(); + ws = null; + wsStatus.value = 'closed'; + } + + /* ===================== 消息辅助 ===================== */ + + + /* ===================== 用户列表 ===================== */ + + function setUserLists(lists: Partial>) { + Object.assign(userLists.value, lists); + } + + const getUserLists = computed(() => { + return userLists.value[activeUserType.value]; + }) + + /* ===================== 当前会话操作 ===================== */ + + /** 点击左侧某个用户 → 发起/恢复会话 */ + // async function startSession(userId: string): Promise { + // const client = ensureWs(); + // if (!client) return false; + // + // try { + // const res = await client.sendAndWaitAck( + // {type: 'session_start', data: {userId}}, + // {ackType: 'session_start_ack', timeout: 5000} + // ); + // currentSessionId.value = res.data?.sessionId ?? ''; + // currentMessages.value = []; + // return true; + // } catch (err) { + // console.error('发起会话失败:', err); + // return false; + // } + // } + + /** 关闭当前会话 */ + function closeSession() { + if (ws && currentSessionId.value) { + ws.send({type: 'session_end', data: {sessionId: currentSessionId.value}}); + } + currentSessionId.value = ''; + currentMessages.value = []; + } + + return { + state, + userInfoMessage, + userLists, + currentSessionId, + currentMessages, + activeUserType, + sending, + setSending, + currentClientSessionId, + currentStartSessionIdList, + handleLoadMoreMessages, + initAllUsersMessages, + setAllUsersMessages, + setCurrentSessionId, + setCurrentUser, + setCurrentUserInfoMain, + handleActiveUserType, + wsStatus, + getWs, + ensureWs, + destroyWs, + setUserLists, + getRightUserList, + getUserLists, + closeSession, + }; +}); diff --git a/src/store/modules/type.ts b/src/store/modules/type.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/store/modules/user.ts b/src/store/modules/user.ts index 03dc267..301b887 100644 --- a/src/store/modules/user.ts +++ b/src/store/modules/user.ts @@ -54,6 +54,7 @@ export const useUserStore = defineStore('user', () => { } else { roles.value = ['ROLE_DEFAULT']; } + localStorage.setItem('userInfo', data.user.userId.toString()); name.value = user.userName; nickname.value = user.nickName; avatar.value = profile; diff --git a/src/types/env.d.ts b/src/types/env.d.ts index e72878c..1adcff5 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -15,7 +15,9 @@ interface ImportMetaEnv { VITE_APP_PORT: number; VITE_APP_BASE_API: string; VITE_APP_BASE_URL: string; + VITE_WS_URL: string; VITE_APP_CONTEXT_PATH: string; + VITE_CS_AGENT_CLIENT_ID: string; VITE_APP_MONITOR_ADMIN: string; VITE_APP_SNAILJOB_ADMIN: string; VITE_APP_SNAILAI_ADMIN: string; diff --git a/src/utils/auth.ts b/src/utils/auth.ts index db50ac9..5717601 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -7,3 +7,5 @@ export const getToken = () => tokenStorage.value; export const setToken = (access_token: string) => (tokenStorage.value = access_token); export const removeToken = () => (tokenStorage.value = null); + + diff --git a/src/utils/phone.ts b/src/utils/phone.ts index 3f71b4f..a453268 100644 --- a/src/utils/phone.ts +++ b/src/utils/phone.ts @@ -5,6 +5,7 @@ * @example 136****9023 * */ export function hiddenPhone(phone: string): string { + if (!phone) return '---'; if (phone.length < 11) { return phone } diff --git a/src/utils/request.ts b/src/utils/request.ts index c1d1e23..595560a 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -96,6 +96,17 @@ export const globalHeaders = () => { axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8'; axios.defaults.headers['clientid'] = import.meta.env.VITE_APP_CLIENT_ID; + + +function resolveClientId() { + const roles = useUserStore().roles; + if (roles.includes('customer_service')) { + return import.meta.env.VITE_CS_AGENT_CLIENT_ID; + } + return import.meta.env.VITE_APP_CLIENT_ID; +} + + // 创建 axios 实例 const service = axios.create({ baseURL: import.meta.env.VITE_APP_BASE_API, @@ -111,6 +122,7 @@ service.interceptors.request.use( (config: any) => { // 对应国际化资源文件后缀 config.headers['Content-Language'] = getLanguage(); + // config.headers['clientid'] = resolveClientId(); const isToken = config.headers?.isToken === false; // 是否需要防止数据重复提交 diff --git a/src/utils/webSocket/index.ts b/src/utils/webSocket/index.ts new file mode 100644 index 0000000..d6fe79a --- /dev/null +++ b/src/utils/webSocket/index.ts @@ -0,0 +1,307 @@ +/* ------------------------------------------------------------------ + * WSClient —— 极简 WebSocket 客户端 + * + * 实际用到的 API(仅此而已): + * new WSClient({ url, heartbeatInterval?, maxReconnectAttempts?, debug? }) + * .connect() + * .destroy() + * .registerHandler(type, fn) → 返回取消函数 + * .send({type, data, ...}) + * .sendAndWaitAck({type, data}, {ackType}) → Promise + * .on('status', fn) .on('ready', fn) .on('close', fn) + * .on('reconnect', fn) .on('reconnectFailed', fn) + * + * 不做:token/auth_ack/protocols/waitForReady/离线队列/竞态序号/... + * ------------------------------------------------------------------ */ + +type Handler = (pkt: any) => void; +type Listener = (...args: any[]) => void; + +export interface WSClientOptions { + url: string; + heartbeatInterval?: number; + maxReconnectAttempts?: number; + debug?: boolean; +} + +export interface WSPacket { + type: string; + data?: any; + + [key: string]: any; +} + +export interface AckOptions { + ackType?: string; + timeout?: number; +} + +export class WSClient { + private url: string; + private hbInterval: number; + private maxRetry: number; + private debug: boolean; + + private ws: WebSocket | null = null; + private status: 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed' = 'idle'; + private retryCount = 0; + private manualClosed = false; + private hbTimer: any = null; + private pongTimer: any = null; + private reconnectTimer: any = null; + + private handlers = new Map>(); + private acks: Array<{ + match: (p: any) => boolean; + resolve: (p: any) => void; + reject: (e: Error) => void; + timer: any + }> = []; + private listeners = new Map>(); + + constructor(opts: WSClientOptions) { + if (!/^wss?:\/\//i.test(opts.url)) throw new Error('WSClient url 必须 ws:// 或 wss:// 开头'); + this.url = opts.url; + this.hbInterval = opts.heartbeatInterval ?? 15_000; + this.maxRetry = opts.maxReconnectAttempts ?? 10; + this.debug = opts.debug ?? false; + if (typeof window !== 'undefined') window.addEventListener('online', this.onOnline); + } + + /* ==================== 公共 ==================== */ + + get isReady() { + return this.status === 'open'; + } + + on(event: string, fn: Listener) { + const set = this.listeners.get(event) ?? new Set(); + set.add(fn); + this.listeners.set(event, set); + return () => set.delete(fn); + } + + getStatus() { + return this.status; + } + + connect() { + if (this.status !== 'idle' && this.status !== 'closed') return; + if (this.manualClosed) return; + this.retryCount = 0; + this.open(); + } + + destroy() { + this.manualClosed = true; + this.clearTimers(); + this.acks.forEach((a) => { + clearTimeout(a.timer); + a.reject(new Error('连接已销毁')); + }); + this.acks = []; + this.handlers.clear(); + this.listeners.clear(); + this.ws?.close(1000, 'destroy'); + this.ws = null; + if (typeof window !== 'undefined') window.removeEventListener('online', this.onOnline); + this.setStatus('closed'); + } + + registerHandler(type: string, fn: Handler) { + const set = this.handlers.get(type) ?? new Set(); + set.add(fn); + this.handlers.set(type, set); + return () => set.delete(fn); + } + + send(packet: WSPacket) { + if (!this.ws || this.status !== 'open') return; + this.ws.send(JSON.stringify(packet)); + } + + sendString(packet: string) { + if (!this.ws || this.status !== 'open') return; + this.ws.send(packet); + } + + + sendAndWaitAck(packet: WSPacket, opts: AckOptions = {}): Promise { + const timeout = opts.timeout ?? 10_000; + return new Promise((resolve, reject) => { + if (!this.ws || this.status !== 'open') return reject(new Error('ws 未就绪')); + + const entry = { + match: opts.ackType ? (p: any) => p.type === opts.ackType : () => false, + resolve: (p: any) => { + clearTimeout(entry.timer); + this.acks = this.acks.filter((e) => e !== entry); + resolve(p); + }, + reject: (e: Error) => { + clearTimeout(entry.timer); + this.acks = this.acks.filter((e) => e !== entry); + reject(e); + }, + timer: setTimeout(() => entry.reject(new Error('ACK 超时')), timeout), + }; + this.acks.push(entry); + this.ws!.send(JSON.stringify(packet)); + }); + } + + /* ==================== 内部 ==================== */ + + private open() { + this.setStatus('connecting'); + + if (typeof navigator !== 'undefined' && !navigator.onLine) { + this.log('设备离线,等 online'); + return; + } + + try { + this.ws = new WebSocket(this.url); + } catch (err) { + this.scheduleReconnect(); + return; + } + + this.ws.onopen = () => { + this.log('连接就绪'); + this.retryCount = 0; + this.setStatus('open'); + this.startHeartbeat(); + this.emit('ready'); + }; + + this.ws.onmessage = (ev) => this.onMessage(ev); + + this.ws.onclose = (ev) => { + this.log('关闭', ev.code, ev.reason); + this.clearTimers(); + this.ws = null; + this.emit('close', ev.code, ev.reason); + if (this.manualClosed) { + this.setStatus('closed'); + return; + } + this.scheduleReconnect(); + }; + } + + private onMessage(ev: MessageEvent) { + let pkt: WSPacket; + try { + pkt = JSON.parse(ev.data as string); + } catch { + return; + } + + // 内建:ping/pong + // if (pkt.type === 'cs:pong') { + // // this.ws?.send(JSON.stringify({type: 'ping'})); + // return; + // } + if (pkt.type === 'cs:pong') { + if (this.pongTimer) { + clearTimeout(this.pongTimer); + this.pongTimer = null; + } + return; + } + + // ACK 匹配 + for (let i = this.acks.length - 1; i >= 0; i--) { + if (this.acks[i].match(pkt)) { + this.acks[i].resolve(pkt); + return; + } + } + + // 业务 handler + this.handlers.get(pkt.type)?.forEach((fn) => { + console.log(pkt.type) + try { + fn(pkt); + } catch (e) { + console.error('[WS] handler error:', e); + } + }); + } + + /* ---------------- 心跳 ---------------- */ + + private startHeartbeat() { + this.stopHeartbeat(); + if (this.hbInterval <= 0) return; + this.hbTimer = setInterval(() => { + if (this.status !== 'open' || !this.ws) return; + this.ws.send(JSON.stringify({type: 'ping'})); + if (this.pongTimer) clearTimeout(this.pongTimer); + this.pongTimer = setTimeout(() => { + this.log('pong 超时,断开'); + this.ws?.close(4000, 'heartbeat timeout'); + }, 10_000); + }, this.hbInterval); + } + + private stopHeartbeat() { + if (this.hbTimer) clearInterval(this.hbTimer); + if (this.pongTimer) clearTimeout(this.pongTimer); + this.hbTimer = null; + this.pongTimer = null; + } + + /* ---------------- 重连 ---------------- */ + + private scheduleReconnect() { + if (this.manualClosed) return; + if (this.retryCount >= this.maxRetry) { + this.log('重连放弃,达上限'); + this.emit('reconnectFailed'); + this.setStatus('closed'); + return; + } + this.retryCount++; + const delay = Math.min(800 * Math.pow(2, this.retryCount - 1) * (0.7 + Math.random() * 0.6), 20_000); + this.setStatus('reconnecting'); + this.emit('reconnect', this.retryCount, Math.round(delay)); + this.log(`第 ${this.retryCount} 次重连 ${Math.round(delay)}ms 后`); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.open(); + }, delay); + } + + private onOnline = () => { + if (this.manualClosed) return; + if (this.status === 'open' || this.status === 'connecting') return; + this.retryCount = 0; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.open(); + }; + + /* ---------------- 工具 ---------------- */ + + private clearTimers() { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + this.stopHeartbeat(); + } + + private setStatus(s: typeof this.status) { + if (s === this.status) return; + this.status = s; + this.emit('status', s); + this.log('状态 →', s); + } + + private emit(event: string, ...args: any[]) { + this.listeners.get(event)?.forEach((fn) => fn(...args)); + } + + private log(...args: any[]) { + if (this.debug) console.log('[WS]', ...args); + } +} diff --git a/src/views/login.vue b/src/views/login.vue index 43730a3..fe83305 100644 --- a/src/views/login.vue +++ b/src/views/login.vue @@ -51,7 +51,7 @@ }" >
- {{ numToWeek(item.formateDay + 1)}} + {{ numToWeek(item.formateDay + 1) }} ({{ item.month }}.{{ item.date }})
@@ -119,19 +119,22 @@ @keyup.enter="handleLogin" > + /> +
{{ - $t("login.rememberPassword") - }} + $t("login.rememberPassword") + }} + {{ $t("login.switchRegisterPage") }} @@ -168,7 +171,7 @@ const title = import.meta.env.VITE_APP_TITLE; const quickStats = ref([]); const userStore = useUserStore(); const router = useRouter(); -const { t } = useI18n(); +const {t} = useI18n(); const loginForm = ref({ username: "admin", @@ -217,7 +220,7 @@ watch( newRoute.query.redirect && decodeURIComponent(newRoute.query.redirect); }, - { immediate: true }, + {immediate: true}, ); const isShowPwd = ref(false); @@ -256,7 +259,7 @@ const handleLogin = () => { const getCode = async () => { const res = await getCodeImg(); - const { data } = res; + const {data} = res; captchaEnabled.value = data.captchaEnabled === undefined ? true : data.captchaEnabled; if (captchaEnabled.value) { @@ -286,8 +289,17 @@ const handleGetLoc = async () => { ) .then((res) => res.json()) .then((res) => { - const arr = res.data.forecast.daily.pm10 ?? []; - quickStats.value = handleMax(arr.slice(0, 7)); + // const weightArr = res.data.forecast.daily.pm10 ?? []; + const weightArr = [ + {avg: 72, day: "2026-10-03"}, + {avg: 85, day: "2026-10-04"}, + {avg: 66, day: "2026-10-05"}, + {avg: 91, day: "2026-10-06"}, + {avg: 78, day: "2026-10-07"}, + {avg: 95, day: "2026-10-08"}, + {avg: 88, day: "2026-10-09"}, + ]; + quickStats.value = handleMax(weightArr.slice(0, 7)); }); } catch (e) { console.error((e as Error).message); @@ -343,10 +355,12 @@ onMounted(() => { padding: 60px 80px; display: flex; flex-direction: column; + .logo-box { display: flex; align-items: center; margin-bottom: 160px; + .logo { width: 48px; height: 48px; @@ -357,6 +371,7 @@ onMounted(() => { text-align: center; line-height: 48px; } + .logo_info { .title { height: 28px; @@ -366,6 +381,7 @@ onMounted(() => { color: rgba(255, 255, 255, 1); font-size: 20px; } + .subTitle { height: 18px; line-height: 18px; @@ -385,6 +401,7 @@ onMounted(() => { font-size: clamp(34px, 4vw, 52px); letter-spacing: -0.03em; } + h2.brand-title { font-size: clamp(28px, 4vw, 46px); } @@ -404,12 +421,14 @@ onMounted(() => { justify-items: center; min-height: 60px; margin-bottom: 40px; + .devices { .data { height: 38px; line-height: 38px; font-size: 30px; } + .label { height: 18px; line-height: 18px; @@ -417,6 +436,7 @@ onMounted(() => { font-size: 13px; } } + .line { width: 1px; background-color: rgba(255, 255, 255, 0.24); @@ -431,17 +451,20 @@ onMounted(() => { background-color: rgba(255, 255, 255, 0.1); box-sizing: border-box; padding: 15px 20px; + .nav { display: flex; justify-content: space-between; align-items: center; } + .brand-metrics_list { margin-top: 48px; display: flex; align-items: end; gap: 16px; height: 105px; + .metric-card { border-radius: 8px 8px 0 0; background-color: rgba(255, 255, 255, 0.3); @@ -449,9 +472,11 @@ onMounted(() => { display: flex; flex-direction: column; height: 100%; + &.active { background-color: rgba(255, 255, 255, 0.6); } + span { color: rgba(226, 232, 240, 0.72); font-size: 13px; @@ -481,6 +506,7 @@ onMounted(() => { display: flex; align-items: center; justify-content: center; + .login-form { width: 480px; height: 600px; @@ -529,6 +555,7 @@ onMounted(() => { border: 1px solid var(--app-surface-border); } } + .loginModel { height: 22px; line-height: 22px; @@ -549,6 +576,7 @@ onMounted(() => { border-radius: 12px 12px 12px 12px; background-color: rgba(247, 248, 250, 1); border: 1px solid rgba(238, 240, 243, 1); + .actions { cursor: pointer; color: rgba(37, 99, 235, 1); @@ -619,6 +647,7 @@ onMounted(() => { border-radius: 12px; box-shadow: 0 18px 34px rgba(53, 109, 255, 0.22); } + .tip { background-color: rgba(0, 0, 0, 0); color: rgba(156, 163, 175, 1); @@ -634,9 +663,8 @@ onMounted(() => { } .login-form :deep(.el-input__wrapper.is-focus) { - box-shadow: - 0 0 0 1px rgba(53, 109, 255, 0.24) inset, - 0 0 0 4px rgba(53, 109, 255, 0.12); + box-shadow: 0 0 0 1px rgba(53, 109, 255, 0.24) inset, + 0 0 0 4px rgba(53, 109, 255, 0.12); background-color: rgba(255, 255, 255, 1); } diff --git a/src/views/system/conversation/compontents/ChatInput.vue b/src/views/system/conversation/compontents/ChatInput.vue index 0b22c22..055801c 100644 --- a/src/views/system/conversation/compontents/ChatInput.vue +++ b/src/views/system/conversation/compontents/ChatInput.vue @@ -7,6 +7,7 @@ v-model="inputText" :autosize="{ minRows:5, maxRows:8 }" placeholder="请输入回复内容" + :disabled="disable" type="textarea" @keydown="handleKeyDown" /> @@ -27,6 +28,7 @@ class="btn-img" plain type="info" + :disabled="disable" @click="handleSelectImage" > 上传图片 @@ -52,6 +54,12 @@ const props = defineProps({ sending: { type: Boolean, default: false + }, + + /** 是否禁用输入框 */ + disable: { + type: Boolean, + default: false } }) @@ -68,7 +76,7 @@ const inputText = ref('') // 是否可以发送 const canSend = computed(() => { - return !!inputText.value.trim() + return !!inputText.value.trim() && !props.disable }) // 点击图片图标,唤起文件选择 diff --git a/src/views/system/conversation/compontents/ChatNavBar.vue b/src/views/system/conversation/compontents/ChatNavBar.vue index 73b8efb..094c3bc 100644 --- a/src/views/system/conversation/compontents/ChatNavBar.vue +++ b/src/views/system/conversation/compontents/ChatNavBar.vue @@ -1,68 +1,27 @@ diff --git a/src/views/system/user/index.vue b/src/views/system/user/index.vue index da0516b..af9ebf1 100644 --- a/src/views/system/user/index.vue +++ b/src/views/system/user/index.vue @@ -3,7 +3,7 @@
@@ -72,9 +72,6 @@

用户列表

- - 新增 - - + - + @@ -145,43 +138,47 @@ + width="220"> @@ -704,13 +701,16 @@ const cancel = () => { /** 新增按钮操作 */ const handleAdd = async () => { - reset(); - const {data} = await api.getUser(); - setDialogTitle('新增用户'); - openUserDialog(); - postOptions.value = data.posts; - roleOptions.value = data.roles; - form.value.password = initPassword.value.toString(); + router.push({ + path: '/system/addUser' + }) + // reset(); + // const {data} = await api.getUser(); + // setDialogTitle('新增用户'); + // openUserDialog(); + // postOptions.value = data.posts; + // roleOptions.value = data.roles; + // form.value.password = initPassword.value.toString(); }; /** 修改按钮操作 */ diff --git a/vite.config.ts b/vite.config.ts index 79a1bc5..a2a3841 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -35,7 +35,7 @@ export default defineConfig(({mode, command}) => { changeOrigin: true, ws: true, rewrite: path => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '') - } + }, } }, css: {