10/15
1. 工作人员账号添加
This commit is contained in:
@@ -12,6 +12,10 @@ VITE_APP_BASE_API='/dev-api'
|
|||||||
VITE_LOCALHOUSET=true
|
VITE_LOCALHOUSET=true
|
||||||
# 本地开发环境
|
# 本地开发环境
|
||||||
VITE_BASE_API='http://192.168.0.212:8082'
|
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'
|
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
|
# 客户端id
|
||||||
VITE_APP_CLIENT_ID='e5cd7e4891bf95d1d19206ce24a7b32e'
|
VITE_APP_CLIENT_ID='e5cd7e4891bf95d1d19206ce24a7b32e'
|
||||||
|
# 客服 客户端id
|
||||||
|
VITE_CS_AGENT_CLIENT_ID='3a7c1e5b9d2f4688b0c4e17a9f63d582'
|
||||||
|
|
||||||
# 统一消息推送开关
|
# 统一消息推送开关
|
||||||
VITE_APP_MESSAGE_ENABLED=true
|
VITE_APP_MESSAGE_ENABLED=true
|
||||||
|
|||||||
86
src/api/system/csSession/index.ts
Normal file
86
src/api/system/csSession/index.ts
Normal file
@@ -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<deskSessionType> => {
|
||||||
|
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<listUserType[]> => {
|
||||||
|
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<uploadAgentOssType>} 图片上传结果
|
||||||
|
* @param FormData
|
||||||
|
*/
|
||||||
|
export const uploadAgentOss = (FormData: FormData): AxiosPromise<uploadAgentOssType> => {
|
||||||
|
return request({
|
||||||
|
url: `/cs/agent/upload`,
|
||||||
|
method: 'post',
|
||||||
|
data: FormData,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
52
src/api/system/csSession/type.ts
Normal file
52
src/api/system/csSession/type.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -32,9 +32,9 @@ export interface RoleVO extends BaseEntity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RoleQuery extends PageQuery {
|
export interface RoleQuery extends PageQuery {
|
||||||
roleName: string;
|
roleName?: string;
|
||||||
roleKey: string;
|
roleKey?: string;
|
||||||
status: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoleForm {
|
export interface RoleForm {
|
||||||
|
|||||||
BIN
src/assets/images/avatar.png
Normal file
BIN
src/assets/images/avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -58,13 +58,14 @@
|
|||||||
>
|
>
|
||||||
<el-radio
|
<el-radio
|
||||||
v-for="item in radioOptions"
|
v-for="item in radioOptions"
|
||||||
:key="item.value"
|
:key="item[valueKey]"
|
||||||
:class="{
|
:class="{
|
||||||
active: modelValue === item.value
|
active: modelValue === item[valueKey]
|
||||||
}"
|
}"
|
||||||
:label="item.value"
|
:disabled="isOptionDisabled(item)"
|
||||||
|
:label="item[valueKey]"
|
||||||
class="radio"
|
class="radio"
|
||||||
>{{ item.label }}
|
>{{ item[labelKey] }}
|
||||||
</el-radio>
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
|
|
||||||
@@ -104,18 +105,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 下拉选择-->
|
||||||
|
<el-select
|
||||||
|
v-else-if="type === 'select'"
|
||||||
|
:model-value="modelValue"
|
||||||
|
class="inputItem selectBox"
|
||||||
|
size="large"
|
||||||
|
style="width: 100%"
|
||||||
|
v-bind="inputAttrs"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in radioOptions"
|
||||||
|
:key="item[valueKey]"
|
||||||
|
:disabled="isOptionDisabled(item)"
|
||||||
|
:label="item[labelKey]"
|
||||||
|
:value="item[valueKey]"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
<!-- 多选-->
|
<!-- 多选-->
|
||||||
<el-checkbox-group
|
<el-checkbox-group
|
||||||
v-else-if="type === 'checkBox'"
|
v-else-if="type === 'checkBox'"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue as (string | number)[]"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@update:model-value="$emit('update:modelValue', $event)"
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
>
|
>
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
v-for="item in radioOptions"
|
v-for="item in radioOptions"
|
||||||
:key="item.value"
|
:key="item[valueKey]"
|
||||||
:label="item.label"
|
:disabled="isOptionDisabled(item)"
|
||||||
:value="item.value"
|
:label="item[labelKey]"
|
||||||
|
:value="item[valueKey]"
|
||||||
class="checkBox"
|
class="checkBox"
|
||||||
>
|
>
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
@@ -125,11 +146,14 @@
|
|||||||
v-else-if="type === 'password'"
|
v-else-if="type === 'password'"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
class="inputItem"
|
class="inputItem"
|
||||||
show-password
|
:type="showPassword ? 'text' : 'password'"
|
||||||
v-bind="inputAttrs"
|
v-bind="inputAttrs"
|
||||||
@input="$emit('update:modelValue', $event)"
|
@input="$emit('update:modelValue', $event)"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
|
<span class="password-toggle" @click="showPassword = !showPassword">
|
||||||
|
{{ showPassword ? '隐藏' : '显示' }}
|
||||||
|
</span>
|
||||||
<slot name="suffix"></slot>
|
<slot name="suffix"></slot>
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
@@ -192,14 +216,41 @@ const props = defineProps({
|
|||||||
default: () => ({})
|
default: () => ({})
|
||||||
},
|
},
|
||||||
radioOptions: {
|
radioOptions: {
|
||||||
type: Array as PropType<Array<{ label: string, value: string | number }>>,
|
type: Array as PropType<Array<Record<string, any>>>,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
|
labelKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'label'
|
||||||
|
},
|
||||||
|
valueKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'value'
|
||||||
|
},
|
||||||
|
disabledValues: {
|
||||||
|
type: Array as PropType<Array<string | number>>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
disabledPredicate: {
|
||||||
|
type: Function as PropType<(item: Record<string, any>) => boolean>,
|
||||||
|
default: undefined
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'file-change'])
|
const emit = defineEmits(['update:modelValue', 'file-change'])
|
||||||
|
|
||||||
|
const isOptionDisabled = (item: Record<string, any>) => {
|
||||||
|
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<UploadInstance>()
|
const upload = ref<UploadInstance>()
|
||||||
const defaultImag = import.meta.env.VITE_DEFAULT_USER_IMAGE;
|
const defaultImag = import.meta.env.VITE_DEFAULT_USER_IMAGE;
|
||||||
const imageUrl = ref('')
|
const imageUrl = ref('')
|
||||||
@@ -377,6 +428,24 @@ const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
|||||||
border-radius: 12px 12px 12px 12px;
|
border-radius: 12px 12px 12px 12px;
|
||||||
background-color: rgba(247, 249, 252, 1);
|
background-color: rgba(247, 249, 252, 1);
|
||||||
border: 1px solid rgba(229, 231, 235, 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 {
|
.radioGroup {
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ const props = defineProps({
|
|||||||
<slot name="rightTitle"></slot>
|
<slot name="rightTitle"></slot>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="props.subTitle" class="subTitle">{{ props.subTitle }}</div>
|
<div v-if="props.subTitle" class="subTitle">{{ props.subTitle }}</div>
|
||||||
|
<div v-else-if="$slots.subTitle" class="subTitle">
|
||||||
|
<slot name="subTitle"></slot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ function addIframe() {
|
|||||||
.app-main:not(.with-fixed-header) {
|
.app-main:not(.with-fixed-header) {
|
||||||
min-height: calc(100vh - 64px);
|
min-height: calc(100vh - 64px);
|
||||||
max-height: calc(100vh - 64px);
|
max-height: calc(100vh - 64px);
|
||||||
|
height: calc(100vh - 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main.with-tags-view:not(.with-fixed-header) {
|
.app-main.with-tags-view:not(.with-fixed-header) {
|
||||||
|
|||||||
@@ -55,14 +55,14 @@ router.beforeEach(async (to, from) => {
|
|||||||
if (res.data.roles.includes('customer_service')) {
|
if (res.data.roles.includes('customer_service')) {
|
||||||
if (to.path === '/index') {
|
if (to.path === '/index') {
|
||||||
return {
|
return {
|
||||||
path: '/conversation/conversationList',
|
path: '/cs/session',
|
||||||
replace: true
|
replace: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 客服登录默认首页:如果原本跳转目标是根路径 /,也跳会话列表
|
// 客服登录默认首页:如果原本跳转目标是根路径 /,也跳会话列表
|
||||||
if (to.path === '/') {
|
if (to.path === '/') {
|
||||||
return {
|
return {
|
||||||
path: '/conversation/conversationList',
|
path: '/cs/session',
|
||||||
replace: true
|
replace: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,7 +88,7 @@ router.beforeEach(async (to, from) => {
|
|||||||
} else {
|
} else {
|
||||||
const redirect = encodeURIComponent(to.fullPath || '/');
|
const redirect = encodeURIComponent(to.fullPath || '/');
|
||||||
NProgress.done();
|
NProgress.done();
|
||||||
return `/login?redirect=${redirect}`; // 否则全部重定向到登录页
|
return `/login`; // 否则全部重定向到登录页
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
383
src/store/modules/csSession.ts
Normal file
383
src/store/modules/csSession.ts
Normal file
@@ -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<UserListType, listUserType[]> = {
|
||||||
|
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<string>('');
|
||||||
|
// 当前客服负责用户 客户端会话id (唯一标识) 发送消息用
|
||||||
|
const currentClientSessionId = () => {
|
||||||
|
return state.userInfoMain.customer.userNo + '-' + Date.now() + '-' + localStorage.getItem('userInfo')
|
||||||
|
};
|
||||||
|
// 客服负责所有用户的消息
|
||||||
|
const allUsersMessages = reactive<Record<string, any[]>>({});
|
||||||
|
|
||||||
|
// ⚠️ 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<UserListType>('all')
|
||||||
|
|
||||||
|
// 切换客服负责用户列表的类型(全部/接待中/排队中/已结束)
|
||||||
|
function handleActiveUserType(value: UserListType) {
|
||||||
|
activeUserType.value = value
|
||||||
|
getRightUserList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客服负责用户列表
|
||||||
|
const userLists = ref<Record<UserListType, listUserType[]>>({...initUserLists});
|
||||||
|
|
||||||
|
// 左侧用户列表刷新
|
||||||
|
function getRightUserList() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
csAgentDesk(activeUserType.value).then((res) => {
|
||||||
|
userLists.value = res.data
|
||||||
|
resolve(res.data)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== 当前会话相关 ===================== */
|
||||||
|
/** 当前会话开始的会话 ID 列表 */
|
||||||
|
const currentStartSessionIdList = ref<string[]>([])
|
||||||
|
/** 当前会话的消息列表(直接给 ChatMessageList 组件用) */
|
||||||
|
const currentMessages = ref<ChatMessage[]>([]);
|
||||||
|
|
||||||
|
/* ===================== ws 相关 ===================== */
|
||||||
|
/** 全局共享的 ws 单例 */
|
||||||
|
let ws: WSClient | null = null;
|
||||||
|
/** ws 当前连接状态(给 UI 显示"连接中/重连中...") */
|
||||||
|
const wsStatus = ref<string>('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<Record<UserListType, any[]>>) {
|
||||||
|
Object.assign(userLists.value, lists);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUserLists = computed(() => {
|
||||||
|
return userLists.value[activeUserType.value];
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ===================== 当前会话操作 ===================== */
|
||||||
|
|
||||||
|
/** 点击左侧某个用户 → 发起/恢复会话 */
|
||||||
|
// async function startSession(userId: string): Promise<boolean> {
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
0
src/store/modules/type.ts
Normal file
0
src/store/modules/type.ts
Normal file
@@ -54,6 +54,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
} else {
|
} else {
|
||||||
roles.value = ['ROLE_DEFAULT'];
|
roles.value = ['ROLE_DEFAULT'];
|
||||||
}
|
}
|
||||||
|
localStorage.setItem('userInfo', data.user.userId.toString());
|
||||||
name.value = user.userName;
|
name.value = user.userName;
|
||||||
nickname.value = user.nickName;
|
nickname.value = user.nickName;
|
||||||
avatar.value = profile;
|
avatar.value = profile;
|
||||||
|
|||||||
2
src/types/env.d.ts
vendored
2
src/types/env.d.ts
vendored
@@ -15,7 +15,9 @@ interface ImportMetaEnv {
|
|||||||
VITE_APP_PORT: number;
|
VITE_APP_PORT: number;
|
||||||
VITE_APP_BASE_API: string;
|
VITE_APP_BASE_API: string;
|
||||||
VITE_APP_BASE_URL: string;
|
VITE_APP_BASE_URL: string;
|
||||||
|
VITE_WS_URL: string;
|
||||||
VITE_APP_CONTEXT_PATH: string;
|
VITE_APP_CONTEXT_PATH: string;
|
||||||
|
VITE_CS_AGENT_CLIENT_ID: string;
|
||||||
VITE_APP_MONITOR_ADMIN: string;
|
VITE_APP_MONITOR_ADMIN: string;
|
||||||
VITE_APP_SNAILJOB_ADMIN: string;
|
VITE_APP_SNAILJOB_ADMIN: string;
|
||||||
VITE_APP_SNAILAI_ADMIN: string;
|
VITE_APP_SNAILAI_ADMIN: string;
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ export const getToken = () => tokenStorage.value;
|
|||||||
export const setToken = (access_token: string) => (tokenStorage.value = access_token);
|
export const setToken = (access_token: string) => (tokenStorage.value = access_token);
|
||||||
|
|
||||||
export const removeToken = () => (tokenStorage.value = null);
|
export const removeToken = () => (tokenStorage.value = null);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* @example 136****9023
|
* @example 136****9023
|
||||||
* */
|
* */
|
||||||
export function hiddenPhone(phone: string): string {
|
export function hiddenPhone(phone: string): string {
|
||||||
|
if (!phone) return '---';
|
||||||
if (phone.length < 11) {
|
if (phone.length < 11) {
|
||||||
return phone
|
return phone
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ export const globalHeaders = () => {
|
|||||||
|
|
||||||
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';
|
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';
|
||||||
axios.defaults.headers['clientid'] = import.meta.env.VITE_APP_CLIENT_ID;
|
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 实例
|
// 创建 axios 实例
|
||||||
const service = axios.create({
|
const service = axios.create({
|
||||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||||
@@ -111,6 +122,7 @@ service.interceptors.request.use(
|
|||||||
(config: any) => {
|
(config: any) => {
|
||||||
// 对应国际化资源文件后缀
|
// 对应国际化资源文件后缀
|
||||||
config.headers['Content-Language'] = getLanguage();
|
config.headers['Content-Language'] = getLanguage();
|
||||||
|
// config.headers['clientid'] = resolveClientId();
|
||||||
|
|
||||||
const isToken = config.headers?.isToken === false;
|
const isToken = config.headers?.isToken === false;
|
||||||
// 是否需要防止数据重复提交
|
// 是否需要防止数据重复提交
|
||||||
|
|||||||
307
src/utils/webSocket/index.ts
Normal file
307
src/utils/webSocket/index.ts
Normal file
@@ -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<packet>
|
||||||
|
* .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<string, Set<Handler>>();
|
||||||
|
private acks: Array<{
|
||||||
|
match: (p: any) => boolean;
|
||||||
|
resolve: (p: any) => void;
|
||||||
|
reject: (e: Error) => void;
|
||||||
|
timer: any
|
||||||
|
}> = [];
|
||||||
|
private listeners = new Map<string, Set<Listener>>();
|
||||||
|
|
||||||
|
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<WSPacket> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
}"
|
}"
|
||||||
></div>
|
></div>
|
||||||
<div>
|
<div>
|
||||||
<span>{{ numToWeek(item.formateDay + 1)}}</span>
|
<span>{{ numToWeek(item.formateDay + 1) }}</span>
|
||||||
<span style="font-size: 12px">({{ item.month }}.{{ item.date }})</span>
|
<span style="font-size: 12px">({{ item.month }}.{{ item.date }})</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -119,19 +119,22 @@
|
|||||||
@keyup.enter="handleLogin"
|
@keyup.enter="handleLogin"
|
||||||
>
|
>
|
||||||
<template #prefix
|
<template #prefix
|
||||||
><svg-icon
|
>
|
||||||
|
<svg-icon
|
||||||
icon-class="validCode"
|
icon-class="validCode"
|
||||||
class="el-input__icon input-icon"
|
class="el-input__icon input-icon"
|
||||||
/></template>
|
/>
|
||||||
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<div class="login-code">
|
<div class="login-code">
|
||||||
<img :src="codeUrl" class="login-code-img" @click="getCode" />
|
<img :src="codeUrl" class="login-code-img" @click="getCode"/>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="form-meta">
|
<div class="form-meta">
|
||||||
<el-checkbox v-model="loginForm.rememberMe">{{
|
<el-checkbox v-model="loginForm.rememberMe">{{
|
||||||
$t("login.rememberPassword")
|
$t("login.rememberPassword")
|
||||||
}}</el-checkbox>
|
}}
|
||||||
|
</el-checkbox>
|
||||||
<router-link v-if="register" class="link-type" :to="'/register'">
|
<router-link v-if="register" class="link-type" :to="'/register'">
|
||||||
{{ $t("login.switchRegisterPage") }}
|
{{ $t("login.switchRegisterPage") }}
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -168,7 +171,7 @@ const title = import.meta.env.VITE_APP_TITLE;
|
|||||||
const quickStats = ref([]);
|
const quickStats = ref([]);
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const {t} = useI18n();
|
||||||
|
|
||||||
const loginForm = ref<LoginData>({
|
const loginForm = ref<LoginData>({
|
||||||
username: "admin",
|
username: "admin",
|
||||||
@@ -217,7 +220,7 @@ watch(
|
|||||||
newRoute.query.redirect &&
|
newRoute.query.redirect &&
|
||||||
decodeURIComponent(newRoute.query.redirect);
|
decodeURIComponent(newRoute.query.redirect);
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{immediate: true},
|
||||||
);
|
);
|
||||||
|
|
||||||
const isShowPwd = ref(false);
|
const isShowPwd = ref(false);
|
||||||
@@ -256,7 +259,7 @@ const handleLogin = () => {
|
|||||||
|
|
||||||
const getCode = async () => {
|
const getCode = async () => {
|
||||||
const res = await getCodeImg();
|
const res = await getCodeImg();
|
||||||
const { data } = res;
|
const {data} = res;
|
||||||
captchaEnabled.value =
|
captchaEnabled.value =
|
||||||
data.captchaEnabled === undefined ? true : data.captchaEnabled;
|
data.captchaEnabled === undefined ? true : data.captchaEnabled;
|
||||||
if (captchaEnabled.value) {
|
if (captchaEnabled.value) {
|
||||||
@@ -286,8 +289,17 @@ const handleGetLoc = async () => {
|
|||||||
)
|
)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const arr = res.data.forecast.daily.pm10 ?? [];
|
// const weightArr = res.data.forecast.daily.pm10 ?? [];
|
||||||
quickStats.value = handleMax(arr.slice(0, 7));
|
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) {
|
} catch (e) {
|
||||||
console.error((e as Error).message);
|
console.error((e as Error).message);
|
||||||
@@ -343,10 +355,12 @@ onMounted(() => {
|
|||||||
padding: 60px 80px;
|
padding: 60px 80px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.logo-box {
|
.logo-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 160px;
|
margin-bottom: 160px;
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
@@ -357,6 +371,7 @@ onMounted(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 48px;
|
line-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo_info {
|
.logo_info {
|
||||||
.title {
|
.title {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -366,6 +381,7 @@ onMounted(() => {
|
|||||||
color: rgba(255, 255, 255, 1);
|
color: rgba(255, 255, 255, 1);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subTitle {
|
.subTitle {
|
||||||
height: 18px;
|
height: 18px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
@@ -385,6 +401,7 @@ onMounted(() => {
|
|||||||
font-size: clamp(34px, 4vw, 52px);
|
font-size: clamp(34px, 4vw, 52px);
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2.brand-title {
|
h2.brand-title {
|
||||||
font-size: clamp(28px, 4vw, 46px);
|
font-size: clamp(28px, 4vw, 46px);
|
||||||
}
|
}
|
||||||
@@ -404,12 +421,14 @@ onMounted(() => {
|
|||||||
justify-items: center;
|
justify-items: center;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
margin-bottom: 40px;
|
margin-bottom: 40px;
|
||||||
|
|
||||||
.devices {
|
.devices {
|
||||||
.data {
|
.data {
|
||||||
height: 38px;
|
height: 38px;
|
||||||
line-height: 38px;
|
line-height: 38px;
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
height: 18px;
|
height: 18px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
@@ -417,6 +436,7 @@ onMounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
background-color: rgba(255, 255, 255, 0.24);
|
background-color: rgba(255, 255, 255, 0.24);
|
||||||
@@ -431,17 +451,20 @@ onMounted(() => {
|
|||||||
background-color: rgba(255, 255, 255, 0.1);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-metrics_list {
|
.brand-metrics_list {
|
||||||
margin-top: 48px;
|
margin-top: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
height: 105px;
|
height: 105px;
|
||||||
|
|
||||||
.metric-card {
|
.metric-card {
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
@@ -449,9 +472,11 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background-color: rgba(255, 255, 255, 0.6);
|
background-color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: rgba(226, 232, 240, 0.72);
|
color: rgba(226, 232, 240, 0.72);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -481,6 +506,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
width: 480px;
|
width: 480px;
|
||||||
height: 600px;
|
height: 600px;
|
||||||
@@ -529,6 +555,7 @@ onMounted(() => {
|
|||||||
border: 1px solid var(--app-surface-border);
|
border: 1px solid var(--app-surface-border);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loginModel {
|
.loginModel {
|
||||||
height: 22px;
|
height: 22px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
@@ -549,6 +576,7 @@ onMounted(() => {
|
|||||||
border-radius: 12px 12px 12px 12px;
|
border-radius: 12px 12px 12px 12px;
|
||||||
background-color: rgba(247, 248, 250, 1);
|
background-color: rgba(247, 248, 250, 1);
|
||||||
border: 1px solid rgba(238, 240, 243, 1);
|
border: 1px solid rgba(238, 240, 243, 1);
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(37, 99, 235, 1);
|
color: rgba(37, 99, 235, 1);
|
||||||
@@ -619,6 +647,7 @@ onMounted(() => {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 18px 34px rgba(53, 109, 255, 0.22);
|
box-shadow: 0 18px 34px rgba(53, 109, 255, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tip {
|
.tip {
|
||||||
background-color: rgba(0, 0, 0, 0);
|
background-color: rgba(0, 0, 0, 0);
|
||||||
color: rgba(156, 163, 175, 1);
|
color: rgba(156, 163, 175, 1);
|
||||||
@@ -634,8 +663,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-form :deep(.el-input__wrapper.is-focus) {
|
.login-form :deep(.el-input__wrapper.is-focus) {
|
||||||
box-shadow:
|
box-shadow: 0 0 0 1px rgba(53, 109, 255, 0.24) inset,
|
||||||
0 0 0 1px rgba(53, 109, 255, 0.24) inset,
|
|
||||||
0 0 0 4px rgba(53, 109, 255, 0.12);
|
0 0 0 4px rgba(53, 109, 255, 0.12);
|
||||||
background-color: rgba(255, 255, 255, 1);
|
background-color: rgba(255, 255, 255, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
:autosize="{ minRows:5, maxRows:8 }"
|
:autosize="{ minRows:5, maxRows:8 }"
|
||||||
placeholder="请输入回复内容"
|
placeholder="请输入回复内容"
|
||||||
|
:disabled="disable"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@keydown="handleKeyDown"
|
@keydown="handleKeyDown"
|
||||||
/>
|
/>
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
class="btn-img"
|
class="btn-img"
|
||||||
plain
|
plain
|
||||||
type="info"
|
type="info"
|
||||||
|
:disabled="disable"
|
||||||
@click="handleSelectImage"
|
@click="handleSelectImage"
|
||||||
>
|
>
|
||||||
上传图片
|
上传图片
|
||||||
@@ -52,6 +54,12 @@ const props = defineProps({
|
|||||||
sending: {
|
sending: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 是否禁用输入框 */
|
||||||
|
disable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -68,7 +76,7 @@ const inputText = ref('')
|
|||||||
|
|
||||||
// 是否可以发送
|
// 是否可以发送
|
||||||
const canSend = computed(() => {
|
const canSend = computed(() => {
|
||||||
return !!inputText.value.trim()
|
return !!inputText.value.trim() && !props.disable
|
||||||
})
|
})
|
||||||
|
|
||||||
// 点击图片图标,唤起文件选择
|
// 点击图片图标,唤起文件选择
|
||||||
|
|||||||
@@ -1,68 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="currentClass" class="chat-tip-bar">
|
<div class="chat-tip-bar agent">
|
||||||
<!-- 提示条区域,放在MessageList内部最顶部,跟随滚动 -->
|
<!-- 提示条区域,放在MessageList内部最顶部,跟随滚动 -->
|
||||||
<span>{{ currentText }}</span>
|
<span>{{ msg }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {computed} from 'vue'
|
defineProps({
|
||||||
import {ChatNavBarType} from './types'
|
msg: {
|
||||||
import type {PropType} from 'vue'
|
type: String,
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
type: {
|
|
||||||
type: Number as PropType<ChatNavBarType>,
|
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
time: String,
|
|
||||||
awaitNum: Number,
|
|
||||||
agentInfo: {
|
|
||||||
type: Object as PropType<{
|
|
||||||
name: string,
|
|
||||||
no: string,
|
|
||||||
}>,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 根据type自动切换文本
|
|
||||||
const currentText = computed(() => {
|
|
||||||
switch (props.type) {
|
|
||||||
case ChatNavBarType.agent:
|
|
||||||
return `${props.time} 用户点击「仍未解决,转人工」`
|
|
||||||
case ChatNavBarType.await:
|
|
||||||
return `排队提醒:当前排队第 ${props.awaitNum} 位,已等待 ${props.time}`
|
|
||||||
case ChatNavBarType.accept:
|
|
||||||
return `客服${props.agentInfo?.name}(工号 ${props.agentInfo?.no})已接入会话`
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 样式类
|
|
||||||
const currentClass = computed(() => {
|
|
||||||
switch (props.type) {
|
|
||||||
case ChatNavBarType.agent:
|
|
||||||
return 'agent'
|
|
||||||
case ChatNavBarType.await:
|
|
||||||
return 'await'
|
|
||||||
case ChatNavBarType.accept:
|
|
||||||
return 'accept'
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chat-tip-bar {
|
.chat-tip-bar {
|
||||||
min-width: 250px;
|
max-width: 280px;
|
||||||
max-width: 250px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 7px auto;
|
margin: 7px auto;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
text-wrap: wrap;
|
text-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,20 +8,23 @@
|
|||||||
<span class="time">{{ time }}</span>
|
<span class="time">{{ time }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 文本消息 -->
|
<!-- 文本消息 -->
|
||||||
<div v-if="type === 'text'" class="bubble" v-html="content"></div>
|
<div v-if="type === 'text'" class="bubble text-bubble" v-html="content"></div>
|
||||||
<!-- 图片消息 -->
|
<!-- 图片消息 -->
|
||||||
<div v-else-if="type === 'image'" class="bubble img-bubble">
|
<div v-else-if="type === 'image'" class="bubble img-bubble">
|
||||||
<img :src="content" class="chat-img" @click="$emit('preview', content)"/>
|
<el-image :preview-src-list="[url]" :src="url" class="chat-img" @click="$emit('preview', url)"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
defineProps({
|
import avatar from '@/assets/images/avatar.png'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
name: {type: String, default: '客服'},
|
name: {type: String, default: '客服'},
|
||||||
avatar: {type: String, default: ''},
|
avatar: {type: String, default: avatar},
|
||||||
content: {type: String, required: true},
|
content: {type: String, default: ''},
|
||||||
|
url: {type: String, default: ''},
|
||||||
type: {type: String as () => 'text' | 'image', default: 'text'},
|
type: {type: String as () => 'text' | 'image', default: 'text'},
|
||||||
tag: {type: String, default: '人工客服'},
|
tag: {type: String, default: '人工客服'},
|
||||||
time: {type: String, default: ''}
|
time: {type: String, default: ''}
|
||||||
@@ -83,13 +86,17 @@ defineEmits(['preview'])
|
|||||||
color: rgba(31, 41, 55, 1);
|
color: rgba(31, 41, 55, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-bubble {
|
||||||
|
display: inline-block;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.img-bubble {
|
.img-bubble {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-img {
|
.chat-img {
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
max-height: 200px;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ defineEmits(['quickAction'])
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
color: #333;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.quick-actions {
|
.quick-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -5,20 +5,23 @@
|
|||||||
<div v-if="type === 'text'" class="bubble" v-html="content"></div>
|
<div v-if="type === 'text'" class="bubble" v-html="content"></div>
|
||||||
<!-- 图片消息 -->
|
<!-- 图片消息 -->
|
||||||
<div v-else-if="type === 'image'" class="bubble img-bubble">
|
<div v-else-if="type === 'image'" class="bubble img-bubble">
|
||||||
<img :src="content" class="chat-img"/>
|
<el-image :preview-src-list="[url]" :src="url" class="chat-img"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="time">{{ time }}</div>
|
<div class="time">{{ time }}</div>
|
||||||
</div>
|
</div>
|
||||||
<img :src="avatar" class="avatar"/>
|
<el-image :src="avatar" class="avatar"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import avatar from '@/assets/images/avatar.png'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
content: {type: String, required: true},
|
content: {type: String, required: true},
|
||||||
type: {type: String as () => 'text' | 'image', default: 'text'},
|
type: {type: String as () => 'text' | 'image', default: 'text'},
|
||||||
avatar: {type: String, default: ''},
|
avatar: {type: String, default: avatar},
|
||||||
time: {type: String, default: ''}
|
time: {type: String, default: ''},
|
||||||
|
url: {type: String, default: ''}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -58,12 +61,11 @@ defineProps({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.img-bubble {
|
.img-bubble {
|
||||||
padding: 4px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-img {
|
.chat-img {
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
max-height: 200px;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +1,72 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="listRef"
|
ref="listRef"
|
||||||
class="message-list "
|
class="message-list"
|
||||||
@scroll.passive="handleScroll"
|
@scroll.passive="handleScroll"
|
||||||
>
|
>
|
||||||
<!-- 加载历史消息提示 -->
|
|
||||||
<div v-if="loadingHistory" class="load-more-tip">
|
<div v-if="loadingHistory" class="load-more-tip">
|
||||||
<span class="spinner"></span> 加载中...
|
<span class="spinner"></span> 加载中...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 消息列表:key 必须用唯一 id,禁止 index -->
|
<template v-for="msg in messages" :key="msg.seq">
|
||||||
<template v-for="msg in messages" :key="msg.id">
|
<ChatNavBar v-if="msg.senderType === 'system'" :msg="msg.content"/>
|
||||||
<!-- 2. 用户消息 -->
|
|
||||||
<UserMessage
|
<UserMessage
|
||||||
v-if="msg.role === 'user'"
|
v-else-if="msg.senderType === 'user'"
|
||||||
:avatar="userAvatar"
|
:avatar="userAvatar"
|
||||||
:content="msg.content"
|
:content="msg.content"
|
||||||
:msg-type="msg.msgType"
|
:time="msg.createTime"
|
||||||
:status="msg.status"
|
:type="msg.msgType"
|
||||||
:time="msg.time"
|
:url="msg.url"
|
||||||
@retry="handleRetry(msg)"
|
@retry="handleRetry(msg)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 3. 机器人消息 -->
|
|
||||||
<RobotMessage
|
<RobotMessage
|
||||||
v-else-if="msg.agentType === 'robot'"
|
v-else-if="msg.senderType === 'bot'"
|
||||||
:avatar="msg.avatar"
|
|
||||||
:content="msg.content"
|
:content="msg.content"
|
||||||
:msg-type="msg.msgType"
|
:msg-type="msg.msgType"
|
||||||
:name="msg.agentName"
|
|
||||||
:quick-actions="msg.quickActions"
|
:quick-actions="msg.quickActions"
|
||||||
:time="msg.time"
|
:time="msg.createTime"
|
||||||
@quick-action="handleQuickAction"
|
@quick-action="handleQuickAction"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 4. 人工客服消息 -->
|
|
||||||
<AgentMessage
|
<AgentMessage
|
||||||
v-else
|
v-else-if="msg.senderType === 'agent'"
|
||||||
:avatar="msg.avatar"
|
|
||||||
:content="msg.content"
|
:content="msg.content"
|
||||||
:msg-type="msg.msgType"
|
:time="msg.createTime"
|
||||||
:name="msg.agentName"
|
:type="msg.msgType"
|
||||||
:time="msg.time"
|
:url="msg.url"
|
||||||
@preview="handlePreview"
|
@preview="handlePreview"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 底部占位 -->
|
|
||||||
<div class="bottom-space"></div>
|
<div class="bottom-space"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {ref, watch, nextTick, onMounted} from 'vue'
|
import {ref, watch, nextTick, onMounted} from 'vue'
|
||||||
import SystemMessage from './SystemMessage.vue'
|
|
||||||
import UserMessage from './UserMessage.vue'
|
import UserMessage from './UserMessage.vue'
|
||||||
import RobotMessage from './RobotMessage.vue'
|
import RobotMessage from './RobotMessage.vue'
|
||||||
import AgentMessage from './AgentMessage.vue'
|
import AgentMessage from './AgentMessage.vue'
|
||||||
|
import ChatNavBar from '@/views/system/conversation/compontents/ChatNavBar.vue'
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
id: string | number
|
senderType: 'user' | 'agent' | 'system' | 'bot'
|
||||||
type: 'system' | 'chat'
|
|
||||||
role?: 'user' | 'agent'
|
|
||||||
agentType?: 'robot' | 'human'
|
|
||||||
content: string
|
content: string
|
||||||
msgType?: 'text' | 'image'
|
msgType: 'text' | 'image'
|
||||||
agentName?: string
|
createTime: string
|
||||||
avatar?: string
|
|
||||||
time: string
|
|
||||||
status?: 'sending' | 'sent' | 'failed'
|
|
||||||
quickActions?: { label: string; action: string; primary?: boolean }[]
|
quickActions?: { label: string; action: string; primary?: boolean }[]
|
||||||
|
clientMsgId: string
|
||||||
|
messageId: string
|
||||||
|
ossId: string
|
||||||
|
url: string
|
||||||
|
senderId: string
|
||||||
|
seq: number
|
||||||
|
sessionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
userAvatar: string
|
userAvatar: string
|
||||||
loadingHistory?: boolean
|
loadingHistory?: boolean
|
||||||
/** 是否还有更多历史可加载(可选,建议父组件传) */
|
|
||||||
hasMoreHistory?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -89,122 +78,68 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const listRef = ref<HTMLElement>()
|
const listRef = ref<HTMLElement>()
|
||||||
|
|
||||||
/** 用户是否贴近底部(阈值 50px) */
|
|
||||||
const isAtBottom = ref(true)
|
const isAtBottom = ref(true)
|
||||||
|
|
||||||
/** 首屏标记:首次渲染不走平滑滚动 */
|
|
||||||
let isFirstRender = true
|
let isFirstRender = true
|
||||||
|
|
||||||
/** 加载历史前的 scrollHeight 快照 */
|
|
||||||
let prevScrollHeight = 0
|
|
||||||
|
|
||||||
/** 加载历史防抖锁 */
|
|
||||||
let isLoadingMore = false
|
let isLoadingMore = false
|
||||||
|
|
||||||
/** 阈值常量 */
|
|
||||||
const BOTTOM_THRESHOLD = 50
|
const BOTTOM_THRESHOLD = 50
|
||||||
const TOP_THRESHOLD = 30
|
const TOP_THRESHOLD = 30
|
||||||
|
|
||||||
/* ========== 判断是否贴近底部 ========== */
|
|
||||||
const checkAtBottom = (): boolean => {
|
const checkAtBottom = (): boolean => {
|
||||||
const el = listRef.value
|
const el = listRef.value
|
||||||
if (!el) return true
|
if (!el) return true
|
||||||
return el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD
|
return el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 滚动到底部 ========== */
|
const scrollToBottom = async () => {
|
||||||
const scrollToBottom = (smooth = false) => {
|
await nextTick()
|
||||||
const el = listRef.value
|
const el = listRef.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
el.scrollTo({
|
await new Promise<void>(resolve => {
|
||||||
top: el.scrollHeight,
|
requestAnimationFrame(() => {
|
||||||
behavior: smooth ? 'smooth' : 'auto'
|
el.scrollTop = el.scrollHeight
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 滚动事件 ========== */
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const el = listRef.value
|
const el = listRef.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
// 1. 更新底部状态
|
|
||||||
isAtBottom.value = checkAtBottom()
|
isAtBottom.value = checkAtBottom()
|
||||||
|
if (el.scrollTop <= TOP_THRESHOLD && !isLoadingMore) {
|
||||||
// 2. 触顶加载历史
|
|
||||||
if (
|
|
||||||
el.scrollTop <= TOP_THRESHOLD &&
|
|
||||||
!isLoadingMore &&
|
|
||||||
!props.loadingHistory &&
|
|
||||||
props.hasMoreHistory !== false
|
|
||||||
) {
|
|
||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
// 关键:在 emit 之前记录当前 scrollHeight 快照
|
|
||||||
prevScrollHeight = el.scrollHeight
|
|
||||||
emit('loadMore')
|
emit('loadMore')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 首屏 + 新消息滚动 ========== */
|
|
||||||
watch(
|
watch(
|
||||||
() => props.messages?.length ?? 0,
|
() => props.messages.length,
|
||||||
async (newLen, oldLen) => {
|
async (newLen) => {
|
||||||
await nextTick()
|
isLoadingMore = false
|
||||||
|
|
||||||
|
if (newLen === 0) return
|
||||||
|
|
||||||
if (isFirstRender) {
|
if (isFirstRender) {
|
||||||
// 场景 ①:首次进入页面,无动画直接滚到底
|
await scrollToBottom()
|
||||||
scrollToBottom(false)
|
|
||||||
isFirstRender = false
|
isFirstRender = false
|
||||||
isAtBottom.value = true
|
isAtBottom.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 场景 ②:新消息到达 && 用户在底部 → 平滑滚到底
|
if (isAtBottom.value) {
|
||||||
// 场景 ③:用户在翻历史 → 不滚动
|
await scrollToBottom()
|
||||||
if (newLen > oldLen && isAtBottom.value) {
|
|
||||||
scrollToBottom(true)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{immediate: true}
|
{immediate: true}
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ========== 加载历史后,保持视觉位置 ========== */
|
|
||||||
watch(
|
|
||||||
() => props.loadingHistory,
|
|
||||||
async (loading, wasLoading) => {
|
|
||||||
// 只在 loading 从 true → false 的瞬间处理
|
|
||||||
if (wasLoading && !loading) {
|
|
||||||
await nextTick()
|
|
||||||
const el = listRef.value
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
// 场景 ④:内容变高了,把 scrollTop 平移到等价位置
|
|
||||||
const delta = el.scrollHeight - prevScrollHeight
|
|
||||||
el.scrollTop = el.scrollTop + delta
|
|
||||||
|
|
||||||
// 重算底部状态(此时肯定不在底部)
|
|
||||||
isAtBottom.value = checkAtBottom()
|
|
||||||
|
|
||||||
// 解锁,允许下一次加载
|
|
||||||
isLoadingMore = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ========== 挂载后兜底:图片等异步内容渲染后可能改变 scrollHeight ========== */
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => {
|
|
||||||
scrollToBottom(false)
|
|
||||||
isAtBottom.value = true
|
isAtBottom.value = true
|
||||||
isFirstRender = false
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/* ========== 对外暴露手动滚底(如发送消息成功后) ========== */
|
defineExpose({scrollToBottom})
|
||||||
defineExpose({
|
|
||||||
scrollToBottom: (smooth = true) => scrollToBottom(smooth)
|
|
||||||
})
|
|
||||||
|
|
||||||
/* ========== 事件转发 ========== */
|
|
||||||
const handleQuickAction = (action: string) => emit('quickAction', action)
|
const handleQuickAction = (action: string) => emit('quickAction', action)
|
||||||
const handleRetry = (msg: ChatMessage) => emit('retry', msg)
|
const handleRetry = (msg: ChatMessage) => emit('retry', msg)
|
||||||
const handlePreview = (url: string) => emit('preview', url)
|
const handlePreview = (url: string) => emit('preview', url)
|
||||||
@@ -213,13 +148,12 @@ const handlePreview = (url: string) => emit('preview', url)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.message-list {
|
.message-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0; /* ⚠️ 关键:flex 子项内滚动必须加 */
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
/* 隐藏滚动条(Chrome / Firefox / IE) */
|
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-overflow-style: none;
|
-ms-overflow-style: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +1,308 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import pageCrumb from '@/components/Pagecrumb/index.vue'
|
import {useCsSessionStore, userTypeList, type UserListType, listUserType, MAX_MESSAGES} from '@/store/modules/csSession'
|
||||||
|
|
||||||
|
|
||||||
import ChatNavBar from "./compontents/ChatNavBar.vue";
|
|
||||||
import {ChatNavBarType} from "./compontents/types";
|
|
||||||
|
|
||||||
import MessageList from './compontents/MessageList/index.vue'
|
import MessageList from './compontents/MessageList/index.vue'
|
||||||
import {initMessages, userAvatar} from "./compontents/data";
|
|
||||||
import ChatInput from "@/views/system/conversation/compontents/ChatInput.vue";
|
import ChatInput from "@/views/system/conversation/compontents/ChatInput.vue";
|
||||||
|
import {
|
||||||
|
deskCsAgentDeskBySessionId,
|
||||||
|
getAgentPresence,
|
||||||
|
getDeskCsAgentHistory,
|
||||||
|
transferAgentPresence, uploadAgentOss
|
||||||
|
} from "@/api/system/csSession";
|
||||||
|
import {parseTime} from "@/utils/ruoyi";
|
||||||
|
import {hiddenPhone} from "@/utils/phone";
|
||||||
|
import {uploadOss} from "@/api/system/oss";
|
||||||
|
import {Device} from "@/api/system/csSession/type";
|
||||||
|
import {useFormDialog} from "@/hooks/dialog/useFormDialog";
|
||||||
|
|
||||||
const defaultImage = import.meta.env.VITE_DEFAULT_USER_IMAGE;
|
const csSessionStore = useCsSessionStore()
|
||||||
const userDevicesList = [
|
|
||||||
{
|
const {
|
||||||
label: 'Z2 空气净化器 · 客厅',
|
state,
|
||||||
model: 'CZ-Z2023',
|
userInfoMessage,
|
||||||
mac: "AA:BB:CC:DD:A3:B6",
|
currentSessionId
|
||||||
status: 0,
|
} = storeToRefs(csSessionStore);
|
||||||
},
|
|
||||||
{
|
function handleActiveUserType(userType: UserListType) {
|
||||||
label: 'Z2 空气净化器 · 主卧',
|
csSessionStore.handleActiveUserType(userType)
|
||||||
model: 'CZ-Z2023',
|
}
|
||||||
mac: "AA:BB:CC:DD:A3:B1",
|
|
||||||
status: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Z2 空气净化器 · 儿童房',
|
|
||||||
model: 'CZ-Z2023',
|
|
||||||
mac: "AA:BB:CC:DD:A3:B2",
|
|
||||||
status: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Z2 空气净化器 · 次卧',
|
|
||||||
model: 'CZ-Z2023',
|
|
||||||
mac: "AA:BB:CC:DD:A3:B3",
|
|
||||||
status: 0,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
const form = ref({
|
/**
|
||||||
status: ChatNavBarType.await as ChatNavBarType,
|
* 发送文本消息
|
||||||
time: new Date().toLocaleTimeString(),
|
*/
|
||||||
awaitNum: 3,
|
const onSendMsg = async (text: string) => {
|
||||||
agentInfo: {
|
const client = csSessionStore.getWs()
|
||||||
no: '8823',
|
if (!client) {
|
||||||
name: '小雨'
|
console.warn('ws 未连接,无法发送')
|
||||||
},
|
return
|
||||||
message: []
|
}
|
||||||
})
|
let sendObj = {
|
||||||
|
"type": "message",
|
||||||
|
"content": text,
|
||||||
|
"msgType": "text",
|
||||||
|
"sessionId": currentSessionId.value,
|
||||||
|
"clientMsgId": csSessionStore.currentClientSessionId()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 客服发送消息
|
||||||
|
csSessionStore.setSending(true)
|
||||||
|
client.sendString(JSON.stringify(sendObj))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('消息发送失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUploadImage = async (file: File) => {
|
||||||
|
const client = csSessionStore.getWs()
|
||||||
|
if (!client) {
|
||||||
|
console.warn('ws 未连接,无法发送')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 1. 转换成 网络图片
|
||||||
|
// 2. 发送图片消息
|
||||||
|
let res = await handleSaveAvatar(file);
|
||||||
|
if (!res) return
|
||||||
|
console.log(res.data)
|
||||||
|
let sendObj = {
|
||||||
|
"type": "message",
|
||||||
|
"msgType": "image",
|
||||||
|
"sessionId": currentSessionId.value,
|
||||||
|
"ossId": res.data.ossId,
|
||||||
|
"clientMsgId": csSessionStore.currentClientSessionId()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 客服发送消息
|
||||||
|
csSessionStore.setSending(true)
|
||||||
|
client.sendString(JSON.stringify(sendObj))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('消息发送失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAvatar(file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file, 'avatar.png');
|
||||||
|
return uploadAgentOss(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 点击左侧某个用户 → 查看会话详情 */
|
||||||
|
async function handlePickUser(userInfo: listUserType) {
|
||||||
|
if (userInfo.status === '1') {
|
||||||
|
console.log('排队中')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentSessionId.value === userInfo.sessionId) {
|
||||||
|
csSessionStore.setCurrentUser(null)
|
||||||
|
csSessionStore.setCurrentUserInfoMain(null)
|
||||||
|
csSessionStore.initAllUsersMessages([], true)
|
||||||
|
csSessionStore.setCurrentSessionId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 0. 存储当前用户 id
|
||||||
|
csSessionStore.setCurrentSessionId(userInfo.sessionId);
|
||||||
|
// 1. 存储当前用户信息
|
||||||
|
csSessionStore.setCurrentUser(userInfo)
|
||||||
|
// 2. 调取当前用户信息
|
||||||
|
deskCsAgentDeskBySessionId(userInfo.sessionId).then(res => {
|
||||||
|
csSessionStore.setCurrentUserInfoMain(res.data)
|
||||||
|
console.log(state.value.userInfoMain.customer.avatarUrl)
|
||||||
|
})
|
||||||
|
// 3. 拉去过往新消息
|
||||||
|
// 每次只能拉取 MAX_MESSAGES 条消息
|
||||||
|
let latestSeq = Math.max((userInfo.latestSeq || 0) - MAX_MESSAGES, 0)
|
||||||
|
getDeskCsAgentHistory(userInfo.sessionId, latestSeq).then((res) => {
|
||||||
|
csSessionStore.initAllUsersMessages(res.data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingHistory = ref(false)
|
||||||
|
|
||||||
|
function handleLoadMore() {
|
||||||
|
loadingHistory.value = true
|
||||||
|
if (!currentSessionId.value) return
|
||||||
|
console.log('滚动到顶部了,想要加载更多消息')
|
||||||
|
// // 每次只能拉取 100 条消息
|
||||||
|
// 1. 得到最早的seq
|
||||||
|
let seq = userInfoMessage.value[0].seq;
|
||||||
|
if (seq === 0) return loadingHistory.value = false;
|
||||||
|
// 2. 减去 MAX_MESSAGES 条消息
|
||||||
|
let latestSeq = Math.max(seq - MAX_MESSAGES, 0)
|
||||||
|
// 3. 拉取过往新消息
|
||||||
|
// console.log('拉取过往新消息从', latestSeq)
|
||||||
|
getDeskCsAgentHistory(currentSessionId.value, latestSeq, seq).then(res => {
|
||||||
|
csSessionStore.handleLoadMoreMessages(res.data)
|
||||||
|
loadingHistory.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭会话
|
||||||
|
function closeSession() {
|
||||||
|
const client = csSessionStore.getWs()
|
||||||
|
if (!client) {
|
||||||
|
console.warn('ws 未连接,无法发送')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let sendObj = {
|
||||||
|
"type": "end",
|
||||||
|
"sessionId": currentSessionId.value,
|
||||||
|
"clientMsgId": csSessionStore.currentClientSessionId()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 客服发送消息
|
||||||
|
csSessionStore.setSending(true)
|
||||||
|
client.sendString(JSON.stringify(sendObj))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('消息发送失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 转接客服
|
||||||
|
function transferAgent() {
|
||||||
|
openDialog('转接客服')
|
||||||
|
getAgentList()
|
||||||
|
form.value.sessionId = currentSessionId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转接客服列表
|
||||||
|
const acceptAgentList = ref([])
|
||||||
|
|
||||||
|
// 获取转接客服列表
|
||||||
|
function getAgentList() {
|
||||||
|
getAgentPresence().then(res => {
|
||||||
|
acceptAgentList.value = res.data;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoFormRef = ref<ElFormInstance>();
|
||||||
|
const initFormData = {
|
||||||
|
agentId: undefined,
|
||||||
|
sessionId: undefined,
|
||||||
|
};
|
||||||
|
const data = reactive({
|
||||||
|
form: {...initFormData},
|
||||||
|
rules: {
|
||||||
|
agentId: [{required: true, message: '请选择转接客服', trigger: 'blur'}],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const {form, rules} = toRefs(data);
|
||||||
|
const buttonLoading = ref(false);
|
||||||
|
const {
|
||||||
|
dialog,
|
||||||
|
resetForm: reset,
|
||||||
|
openDialog,
|
||||||
|
closeDialog
|
||||||
|
} = useFormDialog({
|
||||||
|
form,
|
||||||
|
formRef: demoFormRef,
|
||||||
|
initialFormData: initFormData
|
||||||
|
});
|
||||||
|
/** 取消按钮 */
|
||||||
|
const cancel = () => {
|
||||||
|
reset();
|
||||||
|
closeDialog();
|
||||||
|
};
|
||||||
|
/** 提交按钮 */
|
||||||
|
const submitForm = () => {
|
||||||
|
demoFormRef.value?.validate(async (valid: boolean) => {
|
||||||
|
if (valid) {
|
||||||
|
buttonLoading.value = true;
|
||||||
|
console.log(form.value)
|
||||||
|
transferAgentPresence(form.value).then(() => {
|
||||||
|
csSessionStore.getRightUserList()
|
||||||
|
csSessionStore.setCurrentUserInfoMain(null)
|
||||||
|
}).finally(() => {
|
||||||
|
buttonLoading.value = false;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
form.value.message = [...initMessages]
|
// 确保 ws 已连接(store 内部是单例,调多次不会重复 new)
|
||||||
|
csSessionStore.ensureWs()
|
||||||
|
csSessionStore.getRightUserList()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 注意:组件销毁时不调 destroyWs——ws 是全局资源,整个登出才销毁
|
||||||
|
// 用户登出时调 csSessionStore.destroyWs()
|
||||||
|
|
||||||
const sending = ref(false)
|
|
||||||
// 发送文本消息
|
function handleRouteDeviceMain(deviceInfo: Device) {
|
||||||
const onSendMsg = async (text: string) => {
|
// 跳转设备详情页
|
||||||
sending.value = true
|
window.open(`/device/deviceMain?deviceId=${deviceInfo.deviceId}`)
|
||||||
// 组装消息,推入messages列表
|
|
||||||
const newMsg = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
type: 'chat',
|
|
||||||
role: 'user',
|
|
||||||
msgType: 'text',
|
|
||||||
content: text,
|
|
||||||
time: new Date().toLocaleTimeString(),
|
|
||||||
status: 'sending'
|
|
||||||
}
|
|
||||||
console.log(text)
|
|
||||||
form.value.message.push(newMsg)
|
|
||||||
try {
|
|
||||||
// 请求后端接口
|
|
||||||
// await api.sendChat(...)
|
|
||||||
newMsg.status = 'sent'
|
|
||||||
} catch (err) {
|
|
||||||
newMsg.status = 'failed'
|
|
||||||
} finally {
|
|
||||||
sending.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图片上传,拿到File,上传oss拿到url,生成图片消息
|
|
||||||
const onUploadImage = async (file: File) => {
|
|
||||||
// 调用oss上传接口,拿到真实图片url
|
|
||||||
// const url = await uploadOss(file)
|
|
||||||
const url = 'https://xxx.oss-cn-beijing.aliyuncs.com/demo.jpg'
|
|
||||||
const imgMsg = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
type: 'chat',
|
|
||||||
role: 'user',
|
|
||||||
msgType: 'image',
|
|
||||||
content: url,
|
|
||||||
time: new Date().toLocaleTimeString(),
|
|
||||||
status: 'sending'
|
|
||||||
}
|
|
||||||
form.value.message.push(imgMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 1. 获取用户列表
|
|
||||||
// 2. 点击用户,开始会话,ws
|
|
||||||
// 3. 查看用户信息,
|
|
||||||
// 4. 获取过往信息
|
|
||||||
// 5. 自动发送开场白
|
|
||||||
// 6. ws 发送消息
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="Main beautify_scroll">
|
<div class="Main beautify_scroll">
|
||||||
<pageCrumb>
|
<div class="userList">
|
||||||
<template #rightSlot>
|
<ul class="typeList">
|
||||||
<el-button plain type="info">返回会话列表</el-button>
|
<li v-for="(item,index) in userTypeList" :key="index" :class="{
|
||||||
</template>
|
active: item.value === csSessionStore.activeUserType
|
||||||
</pageCrumb>
|
}" class="topList" @click="handleActiveUserType(item.value)">
|
||||||
|
{{ item.label }}
|
||||||
|
<!-- <div v-if="index !== userTypeList.length-1 " class="awaitNum">12</div>-->
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul class="usersBox beautify_scroll">
|
||||||
|
<li
|
||||||
|
v-for="(item,index) in csSessionStore.getUserLists"
|
||||||
|
:key="index"
|
||||||
|
:class="{
|
||||||
|
active: item.sessionId === currentSessionId,
|
||||||
|
queue: item.status === '1'
|
||||||
|
}"
|
||||||
|
class="usersBox-item"
|
||||||
|
@click="handlePickUser(item)"
|
||||||
|
>
|
||||||
|
<el-image :src="item.avatarUrl" class="img"></el-image>
|
||||||
|
<div class="usersBox-info">
|
||||||
|
<div class="userName">
|
||||||
|
<div class="name">{{ item.userNickName }}</div>
|
||||||
|
<div v-if="item.status === '1'">
|
||||||
|
<!-- 排队等待-->
|
||||||
|
<div v-if="item.aheadCount > 0" class="queueNum">排队中|{{ item.aheadCount }}</div>
|
||||||
|
<!-- 分配客服-->
|
||||||
|
<div v-else class="awaitNum">分配客服中</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="time">{{ parseTime(item.lastMessageTime, '{h}:{i}') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="description">{{ item.lastMessage }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="item.sessionId !== currentSessionId && item.unreadCount > 0"
|
||||||
|
class="unReadCount">{{
|
||||||
|
item.unreadCount > 99 ? '99' : item.unreadCount
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-if="state.userInfoMain" class="contentMain">
|
||||||
<div class="commonCard">
|
<div class="commonCard">
|
||||||
<card-nav
|
<card-nav
|
||||||
:show-line="false"
|
:show-line="false"
|
||||||
subTitle="开始时间 2026-09-10 12:44:52 · 等待时长 00:38 · 接入方式:机器人接待 → 转人工"
|
:title="state.userInfoMain.session.sessionId"
|
||||||
title="CS20260918100038"
|
|
||||||
>
|
>
|
||||||
|
<template #subTitle>
|
||||||
|
<span>开始时间 {{ state.userInfoMain.session.startTime }} · 接入方式:机器人接待 <span
|
||||||
|
v-if="state.userInfoMain.session.status ==='2'||state.userInfoMain.session.status ==='3'"> → 转人工</span></span>
|
||||||
|
</template>
|
||||||
<template #rightTitle>
|
<template #rightTitle>
|
||||||
<el-tag type="primary">人工接待</el-tag>
|
<el-tag v-if="state.userInfoMain.session.status ==='2'" type="primary">人工接待</el-tag>
|
||||||
|
<el-tag v-else-if="state.userInfoMain.session.status ==='3'" type="info">已结束</el-tag>
|
||||||
</template>
|
</template>
|
||||||
<template #rightSlot>
|
<template #rightSlot>
|
||||||
|
<el-button v-if="state.userInfoMain.session.status == '2'" plain type="primary" @click="transferAgent">
|
||||||
|
转接客服
|
||||||
|
</el-button>
|
||||||
<el-button plain type="default">导出聊天记录</el-button>
|
<el-button plain type="default">导出聊天记录</el-button>
|
||||||
<el-button plain type="danger">关闭会话</el-button>
|
<el-button v-if="state.userInfoMain.session.status == '2'" plain type="danger" @click="closeSession">
|
||||||
|
关闭会话
|
||||||
|
</el-button>
|
||||||
|
<!-- <el-button type="primary" v-else @click="openSession">开启会话</el-button>-->
|
||||||
</template>
|
</template>
|
||||||
</card-nav>
|
</card-nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,35 +310,26 @@ const onUploadImage = async (file: File) => {
|
|||||||
<el-col :lg="16" :md="15" :span="24" :xl="18">
|
<el-col :lg="16" :md="15" :span="24" :xl="18">
|
||||||
<div class="commonCard userWindowBox">
|
<div class="commonCard userWindowBox">
|
||||||
<div class="userWindowNav userWindowNavItem">
|
<div class="userWindowNav userWindowNavItem">
|
||||||
<el-image :src="defaultImage" class="userAvatar"></el-image>
|
<el-image :src="state.userInfoMain.customer.avatarUrl" class="userAvatar"></el-image>
|
||||||
<div class="userWindowNavItem-l">
|
<div class="userWindowNavItem-l">
|
||||||
<div class="userWindowNavItem-l-info">
|
<div class="userWindowNavItem-l-info">
|
||||||
<div>
|
<div>
|
||||||
<span class="name">林筱曼</span>
|
<span class="name">{{ state.userInfoMain.customer.nickName }}</span>
|
||||||
<span class="phone">138****6688</span>
|
<span class="phone">{{ hiddenPhone(state.userInfoMain.customer.phonenumber) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="time">会话时长 06:12</span>
|
<!-- <span class="time">会话时长 06:12</span>-->
|
||||||
</div>
|
|
||||||
<div class="userWindowNavItem-l-status">
|
|
||||||
<i class="circle"></i>
|
|
||||||
<span>用户在线</span>
|
|
||||||
<span style="margin:0 0.5rem;">·</span>
|
|
||||||
<span>正在输入</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content beautify_scroll">
|
<div class="content beautify_scroll">
|
||||||
<ChatNavBar
|
<MessageList ref="messageList" :loading-history="loadingHistory" :messages="userInfoMessage"
|
||||||
:agent-info="form.agentInfo"
|
:user-avatar="state.userInfoMain.customer.avatarUrl"
|
||||||
:await-num="form.awaitNum"
|
@load-more="handleLoadMore"></MessageList>
|
||||||
:time="form.time"
|
|
||||||
:type="form.status"
|
|
||||||
></ChatNavBar>
|
|
||||||
<MessageList :messages="form.message" :user-avatar="userAvatar"></MessageList>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<!-- 输入框组件 -->
|
<!-- 输入框组件 -->
|
||||||
<ChatInput :sending="sending" @send="onSendMsg" @uploadImage="onUploadImage"/>
|
<ChatInput :disable="state.userInfoMain.session.status !== '2'" :sending="csSessionStore.sending"
|
||||||
|
@send="onSendMsg" @uploadImage="onUploadImage"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -176,41 +343,44 @@ const onUploadImage = async (file: File) => {
|
|||||||
<ul class="cardUserInfo">
|
<ul class="cardUserInfo">
|
||||||
<li class="cardUserInfo_item">
|
<li class="cardUserInfo_item">
|
||||||
<span class="label">用户ID</span>
|
<span class="label">用户ID</span>
|
||||||
<span class="value">U20260918001</span>
|
<span class="value">{{ state.userInfoMain.customer.userNo }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="cardUserInfo_item">
|
<li class="cardUserInfo_item">
|
||||||
<span class="label">昵称</span>
|
<span class="label">昵称</span>
|
||||||
<span class="value">林小满</span>
|
<span class="value">{{ state.userInfoMain.customer.nickName }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="cardUserInfo_item">
|
<li class="cardUserInfo_item">
|
||||||
<span class="label">手机号</span>
|
<span class="label">手机号</span>
|
||||||
<span class="value">138****6688</span>
|
<span class="value">{{ hiddenPhone(state.userInfoMain.customer.phonenumber) }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="cardUserInfo_item">
|
<li class="cardUserInfo_item">
|
||||||
<span class="label">注册时间</span>
|
<span class="label">注册时间</span>
|
||||||
<span class="value">2026-01-10 14:20</span>
|
<span class="value">{{ state.userInfoMain.customer.createTime }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="userDevicesBox">
|
<div class="userDevicesBox">
|
||||||
<card-nav :show-line="false">
|
<card-nav :show-line="false">
|
||||||
<template #title>
|
<template #title>
|
||||||
<span class="titleName">名下设备(3台)</span>
|
<span class="titleName">名下设备({{ state.userInfoMain.devices.length }}台)</span>
|
||||||
</template>
|
</template>
|
||||||
<template #rightSlot>
|
<template #rightSlot>
|
||||||
<span class="tip">点击可跳转设备详情</span>
|
<span class="tip">点击可跳转设备详情</span>
|
||||||
</template>
|
</template>
|
||||||
</card-nav>
|
</card-nav>
|
||||||
<ul class="userDevicesList beautify_scroll">
|
<ul class="userDevicesList beautify_scroll">
|
||||||
<li v-for="(item,index) in userDevicesList" :key="index" class="userDevicesItem">
|
<li v-for="(item,index) in state.userInfoMain.devices" :key="index" class="userDevicesItem"
|
||||||
|
@click="handleRouteDeviceMain(item)">
|
||||||
<div class="userDevicesItem_item">
|
<div class="userDevicesItem_item">
|
||||||
<div class="userDevicesName">
|
<div class="userDevicesName">
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.name }}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{{ item.locationName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<el-tag v-if="item.status === 0">在线</el-tag>
|
<el-tag v-if="item.status === '1'">在线</el-tag>
|
||||||
<el-tag v-else>离线</el-tag>
|
<el-tag v-else>离线</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="userDevices_describe">
|
<div class="userDevices_describe">
|
||||||
{{ item.model }} · {{ item.mac }}
|
<!-- {{ item.model }} · {{ item.mac }}-->
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -219,20 +389,20 @@ const onUploadImage = async (file: File) => {
|
|||||||
<ul class="cardUserInfo" style="margin-bottom: 0">
|
<ul class="cardUserInfo" style="margin-bottom: 0">
|
||||||
<li class="cardUserInfo_item">
|
<li class="cardUserInfo_item">
|
||||||
<span class="label">会话 ID</span>
|
<span class="label">会话 ID</span>
|
||||||
<span class="value">U20260918001</span>
|
<span class="value">{{ state.userInfoMain.session.sessionId }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="cardUserInfo_item">
|
<li class="cardUserInfo_item">
|
||||||
<span class="label">会话开始时间</span>
|
<span class="label">会话开始时间</span>
|
||||||
<span class="value">2026-09-10 12:44:52</span>
|
<span class="value">{{ state.userInfoMain.session.startTime }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="cardUserInfo_item">
|
<li class="cardUserInfo_item">
|
||||||
<span class="label">接入方式</span>
|
<span class="label">接入方式</span>
|
||||||
<span class="value">机器人接待 → 转人工</span>
|
<span class="value">机器人接待 → 转人工</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="cardUserInfo_item">
|
<!-- <li class="cardUserInfo_item">-->
|
||||||
<span class="label">当前等待时长</span>
|
<!-- <span class="label">当前等待时长</span>-->
|
||||||
<span class="value" style="color: #EA580C">00:38</span>
|
<!-- <span class="value" style="color: #EA580C">00:38</span>-->
|
||||||
</li>
|
<!-- </li>-->
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,17 +410,195 @@ const onUploadImage = async (file: File) => {
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="noContentMain">
|
||||||
|
<span>暂无会话</span>
|
||||||
|
</div>
|
||||||
|
<el-dialog v-model="dialog.visible" :title="dialog.title" append-to-body width="500px">
|
||||||
|
<el-form ref="demoFormRef" :model="form" :rules="rules" label-width="80px">
|
||||||
|
<el-form-item label="转接客服" prop="agentId">
|
||||||
|
<el-select v-model="form.agentId" placeholder="请选择转接客服">
|
||||||
|
<el-option
|
||||||
|
v-for="(item,index) in acceptAgentList"
|
||||||
|
:key="index"
|
||||||
|
:disabled="item.onlineStatus === '0'"
|
||||||
|
:label="item.nickName"
|
||||||
|
:value="item.agentId"
|
||||||
|
>
|
||||||
|
<div class="flex flex-items-center justify-between">
|
||||||
|
<div style="float: left">{{ item.nickName }}</div>
|
||||||
|
<el-tag
|
||||||
|
:type="item.onlineStatus === '0' ? 'danger' : 'primary'"
|
||||||
|
style="float: right;font-size: 13px;"
|
||||||
|
> {{ item.onlineStatusName }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button :loading="buttonLoading" type="primary" @click="submitForm">确 定</el-button>
|
||||||
|
<el-button @click="cancel">取 消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.Main {
|
.Main {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
overflow-x: hidden;
|
|
||||||
padding-bottom: 40px;
|
padding-bottom: 40px;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.userList {
|
||||||
|
margin-right: 20px;
|
||||||
|
display: flex;
|
||||||
|
height: 100%; // ✅ 关键:撑满父高度
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden; // ✅ 外层也压住
|
||||||
|
|
||||||
|
.typeList {
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
li {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 48px;
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.awaitNum {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: red;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(230, 247, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #e6f7ff;
|
||||||
|
color: #1684fc;
|
||||||
|
border-bottom: 3px solid #1684fc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.usersBox {
|
||||||
|
flex: 1;
|
||||||
|
margin-top: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 1000px;
|
||||||
|
|
||||||
|
width: 250px;
|
||||||
|
|
||||||
|
.usersBox-item {
|
||||||
|
display: flex;
|
||||||
|
padding: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-bottom: 1px solid #eeeef0;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
// 未读消息数量
|
||||||
|
.unReadCount {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: red;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #e2e2e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.queue {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.usersBox-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
width: 130px;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: rgba(17, 24, 39, 1);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
margin-left: 8px;
|
||||||
|
width: 30px;
|
||||||
|
color: rgba(156, 163, 175, 1);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queueNum, .awaitNum {
|
||||||
|
min-width: 45px;
|
||||||
|
color: #EA580C;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: rgba(156, 163, 175, 1);
|
||||||
|
font-size: 12px;
|
||||||
|
text-wrap: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentMain {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
.cardUserInfoBox {
|
.cardUserInfoBox {
|
||||||
.cardUserInfo {
|
.cardUserInfo {
|
||||||
@@ -308,6 +656,11 @@ const onUploadImage = async (file: File) => {
|
|||||||
border: 1px solid rgba(238, 240, 243, 1);
|
border: 1px solid rgba(238, 240, 243, 1);
|
||||||
padding: 15px 13px;
|
padding: 15px 13px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(182, 182, 182, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.userDevicesItem_item {
|
.userDevicesItem_item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -344,8 +697,7 @@ const onUploadImage = async (file: File) => {
|
|||||||
.userWindowNav.userWindowNavItem {
|
.userWindowNav.userWindowNavItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 21px;
|
padding: 0 21px 19px;
|
||||||
padding-bottom: 19px;
|
|
||||||
|
|
||||||
.userAvatar {
|
.userAvatar {
|
||||||
width: 41px;
|
width: 41px;
|
||||||
@@ -369,6 +721,7 @@ const onUploadImage = async (file: File) => {
|
|||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
color: rgba(17, 24, 39, 1);
|
color: rgba(17, 24, 39, 1);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phone, .time {
|
.phone, .time {
|
||||||
@@ -408,6 +761,19 @@ const onUploadImage = async (file: File) => {
|
|||||||
padding: 0 21px;
|
padding: 0 21px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.noContentMain {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: rgba(156, 163, 175, 0.16);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
import cardNav from '@/components/cardNav/index.vue';
|
import cardNav from '@/components/cardNav/index.vue';
|
||||||
import {getDevice} from "@/api/system/device";
|
import {getDevice} from "@/api/system/device";
|
||||||
import {DeviceForm} from "@/api/system/device/types";
|
import {DeviceForm} from "@/api/system/device/types";
|
||||||
|
import {useUserStore} from "@/store/modules/user";
|
||||||
|
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
function backList() {
|
function backList() {
|
||||||
@@ -65,7 +68,6 @@ const form = ref();
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
form.value = {...initFormData};
|
form.value = {...initFormData};
|
||||||
console.log(form.value);
|
|
||||||
if (route.query.id) {
|
if (route.query.id) {
|
||||||
getDevice(route.query.id as string).then(res => {
|
getDevice(route.query.id as string).then(res => {
|
||||||
Object.assign(form.value, res.data);
|
Object.assign(form.value, res.data);
|
||||||
@@ -82,7 +84,9 @@ init()
|
|||||||
<nav class="flex justify-between" style="padding-top: 22px;">
|
<nav class="flex justify-between" style="padding-top: 22px;">
|
||||||
<Pagecrumb></Pagecrumb>
|
<Pagecrumb></Pagecrumb>
|
||||||
<div>
|
<div>
|
||||||
<el-button plain type="info" @click="backList">返回设备列表</el-button>
|
<el-button v-if="!userStore.roles.includes('customer_service')" plain type="info" @click="backList">
|
||||||
|
返回设备列表
|
||||||
|
</el-button>
|
||||||
<el-button type="primary" @click="editDevice">编辑设备</el-button>
|
<el-button type="primary" @click="editDevice">编辑设备</el-button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ const handleOrderChange = (prop: string, order: string) => {
|
|||||||
};
|
};
|
||||||
/** 任务日志列表查询 */
|
/** 任务日志列表查询 */
|
||||||
const handleOssConfig = () => {
|
const handleOssConfig = () => {
|
||||||
router.push('/system/oss-config/index.vue');
|
router.push('/system/oss-config/index');
|
||||||
};
|
};
|
||||||
/** 文件按钮操作 */
|
/** 文件按钮操作 */
|
||||||
const handleFile = () => {
|
const handleFile = () => {
|
||||||
|
|||||||
@@ -1,31 +1,126 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {ArrowLeft, InfoFilled} from "@element-plus/icons-vue";
|
import {ArrowLeft, InfoFilled} from "@element-plus/icons-vue";
|
||||||
import FormItemTip from '@/components/FormItemTip/index.vue'
|
import FormItemTip from '@/components/FormItemTip/index.vue'
|
||||||
|
import {listRole, addRole} from "@/api/system/role";
|
||||||
|
import {useRouter} from "vue-router";
|
||||||
|
|
||||||
const roleslist = [
|
const router = useRouter()
|
||||||
|
|
||||||
|
const rolesList = [
|
||||||
{
|
{
|
||||||
label: '超级管理员',
|
label: '超级管理员',
|
||||||
sutTitle: "拥有系统所有功能权限"
|
sutTitle: "拥有系统所有功能权限",
|
||||||
|
color: '#2563eb'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '设备管理员',
|
label: '设备管理员',
|
||||||
sutTitle: "管理设备列表、OTA升级等"
|
sutTitle: "管理设备列表、OTA升级等",
|
||||||
|
color: '#3b82f6'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '普通用户',
|
label: '普通用户',
|
||||||
sutTitle: "仅可查看设备信息"
|
sutTitle: "仅可查看设备信息",
|
||||||
|
color: '#22c55e'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '审计员',
|
label: '审计员',
|
||||||
sutTitle: "查看操作日志与审计"
|
sutTitle: "查看操作日志与审计",
|
||||||
|
color: '#a855f7'
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
const initForm = {
|
||||||
|
userId: undefined,
|
||||||
|
userName: '',
|
||||||
|
nickName: undefined,
|
||||||
|
gender: '1',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
phoneNumber: undefined,
|
||||||
|
email: undefined,
|
||||||
|
status: '0',
|
||||||
|
remark: '',
|
||||||
|
roleIds: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const formRef = ref<ElFormInstance>()
|
||||||
|
const form = ref(initForm);
|
||||||
|
const rules = {
|
||||||
|
userName: [
|
||||||
|
{required: true, message: '用户名称不能为空', trigger: 'blur'},
|
||||||
|
{min: 4, max: 20, message: '用户名称长度必须介于 4 和 20 之间', trigger: 'blur'}
|
||||||
|
],
|
||||||
|
nickName: [{required: true, message: '用户昵称不能为空', trigger: 'blur'}],
|
||||||
|
password: [
|
||||||
|
{required: true, message: '用户密码不能为空', trigger: 'blur'},
|
||||||
|
{min: 5, max: 20, message: '用户密码长度必须介于 5 和 20 之间', trigger: 'blur'},
|
||||||
|
{pattern: /^[^<>"'|\\]+$/, message: '不能包含非法字符:< > " \' \\ |', trigger: 'blur'}
|
||||||
|
],
|
||||||
|
phoneNumber: [
|
||||||
|
{required: true, message: '用户手机号不能为空', trigger: 'blur'},
|
||||||
|
{pattern: /^1[3456789][0-9]\d{8}$/, message: '请输入正确的手机号码', trigger: 'blur'}
|
||||||
|
],
|
||||||
|
email: [
|
||||||
|
{type: 'email', message: '请输入正确的邮箱地址', trigger: ['blur', 'change']},
|
||||||
|
{pattern: /^[^@]+@[^@]+\.[^@]+$/, message: '请输入正确的邮箱地址', trigger: 'blur'}
|
||||||
|
],
|
||||||
|
confirmPassword: [
|
||||||
|
{required: true, message: '确认密码不能为空', trigger: 'blur'},
|
||||||
|
{
|
||||||
|
validator: (rule, value, callback) => {
|
||||||
|
if (value !== form.value.password) {
|
||||||
|
callback(new Error('两次输入密码不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
roleIds: [{required: true, message: '用户角色不能为空', trigger: 'blur'}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleList = ref([])
|
||||||
|
|
||||||
|
function getRoleList() {
|
||||||
|
listRole({pageNum: 1, pageSize: undefined,}).then((res) => {
|
||||||
|
roleList.value = res.data.rows || []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
form.value = {...initForm};
|
||||||
|
formRef.value?.resetFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRoleAction() {
|
||||||
|
console.log(form.value);
|
||||||
|
formRef.value?.validate().then((valid) => {
|
||||||
|
if (valid) {
|
||||||
|
addRole(form.value).then((res) => {
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('新增成功')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '新增失败')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function reBack() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getRoleList()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="Add">
|
<div class="Add beautify_scroll">
|
||||||
<div class="AddNav">
|
<div class="AddNav">
|
||||||
<div class="addItem">
|
<div class="addItem">
|
||||||
<div class="arrowBox">
|
<div class="arrowBox" @click="reBack">
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<arrow-left></arrow-left>
|
<arrow-left></arrow-left>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
@@ -36,14 +131,15 @@ const roleslist = [
|
|||||||
</div>
|
</div>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="17">
|
<el-col :span="17">
|
||||||
<el-form label-position="top">
|
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||||
<div class="commonCard">
|
<div class="commonCard">
|
||||||
<card-nav :show-line="false" title="基本信息"></card-nav>
|
<card-nav :show-line="false" style="margin-bottom: 10px" title="基本信息"></card-nav>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<FormItemTip
|
<FormItemTip
|
||||||
|
v-model="form.userName"
|
||||||
:inputAttrs="{placeholder:'请输入登录用户名'}"
|
:inputAttrs="{placeholder:'请输入登录用户名'}"
|
||||||
:required="true"
|
prop="userName"
|
||||||
label="登录用户名"
|
label="登录用户名"
|
||||||
tip="4-20位字母、数字或下划线,创建后不可修改"
|
tip="4-20位字母、数字或下划线,创建后不可修改"
|
||||||
type="input"
|
type="input"
|
||||||
@@ -51,23 +147,27 @@ const roleslist = [
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<FormItemTip
|
<FormItemTip
|
||||||
|
v-model="form.nickName"
|
||||||
:inputAttrs="{placeholder:'请输入真实姓名'}"
|
:inputAttrs="{placeholder:'请输入真实姓名'}"
|
||||||
:required="true"
|
prop="nickName"
|
||||||
label="用户姓名"
|
label="用户姓名"
|
||||||
type="input"
|
type="input"
|
||||||
></FormItemTip>
|
></FormItemTip>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<FormItemTip
|
<FormItemTip
|
||||||
|
v-model="form.phoneNumber"
|
||||||
:inputAttrs="{placeholder:'请输入手机号码'}"
|
:inputAttrs="{placeholder:'请输入手机号码'}"
|
||||||
:required="true"
|
prop="phoneNumber"
|
||||||
label="手机号码"
|
label="手机号码"
|
||||||
type="input"
|
type="input"
|
||||||
></FormItemTip>
|
></FormItemTip>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<FormItemTip
|
<FormItemTip
|
||||||
|
v-model="form.email"
|
||||||
:inputAttrs="{placeholder:'请输入邮箱'}"
|
:inputAttrs="{placeholder:'请输入邮箱'}"
|
||||||
|
prop="email"
|
||||||
:required="false"
|
:required="false"
|
||||||
label="邮箱"
|
label="邮箱"
|
||||||
type="input"
|
type="input"
|
||||||
@@ -75,11 +175,12 @@ const roleslist = [
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<FormItemTip
|
<FormItemTip
|
||||||
|
v-model="form.gender"
|
||||||
:radio-options="[
|
:radio-options="[
|
||||||
{label:'男',value:1},
|
{label:'男',value:'1'},
|
||||||
{label:'女',value:0},
|
{label:'女',value:'0'},
|
||||||
]"
|
]"
|
||||||
:required="false"
|
prop="gender"
|
||||||
label="性别"
|
label="性别"
|
||||||
type="radio"
|
type="radio"
|
||||||
></FormItemTip>
|
></FormItemTip>
|
||||||
@@ -90,8 +191,14 @@ const roleslist = [
|
|||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<FormItemTip
|
<FormItemTip
|
||||||
:required="true"
|
v-model="form.roleIds"
|
||||||
|
:disabled-values="['superadmin']"
|
||||||
|
:radioOptions="roleList"
|
||||||
|
labelKey="roleName"
|
||||||
|
prop="roleIds"
|
||||||
|
tip="角色决定用户的功能权限范围"
|
||||||
label="角色"
|
label="角色"
|
||||||
|
valueKey="roleKey"
|
||||||
type="select"
|
type="select"
|
||||||
></FormItemTip>
|
></FormItemTip>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -99,29 +206,31 @@ const roleslist = [
|
|||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<FormItemTip
|
<FormItemTip
|
||||||
:input-attrs="{maxlength:20,minlength:8}"
|
v-model="form.password"
|
||||||
:required="true"
|
:input-attrs="{maxlength:20,minlength:8,placeholder:'请再次输入密码'}"
|
||||||
|
prop="password"
|
||||||
label="初始密码"
|
label="初始密码"
|
||||||
tip="8-20位,包含字母和数字"
|
tip="8-20位,包含字母和数字"
|
||||||
type="input"
|
type="password"
|
||||||
></FormItemTip>
|
></FormItemTip>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<FormItemTip
|
<FormItemTip
|
||||||
|
v-model="form.confirmPassword"
|
||||||
:input-attrs="{placeholder:'请再次输入密码',maxlength:20,minlength:8}"
|
:input-attrs="{placeholder:'请再次输入密码',maxlength:20,minlength:8}"
|
||||||
:required="true"
|
prop="confirmPassword"
|
||||||
label="重复初始密码"
|
label="重复初始密码"
|
||||||
type="input"
|
type="password"
|
||||||
></FormItemTip>
|
></FormItemTip>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<FormItemTip
|
<FormItemTip
|
||||||
|
v-model="form.status"
|
||||||
:radio-options="[
|
:radio-options="[
|
||||||
{label:'启用',value:1},
|
{label:'启用',value:'0'},
|
||||||
{label:'禁用',value:0},
|
{label:'禁用',value:'1'},
|
||||||
]"
|
]"
|
||||||
:required="false"
|
label="状态"
|
||||||
label="性别"
|
|
||||||
type="radio"
|
type="radio"
|
||||||
></FormItemTip>
|
></FormItemTip>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -133,9 +242,9 @@ const roleslist = [
|
|||||||
<div class="commonCard roleInfoBox">
|
<div class="commonCard roleInfoBox">
|
||||||
<card-nav :show-line="false" title="角色说明"></card-nav>
|
<card-nav :show-line="false" title="角色说明"></card-nav>
|
||||||
<ul class="roleInfoListBox">
|
<ul class="roleInfoListBox">
|
||||||
<li v-for="(item,index) in roleslist" :key="index" class="roleInfoListItem">
|
<li v-for="(item,index) in rolesList" :key="index" class="roleInfoListItem">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<i class="circle"></i>
|
<i :style="{backgroundColor: item.color}" class="circle"></i>
|
||||||
<span class="title">{{ item.label }}</span>
|
<span class="title">{{ item.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="subTitle">{{ item.sutTitle }}</div>
|
<div class="subTitle">{{ item.sutTitle }}</div>
|
||||||
@@ -143,20 +252,24 @@ const roleslist = [
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="commonCard tipBox">
|
<div class="commonCard tipBox">
|
||||||
<div>
|
<div class="tipTitle">
|
||||||
<el-icon>
|
<el-icon :size="18">
|
||||||
<InfoFilled/>
|
<InfoFilled/>
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<span>操作提示</span>
|
<span>操作提示</span>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>1. 带 * 号为必填项</li>
|
<li>1. 带 <strong>*</strong> 号为必填项</li>
|
||||||
<li>2. 用户名创建后不可修改</li>
|
<li>2. 用户名创建后不可修改</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="actionBox">
|
||||||
<el-button>确认创建</el-button>
|
<div style="width:100%;">
|
||||||
<el-button>取消</el-button>
|
<el-button size="large" style="width:100%;" type="primary" @click="addRoleAction">确认创建</el-button>
|
||||||
|
</div>
|
||||||
|
<div style="width:100%;margin-top:10px">
|
||||||
|
<el-button size="large" style="width:100%;" @click="reset">取消</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -184,7 +297,7 @@ const roleslist = [
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,11 +361,39 @@ const roleslist = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tipBox {
|
.tipBox {
|
||||||
margin-top: 20px;
|
padding: 15px 17px;
|
||||||
border-radius: 8px 8px 8px 8px;
|
border-radius: 8px 8px 8px 8px;
|
||||||
background-color: rgba(239, 246, 255, 1);
|
background-color: rgba(239, 246, 255, 1);
|
||||||
border: 1px solid rgba(191, 219, 254, 1);
|
border: 1px solid rgba(191, 219, 254, 1);
|
||||||
padding: 17px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.tipTitle {
|
||||||
|
line-height: 16px;
|
||||||
|
color: rgba(30, 64, 175, 1);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
height: 18px;
|
||||||
|
line-height: 14px;
|
||||||
|
color: rgba(30, 64, 175, 1);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.actionBox {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div style="margin-top:24px">
|
<div style="margin-top:24px">
|
||||||
<pagecrumb>
|
<pagecrumb>
|
||||||
<template #rightSlot>
|
<template #rightSlot>
|
||||||
<el-button size="large" type="primary">新增账号</el-button>
|
<el-button size="large" type="primary" @click="handleAdd">新增账号</el-button>
|
||||||
</template>
|
</template>
|
||||||
</pagecrumb>
|
</pagecrumb>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,9 +72,6 @@
|
|||||||
<h3>用户列表</h3>
|
<h3>用户列表</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<el-button v-has-permi="['system:user:add']" type="primary" plain icon="Plus" @click="handleAdd()">
|
|
||||||
新增
|
|
||||||
</el-button>
|
|
||||||
<right-toolbar
|
<right-toolbar
|
||||||
v-model:show-search="showSearch"
|
v-model:show-search="showSearch"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
@@ -94,24 +91,20 @@
|
|||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
>
|
>
|
||||||
<el-table-column align="center" type="selection" width="50"/>
|
<el-table-column align="center" type="selection" width="50"/>
|
||||||
<el-table-column align="center" label="用户名" prop="userId"/>
|
<el-table-column align="center" label="用户名" prop="userName"/>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
key="userName"
|
|
||||||
label="姓名"
|
label="姓名"
|
||||||
align="center"
|
align="center"
|
||||||
:show-overflow-tooltip="true"
|
:show-overflow-tooltip="true"
|
||||||
>
|
>
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-link type="primary" underline="never" @click="handleViewDetail(scope.row)">
|
{{ scope.row.nickName }}
|
||||||
{{ scope.row.userName }}
|
|
||||||
</el-link>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
key="nickName"
|
|
||||||
label="角色"
|
label="角色"
|
||||||
align="center"
|
align="center"
|
||||||
prop="nickName"
|
prop="roleIds"
|
||||||
:show-overflow-tooltip="true"
|
:show-overflow-tooltip="true"
|
||||||
></el-table-column>
|
></el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
@@ -128,14 +121,14 @@
|
|||||||
<el-table-column align="center" label="账号状态" width="100">
|
<el-table-column align="center" label="账号状态" width="100">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<div class="open tag"><i class="circle"></i><span>启用</span></div>
|
<div v-if="scope.row.status === '0'" class="open tag"><i class="circle"></i><span>启用</span></div>
|
||||||
<!-- <div class="close tag"><i class="circle"></i><span>已禁用</span></div>-->
|
<div v-else class="close tag"><i class="circle"></i><span>已禁用</span></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column align="center" label="最后登录" prop="createTime" width="160">
|
<el-table-column align="center" label="最后登录" prop="loginDate" width="160">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span style="color:#64748B;">{{ scope.row.createTime }}</span>
|
<span style="color:#64748B;">{{ scope.row.loginDate }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column align="center" label="创建时间" prop="createTime" width="160">
|
<el-table-column align="center" label="创建时间" prop="createTime" width="160">
|
||||||
@@ -145,8 +138,9 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column align="center" class-name="small-padding fixed-width" fixed="right" label="操作"
|
<el-table-column align="center" class-name="small-padding fixed-width" fixed="right" label="操作"
|
||||||
width="300">
|
width="220">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<div v-if="scope.row.userId !== '1761100000000000001'">
|
||||||
<el-button
|
<el-button
|
||||||
v-hasPermi="['system:user:edit']"
|
v-hasPermi="['system:user:edit']"
|
||||||
link
|
link
|
||||||
@@ -162,6 +156,7 @@
|
|||||||
>重置密码
|
>重置密码
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="scope.row.status === '0'"
|
||||||
v-hasPermi="['system:user:edit']"
|
v-hasPermi="['system:user:edit']"
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -169,6 +164,7 @@
|
|||||||
>禁用
|
>禁用
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
|
v-else
|
||||||
v-hasPermi="['system:user:edit']"
|
v-hasPermi="['system:user:edit']"
|
||||||
link
|
link
|
||||||
type="success"
|
type="success"
|
||||||
@@ -182,6 +178,7 @@
|
|||||||
@click="handleDelete(scope.row)"
|
@click="handleDelete(scope.row)"
|
||||||
>删除
|
>删除
|
||||||
</el-button>
|
</el-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -704,13 +701,16 @@ const cancel = () => {
|
|||||||
|
|
||||||
/** 新增按钮操作 */
|
/** 新增按钮操作 */
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
reset();
|
router.push({
|
||||||
const {data} = await api.getUser();
|
path: '/system/addUser'
|
||||||
setDialogTitle('新增用户');
|
})
|
||||||
openUserDialog();
|
// reset();
|
||||||
postOptions.value = data.posts;
|
// const {data} = await api.getUser();
|
||||||
roleOptions.value = data.roles;
|
// setDialogTitle('新增用户');
|
||||||
form.value.password = initPassword.value.toString();
|
// openUserDialog();
|
||||||
|
// postOptions.value = data.posts;
|
||||||
|
// roleOptions.value = data.roles;
|
||||||
|
// form.value.password = initPassword.value.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 修改按钮操作 */
|
/** 修改按钮操作 */
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default defineConfig(({mode, command}) => {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
rewrite: path => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
|
rewrite: path => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
css: {
|
css: {
|
||||||
|
|||||||
Reference in New Issue
Block a user