1. 工作人员账号添加
This commit is contained in:
Zy
2026-10-15 09:30:04 +08:00
parent 2ccce95d44
commit 87cd3d2519
30 changed files with 1988 additions and 614 deletions

View File

@@ -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

View 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',
}
});
};

View 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
}

View File

@@ -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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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`; // 否则全部重定向到登录页
} }
} }
}); });

View 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,
};
});

View File

View 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
View File

@@ -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;

View File

@@ -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);

View File

@@ -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
} }

View File

@@ -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;
// 是否需要防止数据重复提交 // 是否需要防止数据重复提交

View 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);
}
}

View File

@@ -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);
} }

View File

@@ -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
}) })
// 点击图片图标,唤起文件选择 // 点击图片图标,唤起文件选择

View File

@@ -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} &nbsp; 用户点击「仍未解决,转人工」`
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;
} }

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 = () => {

View File

@@ -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>

View File

@@ -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();
}; };
/** 修改按钮操作 */ /** 修改按钮操作 */

View File

@@ -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: {