10/15
1. 工作人员账号添加
This commit is contained in:
@@ -12,6 +12,10 @@ VITE_APP_BASE_API='/dev-api'
|
||||
VITE_LOCALHOUSET=true
|
||||
# 本地开发环境
|
||||
VITE_BASE_API='http://192.168.0.212:8082'
|
||||
#VITE_BASE_API='http://192.168.0.212:8082'
|
||||
VITE_WS_URL='ws://192.168.0.212:8082/cs/chat'
|
||||
|
||||
|
||||
# 默认头像
|
||||
VITE_DEFAULT_USER_IMAGE='https://ts3.tc.mm.bing.net/th?id=ORMS.48a27ca8353fa47bf0dc8df38854d0e1&pid=Wdp&w=312&h=172&qlt=90&c=1&rs=1&dpr=1&p=0'
|
||||
|
||||
@@ -38,6 +42,8 @@ VITE_APP_RSA_PRIVATE_KEY='MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAObq7y
|
||||
|
||||
# 客户端id
|
||||
VITE_APP_CLIENT_ID='e5cd7e4891bf95d1d19206ce24a7b32e'
|
||||
# 客服 客户端id
|
||||
VITE_CS_AGENT_CLIENT_ID='3a7c1e5b9d2f4688b0c4e17a9f63d582'
|
||||
|
||||
# 统一消息推送开关
|
||||
VITE_APP_MESSAGE_ENABLED=true
|
||||
|
||||
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 {
|
||||
roleName: string;
|
||||
roleKey: string;
|
||||
status: string;
|
||||
roleName?: string;
|
||||
roleKey?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
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
|
||||
v-for="item in radioOptions"
|
||||
:key="item.value"
|
||||
:key="item[valueKey]"
|
||||
:class="{
|
||||
active: modelValue === item.value
|
||||
active: modelValue === item[valueKey]
|
||||
}"
|
||||
:label="item.value"
|
||||
:disabled="isOptionDisabled(item)"
|
||||
:label="item[valueKey]"
|
||||
class="radio"
|
||||
>{{ item.label }}
|
||||
>{{ item[labelKey] }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
|
||||
@@ -104,18 +105,38 @@
|
||||
</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
|
||||
v-else-if="type === 'checkBox'"
|
||||
:model-value="modelValue"
|
||||
:model-value="modelValue as (string | number)[]"
|
||||
style="width: 100%"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<el-checkbox
|
||||
v-for="item in radioOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
:key="item[valueKey]"
|
||||
:disabled="isOptionDisabled(item)"
|
||||
:label="item[labelKey]"
|
||||
:value="item[valueKey]"
|
||||
class="checkBox"
|
||||
>
|
||||
</el-checkbox>
|
||||
@@ -125,11 +146,14 @@
|
||||
v-else-if="type === 'password'"
|
||||
:model-value="modelValue"
|
||||
class="inputItem"
|
||||
show-password
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
v-bind="inputAttrs"
|
||||
@input="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #suffix>
|
||||
<span class="password-toggle" @click="showPassword = !showPassword">
|
||||
{{ showPassword ? '隐藏' : '显示' }}
|
||||
</span>
|
||||
<slot name="suffix"></slot>
|
||||
</template>
|
||||
</el-input>
|
||||
@@ -192,14 +216,41 @@ const props = defineProps({
|
||||
default: () => ({})
|
||||
},
|
||||
radioOptions: {
|
||||
type: Array as PropType<Array<{ label: string, value: string | number }>>,
|
||||
type: Array as PropType<Array<Record<string, any>>>,
|
||||
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 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 defaultImag = import.meta.env.VITE_DEFAULT_USER_IMAGE;
|
||||
const imageUrl = ref('')
|
||||
@@ -377,6 +428,24 @@ const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
background-color: rgba(247, 249, 252, 1);
|
||||
border: 1px solid rgba(229, 231, 235, 1);
|
||||
|
||||
|
||||
&.selectBox {
|
||||
.el-select__wrapper {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: rgba(29, 78, 216, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
|
||||
@@ -39,6 +39,9 @@ const props = defineProps({
|
||||
<slot name="rightTitle"></slot>
|
||||
</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>
|
||||
|
||||
@@ -67,6 +67,7 @@ function addIframe() {
|
||||
.app-main:not(.with-fixed-header) {
|
||||
min-height: calc(100vh - 64px);
|
||||
max-height: calc(100vh - 64px);
|
||||
height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.app-main.with-tags-view:not(.with-fixed-header) {
|
||||
|
||||
@@ -55,14 +55,14 @@ router.beforeEach(async (to, from) => {
|
||||
if (res.data.roles.includes('customer_service')) {
|
||||
if (to.path === '/index') {
|
||||
return {
|
||||
path: '/conversation/conversationList',
|
||||
path: '/cs/session',
|
||||
replace: true
|
||||
}
|
||||
}
|
||||
// 客服登录默认首页:如果原本跳转目标是根路径 /,也跳会话列表
|
||||
if (to.path === '/') {
|
||||
return {
|
||||
path: '/conversation/conversationList',
|
||||
path: '/cs/session',
|
||||
replace: true
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ router.beforeEach(async (to, from) => {
|
||||
} else {
|
||||
const redirect = encodeURIComponent(to.fullPath || '/');
|
||||
NProgress.done();
|
||||
return `/login?redirect=${redirect}`; // 否则全部重定向到登录页
|
||||
return `/login`; // 否则全部重定向到登录页
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
roles.value = ['ROLE_DEFAULT'];
|
||||
}
|
||||
localStorage.setItem('userInfo', data.user.userId.toString());
|
||||
name.value = user.userName;
|
||||
nickname.value = user.nickName;
|
||||
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_BASE_API: string;
|
||||
VITE_APP_BASE_URL: string;
|
||||
VITE_WS_URL: string;
|
||||
VITE_APP_CONTEXT_PATH: string;
|
||||
VITE_CS_AGENT_CLIENT_ID: string;
|
||||
VITE_APP_MONITOR_ADMIN: string;
|
||||
VITE_APP_SNAILJOB_ADMIN: string;
|
||||
VITE_APP_SNAILAI_ADMIN: string;
|
||||
|
||||
@@ -7,3 +7,5 @@ export const getToken = () => tokenStorage.value;
|
||||
export const setToken = (access_token: string) => (tokenStorage.value = access_token);
|
||||
|
||||
export const removeToken = () => (tokenStorage.value = null);
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @example 136****9023
|
||||
* */
|
||||
export function hiddenPhone(phone: string): string {
|
||||
if (!phone) return '---';
|
||||
if (phone.length < 11) {
|
||||
return phone
|
||||
}
|
||||
|
||||
@@ -96,6 +96,17 @@ export const globalHeaders = () => {
|
||||
|
||||
axios.defaults.headers['Content-Type'] = 'application/json;charset=utf-8';
|
||||
axios.defaults.headers['clientid'] = import.meta.env.VITE_APP_CLIENT_ID;
|
||||
|
||||
|
||||
function resolveClientId() {
|
||||
const roles = useUserStore().roles;
|
||||
if (roles.includes('customer_service')) {
|
||||
return import.meta.env.VITE_CS_AGENT_CLIENT_ID;
|
||||
}
|
||||
return import.meta.env.VITE_APP_CLIENT_ID;
|
||||
}
|
||||
|
||||
|
||||
// 创建 axios 实例
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||
@@ -111,6 +122,7 @@ service.interceptors.request.use(
|
||||
(config: any) => {
|
||||
// 对应国际化资源文件后缀
|
||||
config.headers['Content-Language'] = getLanguage();
|
||||
// config.headers['clientid'] = resolveClientId();
|
||||
|
||||
const isToken = config.headers?.isToken === false;
|
||||
// 是否需要防止数据重复提交
|
||||
|
||||
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>
|
||||
<span>{{ numToWeek(item.formateDay + 1)}}</span>
|
||||
<span>{{ numToWeek(item.formateDay + 1) }}</span>
|
||||
<span style="font-size: 12px">({{ item.month }}.{{ item.date }})</span>
|
||||
</div>
|
||||
</article>
|
||||
@@ -119,19 +119,22 @@
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<template #prefix
|
||||
><svg-icon
|
||||
>
|
||||
<svg-icon
|
||||
icon-class="validCode"
|
||||
class="el-input__icon input-icon"
|
||||
/></template>
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="login-code">
|
||||
<img :src="codeUrl" class="login-code-img" @click="getCode" />
|
||||
<img :src="codeUrl" class="login-code-img" @click="getCode"/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div class="form-meta">
|
||||
<el-checkbox v-model="loginForm.rememberMe">{{
|
||||
$t("login.rememberPassword")
|
||||
}}</el-checkbox>
|
||||
$t("login.rememberPassword")
|
||||
}}
|
||||
</el-checkbox>
|
||||
<router-link v-if="register" class="link-type" :to="'/register'">
|
||||
{{ $t("login.switchRegisterPage") }}
|
||||
</router-link>
|
||||
@@ -168,7 +171,7 @@ const title = import.meta.env.VITE_APP_TITLE;
|
||||
const quickStats = ref([]);
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const {t} = useI18n();
|
||||
|
||||
const loginForm = ref<LoginData>({
|
||||
username: "admin",
|
||||
@@ -217,7 +220,7 @@ watch(
|
||||
newRoute.query.redirect &&
|
||||
decodeURIComponent(newRoute.query.redirect);
|
||||
},
|
||||
{ immediate: true },
|
||||
{immediate: true},
|
||||
);
|
||||
|
||||
const isShowPwd = ref(false);
|
||||
@@ -256,7 +259,7 @@ const handleLogin = () => {
|
||||
|
||||
const getCode = async () => {
|
||||
const res = await getCodeImg();
|
||||
const { data } = res;
|
||||
const {data} = res;
|
||||
captchaEnabled.value =
|
||||
data.captchaEnabled === undefined ? true : data.captchaEnabled;
|
||||
if (captchaEnabled.value) {
|
||||
@@ -286,8 +289,17 @@ const handleGetLoc = async () => {
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
const arr = res.data.forecast.daily.pm10 ?? [];
|
||||
quickStats.value = handleMax(arr.slice(0, 7));
|
||||
// const weightArr = res.data.forecast.daily.pm10 ?? [];
|
||||
const weightArr = [
|
||||
{avg: 72, day: "2026-10-03"},
|
||||
{avg: 85, day: "2026-10-04"},
|
||||
{avg: 66, day: "2026-10-05"},
|
||||
{avg: 91, day: "2026-10-06"},
|
||||
{avg: 78, day: "2026-10-07"},
|
||||
{avg: 95, day: "2026-10-08"},
|
||||
{avg: 88, day: "2026-10-09"},
|
||||
];
|
||||
quickStats.value = handleMax(weightArr.slice(0, 7));
|
||||
});
|
||||
} catch (e) {
|
||||
console.error((e as Error).message);
|
||||
@@ -343,10 +355,12 @@ onMounted(() => {
|
||||
padding: 60px 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.logo-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 160px;
|
||||
|
||||
.logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
@@ -357,6 +371,7 @@ onMounted(() => {
|
||||
text-align: center;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
.logo_info {
|
||||
.title {
|
||||
height: 28px;
|
||||
@@ -366,6 +381,7 @@ onMounted(() => {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.subTitle {
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
@@ -385,6 +401,7 @@ onMounted(() => {
|
||||
font-size: clamp(34px, 4vw, 52px);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
h2.brand-title {
|
||||
font-size: clamp(28px, 4vw, 46px);
|
||||
}
|
||||
@@ -404,12 +421,14 @@ onMounted(() => {
|
||||
justify-items: center;
|
||||
min-height: 60px;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.devices {
|
||||
.data {
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.label {
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
@@ -417,6 +436,7 @@ onMounted(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 1px;
|
||||
background-color: rgba(255, 255, 255, 0.24);
|
||||
@@ -431,17 +451,20 @@ onMounted(() => {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
box-sizing: border-box;
|
||||
padding: 15px 20px;
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand-metrics_list {
|
||||
margin-top: 48px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 16px;
|
||||
height: 105px;
|
||||
|
||||
.metric-card {
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
@@ -449,9 +472,11 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
&.active {
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
span {
|
||||
color: rgba(226, 232, 240, 0.72);
|
||||
font-size: 13px;
|
||||
@@ -481,6 +506,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.login-form {
|
||||
width: 480px;
|
||||
height: 600px;
|
||||
@@ -529,6 +555,7 @@ onMounted(() => {
|
||||
border: 1px solid var(--app-surface-border);
|
||||
}
|
||||
}
|
||||
|
||||
.loginModel {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
@@ -549,6 +576,7 @@ onMounted(() => {
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
background-color: rgba(247, 248, 250, 1);
|
||||
border: 1px solid rgba(238, 240, 243, 1);
|
||||
|
||||
.actions {
|
||||
cursor: pointer;
|
||||
color: rgba(37, 99, 235, 1);
|
||||
@@ -619,6 +647,7 @@ onMounted(() => {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 34px rgba(53, 109, 255, 0.22);
|
||||
}
|
||||
|
||||
.tip {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgba(156, 163, 175, 1);
|
||||
@@ -634,9 +663,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(53, 109, 255, 0.24) inset,
|
||||
0 0 0 4px rgba(53, 109, 255, 0.12);
|
||||
box-shadow: 0 0 0 1px rgba(53, 109, 255, 0.24) inset,
|
||||
0 0 0 4px rgba(53, 109, 255, 0.12);
|
||||
background-color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
v-model="inputText"
|
||||
:autosize="{ minRows:5, maxRows:8 }"
|
||||
placeholder="请输入回复内容"
|
||||
:disabled="disable"
|
||||
type="textarea"
|
||||
@keydown="handleKeyDown"
|
||||
/>
|
||||
@@ -27,6 +28,7 @@
|
||||
class="btn-img"
|
||||
plain
|
||||
type="info"
|
||||
:disabled="disable"
|
||||
@click="handleSelectImage"
|
||||
>
|
||||
上传图片
|
||||
@@ -52,6 +54,12 @@ const props = defineProps({
|
||||
sending: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
/** 是否禁用输入框 */
|
||||
disable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -68,7 +76,7 @@ const inputText = ref('')
|
||||
|
||||
// 是否可以发送
|
||||
const canSend = computed(() => {
|
||||
return !!inputText.value.trim()
|
||||
return !!inputText.value.trim() && !props.disable
|
||||
})
|
||||
|
||||
// 点击图片图标,唤起文件选择
|
||||
|
||||
@@ -1,68 +1,27 @@
|
||||
<template>
|
||||
<div :class="currentClass" class="chat-tip-bar">
|
||||
<div class="chat-tip-bar agent">
|
||||
<!-- 提示条区域,放在MessageList内部最顶部,跟随滚动 -->
|
||||
<span>{{ currentText }}</span>
|
||||
<span>{{ msg }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
import {ChatNavBarType} from './types'
|
||||
import type {PropType} from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: Number as PropType<ChatNavBarType>,
|
||||
defineProps({
|
||||
msg: {
|
||||
type: String,
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.chat-tip-bar {
|
||||
min-width: 250px;
|
||||
max-width: 250px;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
margin: 7px auto;
|
||||
min-height: 24px;
|
||||
padding: 5px 12px;
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -8,20 +8,23 @@
|
||||
<span class="time">{{ time }}</span>
|
||||
</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">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
import avatar from '@/assets/images/avatar.png'
|
||||
|
||||
const props = defineProps({
|
||||
name: {type: String, default: '客服'},
|
||||
avatar: {type: String, default: ''},
|
||||
content: {type: String, required: true},
|
||||
avatar: {type: String, default: avatar},
|
||||
content: {type: String, default: ''},
|
||||
url: {type: String, default: ''},
|
||||
type: {type: String as () => 'text' | 'image', default: 'text'},
|
||||
tag: {type: String, default: '人工客服'},
|
||||
time: {type: String, default: ''}
|
||||
@@ -83,13 +86,17 @@ defineEmits(['preview'])
|
||||
color: rgba(31, 41, 55, 1);
|
||||
}
|
||||
|
||||
.text-bubble {
|
||||
display: inline-block;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.img-bubble {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.chat-img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -101,9 +101,9 @@ defineEmits(['quickAction'])
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -5,20 +5,23 @@
|
||||
<div v-if="type === 'text'" class="bubble" v-html="content"></div>
|
||||
<!-- 图片消息 -->
|
||||
<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 class="time">{{ time }}</div>
|
||||
</div>
|
||||
<img :src="avatar" class="avatar"/>
|
||||
<el-image :src="avatar" class="avatar"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import avatar from '@/assets/images/avatar.png'
|
||||
|
||||
defineProps({
|
||||
content: {type: String, required: true},
|
||||
type: {type: String as () => 'text' | 'image', default: 'text'},
|
||||
avatar: {type: String, default: ''},
|
||||
time: {type: String, default: ''}
|
||||
avatar: {type: String, default: avatar},
|
||||
time: {type: String, default: ''},
|
||||
url: {type: String, default: ''}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -58,12 +61,11 @@ defineProps({
|
||||
}
|
||||
|
||||
.img-bubble {
|
||||
padding: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.chat-img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,83 +1,72 @@
|
||||
<template>
|
||||
<div
|
||||
ref="listRef"
|
||||
class="message-list "
|
||||
class="message-list"
|
||||
@scroll.passive="handleScroll"
|
||||
>
|
||||
<!-- 加载历史消息提示 -->
|
||||
<div v-if="loadingHistory" class="load-more-tip">
|
||||
<span class="spinner"></span> 加载中...
|
||||
</div>
|
||||
|
||||
<!-- 消息列表:key 必须用唯一 id,禁止 index -->
|
||||
<template v-for="msg in messages" :key="msg.id">
|
||||
<!-- 2. 用户消息 -->
|
||||
<template v-for="msg in messages" :key="msg.seq">
|
||||
<ChatNavBar v-if="msg.senderType === 'system'" :msg="msg.content"/>
|
||||
<UserMessage
|
||||
v-if="msg.role === 'user'"
|
||||
v-else-if="msg.senderType === 'user'"
|
||||
:avatar="userAvatar"
|
||||
:content="msg.content"
|
||||
:msg-type="msg.msgType"
|
||||
:status="msg.status"
|
||||
:time="msg.time"
|
||||
:time="msg.createTime"
|
||||
:type="msg.msgType"
|
||||
:url="msg.url"
|
||||
@retry="handleRetry(msg)"
|
||||
/>
|
||||
|
||||
<!-- 3. 机器人消息 -->
|
||||
<RobotMessage
|
||||
v-else-if="msg.agentType === 'robot'"
|
||||
:avatar="msg.avatar"
|
||||
v-else-if="msg.senderType === 'bot'"
|
||||
:content="msg.content"
|
||||
:msg-type="msg.msgType"
|
||||
:name="msg.agentName"
|
||||
:quick-actions="msg.quickActions"
|
||||
:time="msg.time"
|
||||
:time="msg.createTime"
|
||||
@quick-action="handleQuickAction"
|
||||
/>
|
||||
|
||||
<!-- 4. 人工客服消息 -->
|
||||
<AgentMessage
|
||||
v-else
|
||||
:avatar="msg.avatar"
|
||||
v-else-if="msg.senderType === 'agent'"
|
||||
:content="msg.content"
|
||||
:msg-type="msg.msgType"
|
||||
:name="msg.agentName"
|
||||
:time="msg.time"
|
||||
:time="msg.createTime"
|
||||
:type="msg.msgType"
|
||||
:url="msg.url"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 底部占位 -->
|
||||
<div class="bottom-space"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {ref, watch, nextTick, onMounted} from 'vue'
|
||||
import SystemMessage from './SystemMessage.vue'
|
||||
import UserMessage from './UserMessage.vue'
|
||||
import RobotMessage from './RobotMessage.vue'
|
||||
import AgentMessage from './AgentMessage.vue'
|
||||
import ChatNavBar from '@/views/system/conversation/compontents/ChatNavBar.vue'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string | number
|
||||
type: 'system' | 'chat'
|
||||
role?: 'user' | 'agent'
|
||||
agentType?: 'robot' | 'human'
|
||||
senderType: 'user' | 'agent' | 'system' | 'bot'
|
||||
content: string
|
||||
msgType?: 'text' | 'image'
|
||||
agentName?: string
|
||||
avatar?: string
|
||||
time: string
|
||||
status?: 'sending' | 'sent' | 'failed'
|
||||
msgType: 'text' | 'image'
|
||||
createTime: string
|
||||
quickActions?: { label: string; action: string; primary?: boolean }[]
|
||||
clientMsgId: string
|
||||
messageId: string
|
||||
ossId: string
|
||||
url: string
|
||||
senderId: string
|
||||
seq: number
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
messages: ChatMessage[]
|
||||
userAvatar: string
|
||||
loadingHistory?: boolean
|
||||
/** 是否还有更多历史可加载(可选,建议父组件传) */
|
||||
hasMoreHistory?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -89,122 +78,68 @@ const emit = defineEmits<{
|
||||
|
||||
const listRef = ref<HTMLElement>()
|
||||
|
||||
/** 用户是否贴近底部(阈值 50px) */
|
||||
const isAtBottom = ref(true)
|
||||
|
||||
/** 首屏标记:首次渲染不走平滑滚动 */
|
||||
let isFirstRender = true
|
||||
|
||||
/** 加载历史前的 scrollHeight 快照 */
|
||||
let prevScrollHeight = 0
|
||||
|
||||
/** 加载历史防抖锁 */
|
||||
let isLoadingMore = false
|
||||
|
||||
/** 阈值常量 */
|
||||
const BOTTOM_THRESHOLD = 50
|
||||
const TOP_THRESHOLD = 30
|
||||
|
||||
/* ========== 判断是否贴近底部 ========== */
|
||||
const checkAtBottom = (): boolean => {
|
||||
const el = listRef.value
|
||||
if (!el) return true
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD
|
||||
}
|
||||
|
||||
/* ========== 滚动到底部 ========== */
|
||||
const scrollToBottom = (smooth = false) => {
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
const el = listRef.value
|
||||
if (!el) return
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
behavior: smooth ? 'smooth' : 'auto'
|
||||
await new Promise<void>(resolve => {
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/* ========== 滚动事件 ========== */
|
||||
const handleScroll = () => {
|
||||
const el = listRef.value
|
||||
if (!el) return
|
||||
|
||||
// 1. 更新底部状态
|
||||
isAtBottom.value = checkAtBottom()
|
||||
|
||||
// 2. 触顶加载历史
|
||||
if (
|
||||
el.scrollTop <= TOP_THRESHOLD &&
|
||||
!isLoadingMore &&
|
||||
!props.loadingHistory &&
|
||||
props.hasMoreHistory !== false
|
||||
) {
|
||||
if (el.scrollTop <= TOP_THRESHOLD && !isLoadingMore) {
|
||||
isLoadingMore = true
|
||||
// 关键:在 emit 之前记录当前 scrollHeight 快照
|
||||
prevScrollHeight = el.scrollHeight
|
||||
emit('loadMore')
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 首屏 + 新消息滚动 ========== */
|
||||
watch(
|
||||
() => props.messages?.length ?? 0,
|
||||
async (newLen, oldLen) => {
|
||||
await nextTick()
|
||||
() => props.messages.length,
|
||||
async (newLen) => {
|
||||
isLoadingMore = false
|
||||
|
||||
if (newLen === 0) return
|
||||
|
||||
if (isFirstRender) {
|
||||
// 场景 ①:首次进入页面,无动画直接滚到底
|
||||
scrollToBottom(false)
|
||||
await scrollToBottom()
|
||||
isFirstRender = false
|
||||
isAtBottom.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 场景 ②:新消息到达 && 用户在底部 → 平滑滚到底
|
||||
// 场景 ③:用户在翻历史 → 不滚动
|
||||
if (newLen > oldLen && isAtBottom.value) {
|
||||
scrollToBottom(true)
|
||||
if (isAtBottom.value) {
|
||||
await scrollToBottom()
|
||||
}
|
||||
},
|
||||
{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(() => {
|
||||
nextTick(() => {
|
||||
scrollToBottom(false)
|
||||
isAtBottom.value = true
|
||||
isFirstRender = false
|
||||
})
|
||||
isAtBottom.value = true
|
||||
})
|
||||
|
||||
/* ========== 对外暴露手动滚底(如发送消息成功后) ========== */
|
||||
defineExpose({
|
||||
scrollToBottom: (smooth = true) => scrollToBottom(smooth)
|
||||
})
|
||||
defineExpose({scrollToBottom})
|
||||
|
||||
/* ========== 事件转发 ========== */
|
||||
const handleQuickAction = (action: string) => emit('quickAction', action)
|
||||
const handleRetry = (msg: ChatMessage) => emit('retry', msg)
|
||||
const handlePreview = (url: string) => emit('preview', url)
|
||||
@@ -213,13 +148,12 @@ const handlePreview = (url: string) => emit('preview', url)
|
||||
<style scoped>
|
||||
.message-list {
|
||||
flex: 1;
|
||||
min-height: 0; /* ⚠️ 关键:flex 子项内滚动必须加 */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
/* 隐藏滚动条(Chrome / Firefox / IE) */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,10 @@
|
||||
import cardNav from '@/components/cardNav/index.vue';
|
||||
import {getDevice} from "@/api/system/device";
|
||||
import {DeviceForm} from "@/api/system/device/types";
|
||||
import {useUserStore} from "@/store/modules/user";
|
||||
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
function backList() {
|
||||
@@ -65,7 +68,6 @@ const form = ref();
|
||||
|
||||
function init() {
|
||||
form.value = {...initFormData};
|
||||
console.log(form.value);
|
||||
if (route.query.id) {
|
||||
getDevice(route.query.id as string).then(res => {
|
||||
Object.assign(form.value, res.data);
|
||||
@@ -82,7 +84,9 @@ init()
|
||||
<nav class="flex justify-between" style="padding-top: 22px;">
|
||||
<Pagecrumb></Pagecrumb>
|
||||
<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>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -347,7 +347,7 @@ const handleOrderChange = (prop: string, order: string) => {
|
||||
};
|
||||
/** 任务日志列表查询 */
|
||||
const handleOssConfig = () => {
|
||||
router.push('/system/oss-config/index.vue');
|
||||
router.push('/system/oss-config/index');
|
||||
};
|
||||
/** 文件按钮操作 */
|
||||
const handleFile = () => {
|
||||
|
||||
@@ -1,31 +1,126 @@
|
||||
<script lang="ts" setup>
|
||||
import {ArrowLeft, InfoFilled} from "@element-plus/icons-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: '超级管理员',
|
||||
sutTitle: "拥有系统所有功能权限"
|
||||
sutTitle: "拥有系统所有功能权限",
|
||||
color: '#2563eb'
|
||||
},
|
||||
{
|
||||
label: '设备管理员',
|
||||
sutTitle: "管理设备列表、OTA升级等"
|
||||
sutTitle: "管理设备列表、OTA升级等",
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
label: '普通用户',
|
||||
sutTitle: "仅可查看设备信息"
|
||||
sutTitle: "仅可查看设备信息",
|
||||
color: '#22c55e'
|
||||
},
|
||||
{
|
||||
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>
|
||||
<template>
|
||||
<div class="Add">
|
||||
<div class="Add beautify_scroll">
|
||||
<div class="AddNav">
|
||||
<div class="addItem">
|
||||
<div class="arrowBox">
|
||||
<div class="arrowBox" @click="reBack">
|
||||
<el-icon>
|
||||
<arrow-left></arrow-left>
|
||||
</el-icon>
|
||||
@@ -36,14 +131,15 @@ const roleslist = [
|
||||
</div>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="17">
|
||||
<el-form label-position="top">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<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-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.userName"
|
||||
:inputAttrs="{placeholder:'请输入登录用户名'}"
|
||||
:required="true"
|
||||
prop="userName"
|
||||
label="登录用户名"
|
||||
tip="4-20位字母、数字或下划线,创建后不可修改"
|
||||
type="input"
|
||||
@@ -51,23 +147,27 @@ const roleslist = [
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.nickName"
|
||||
:inputAttrs="{placeholder:'请输入真实姓名'}"
|
||||
:required="true"
|
||||
prop="nickName"
|
||||
label="用户姓名"
|
||||
type="input"
|
||||
></FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.phoneNumber"
|
||||
:inputAttrs="{placeholder:'请输入手机号码'}"
|
||||
:required="true"
|
||||
prop="phoneNumber"
|
||||
label="手机号码"
|
||||
type="input"
|
||||
></FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.email"
|
||||
:inputAttrs="{placeholder:'请输入邮箱'}"
|
||||
prop="email"
|
||||
:required="false"
|
||||
label="邮箱"
|
||||
type="input"
|
||||
@@ -75,11 +175,12 @@ const roleslist = [
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.gender"
|
||||
:radio-options="[
|
||||
{label:'男',value:1},
|
||||
{label:'女',value:0},
|
||||
{label:'男',value:'1'},
|
||||
{label:'女',value:'0'},
|
||||
]"
|
||||
:required="false"
|
||||
prop="gender"
|
||||
label="性别"
|
||||
type="radio"
|
||||
></FormItemTip>
|
||||
@@ -90,8 +191,14 @@ const roleslist = [
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
:required="true"
|
||||
v-model="form.roleIds"
|
||||
:disabled-values="['superadmin']"
|
||||
:radioOptions="roleList"
|
||||
labelKey="roleName"
|
||||
prop="roleIds"
|
||||
tip="角色决定用户的功能权限范围"
|
||||
label="角色"
|
||||
valueKey="roleKey"
|
||||
type="select"
|
||||
></FormItemTip>
|
||||
</el-col>
|
||||
@@ -99,29 +206,31 @@ const roleslist = [
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
:input-attrs="{maxlength:20,minlength:8}"
|
||||
:required="true"
|
||||
v-model="form.password"
|
||||
:input-attrs="{maxlength:20,minlength:8,placeholder:'请再次输入密码'}"
|
||||
prop="password"
|
||||
label="初始密码"
|
||||
tip="8-20位,包含字母和数字"
|
||||
type="input"
|
||||
type="password"
|
||||
></FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.confirmPassword"
|
||||
:input-attrs="{placeholder:'请再次输入密码',maxlength:20,minlength:8}"
|
||||
:required="true"
|
||||
prop="confirmPassword"
|
||||
label="重复初始密码"
|
||||
type="input"
|
||||
type="password"
|
||||
></FormItemTip>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<FormItemTip
|
||||
v-model="form.status"
|
||||
:radio-options="[
|
||||
{label:'启用',value:1},
|
||||
{label:'禁用',value:0},
|
||||
{label:'启用',value:'0'},
|
||||
{label:'禁用',value:'1'},
|
||||
]"
|
||||
:required="false"
|
||||
label="性别"
|
||||
label="状态"
|
||||
type="radio"
|
||||
></FormItemTip>
|
||||
</el-col>
|
||||
@@ -133,9 +242,9 @@ const roleslist = [
|
||||
<div class="commonCard roleInfoBox">
|
||||
<card-nav :show-line="false" title="角色说明"></card-nav>
|
||||
<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">
|
||||
<i class="circle"></i>
|
||||
<i :style="{backgroundColor: item.color}" class="circle"></i>
|
||||
<span class="title">{{ item.label }}</span>
|
||||
</div>
|
||||
<div class="subTitle">{{ item.sutTitle }}</div>
|
||||
@@ -143,20 +252,24 @@ const roleslist = [
|
||||
</ul>
|
||||
</div>
|
||||
<div class="commonCard tipBox">
|
||||
<div>
|
||||
<el-icon>
|
||||
<div class="tipTitle">
|
||||
<el-icon :size="18">
|
||||
<InfoFilled/>
|
||||
</el-icon>
|
||||
<span>操作提示</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li>1. 带 * 号为必填项</li>
|
||||
<li>1. 带 <strong>*</strong> 号为必填项</li>
|
||||
<li>2. 用户名创建后不可修改</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<el-button>确认创建</el-button>
|
||||
<el-button>取消</el-button>
|
||||
<div class="actionBox">
|
||||
<div style="width:100%;">
|
||||
<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>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -184,7 +297,7 @@ const roleslist = [
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
@@ -248,11 +361,39 @@ const roleslist = [
|
||||
}
|
||||
|
||||
.tipBox {
|
||||
margin-top: 20px;
|
||||
padding: 15px 17px;
|
||||
border-radius: 8px 8px 8px 8px;
|
||||
background-color: rgba(239, 246, 255, 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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div style="margin-top:24px">
|
||||
<pagecrumb>
|
||||
<template #rightSlot>
|
||||
<el-button size="large" type="primary">新增账号</el-button>
|
||||
<el-button size="large" type="primary" @click="handleAdd">新增账号</el-button>
|
||||
</template>
|
||||
</pagecrumb>
|
||||
</div>
|
||||
@@ -72,9 +72,6 @@
|
||||
<h3>用户列表</h3>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button v-has-permi="['system:user:add']" type="primary" plain icon="Plus" @click="handleAdd()">
|
||||
新增
|
||||
</el-button>
|
||||
<right-toolbar
|
||||
v-model:show-search="showSearch"
|
||||
:columns="columns"
|
||||
@@ -94,24 +91,20 @@
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<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
|
||||
key="userName"
|
||||
label="姓名"
|
||||
align="center"
|
||||
:show-overflow-tooltip="true"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-link type="primary" underline="never" @click="handleViewDetail(scope.row)">
|
||||
{{ scope.row.userName }}
|
||||
</el-link>
|
||||
{{ scope.row.nickName }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
key="nickName"
|
||||
label="角色"
|
||||
align="center"
|
||||
prop="nickName"
|
||||
prop="roleIds"
|
||||
:show-overflow-tooltip="true"
|
||||
></el-table-column>
|
||||
<el-table-column
|
||||
@@ -128,14 +121,14 @@
|
||||
<el-table-column align="center" label="账号状态" width="100">
|
||||
<template #default="scope">
|
||||
<div class="flex justify-center">
|
||||
<div class="open tag"><i class="circle"></i><span>启用</span></div>
|
||||
<!-- <div class="close 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 v-else class="close tag"><i class="circle"></i><span>已禁用</span></div>
|
||||
</div>
|
||||
</template>
|
||||
</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">
|
||||
<span style="color:#64748B;">{{ scope.row.createTime }}</span>
|
||||
<span style="color:#64748B;">{{ scope.row.loginDate }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="创建时间" prop="createTime" width="160">
|
||||
@@ -145,43 +138,47 @@
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" fixed="right" label="操作"
|
||||
width="300">
|
||||
width="220">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-hasPermi="['system:user:edit']"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleUpdate(scope.row)"
|
||||
>编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['system:user:resetPwd']"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleResetPwd(scope.row)"
|
||||
>重置密码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['system:user:edit']"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleAuthRole(scope.row)"
|
||||
>禁用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['system:user:edit']"
|
||||
link
|
||||
type="success"
|
||||
@click="handleAuthRole(scope.row)"
|
||||
>启用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['system:user:remove']"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row)"
|
||||
>删除
|
||||
</el-button>
|
||||
<div v-if="scope.row.userId !== '1761100000000000001'">
|
||||
<el-button
|
||||
v-hasPermi="['system:user:edit']"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleUpdate(scope.row)"
|
||||
>编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['system:user:resetPwd']"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleResetPwd(scope.row)"
|
||||
>重置密码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.status === '0'"
|
||||
v-hasPermi="['system:user:edit']"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleAuthRole(scope.row)"
|
||||
>禁用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
v-hasPermi="['system:user:edit']"
|
||||
link
|
||||
type="success"
|
||||
@click="handleAuthRole(scope.row)"
|
||||
>启用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['system:user:remove']"
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row)"
|
||||
>删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -704,13 +701,16 @@ const cancel = () => {
|
||||
|
||||
/** 新增按钮操作 */
|
||||
const handleAdd = async () => {
|
||||
reset();
|
||||
const {data} = await api.getUser();
|
||||
setDialogTitle('新增用户');
|
||||
openUserDialog();
|
||||
postOptions.value = data.posts;
|
||||
roleOptions.value = data.roles;
|
||||
form.value.password = initPassword.value.toString();
|
||||
router.push({
|
||||
path: '/system/addUser'
|
||||
})
|
||||
// reset();
|
||||
// const {data} = await api.getUser();
|
||||
// setDialogTitle('新增用户');
|
||||
// openUserDialog();
|
||||
// postOptions.value = data.posts;
|
||||
// roleOptions.value = data.roles;
|
||||
// form.value.password = initPassword.value.toString();
|
||||
};
|
||||
|
||||
/** 修改按钮操作 */
|
||||
|
||||
@@ -35,7 +35,7 @@ export default defineConfig(({mode, command}) => {
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: path => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
css: {
|
||||
|
||||
Reference in New Issue
Block a user