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

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 {
roleName: string;
roleKey: string;
status: string;
roleName?: string;
roleKey?: string;
status?: string;
}
export interface RoleForm {

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -5,6 +5,7 @@
* @example 136****9023
* */
export function hiddenPhone(phone: string): string {
if (!phone) return '---';
if (phone.length < 11) {
return phone
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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