1. 设备管理界面完成
2. 用户管理界面完成
3. 会话列表完成
This commit is contained in:
Zy
2026-10-10 17:02:31 +08:00
parent 23703a66c2
commit 2ccce95d44
64 changed files with 5341 additions and 1254 deletions

View File

@@ -0,0 +1,146 @@
<template>
<div class="chat-input-wrap">
<div class="input-area">
<!-- 多行输入框 -->
<el-input
ref="inputRef"
v-model="inputText"
:autosize="{ minRows:5, maxRows:8 }"
placeholder="请输入回复内容"
type="textarea"
@keydown="handleKeyDown"
/>
<!-- 发送按钮 -->
<el-button
:disabled="!canSend || sending"
class="btn-send"
size="large"
type="primary"
@click="handleSend"
>
{{ sending ? '发送中' : '发送' }}
</el-button>
</div>
<div>
<!-- 图片上传按钮 -->
<el-button
class="btn-img"
plain
type="info"
@click="handleSelectImage"
>
上传图片
</el-button>
</div>
<!-- 隐藏input用于选图 -->
<input
ref="fileRef"
accept="image/*"
style="display:none"
type="file"
@change="onFileChange"
/>
</div>
</template>
<script lang="ts" setup>
import {ref, computed} from 'vue'
import type {PropType} from 'vue'
const props = defineProps({
/** 是否正在发送中(外部控制,防止重复点击) */
sending: {
type: Boolean,
default: false
}
})
const emit = defineEmits<{
/** 发送文本消息 */
(e: 'send', text: string): void
/** 选中图片文件,交给父组件上传oss */
(e: 'uploadImage', file: File): void
}>()
const inputRef = ref<InstanceType<typeof elInput>>()
const fileRef = ref<HTMLInputElement | null>(null)
const inputText = ref('')
// 是否可以发送
const canSend = computed(() => {
return !!inputText.value.trim()
})
// 点击图片图标,唤起文件选择
const handleSelectImage = () => {
fileRef.value?.click()
}
// 文件选择回调
const onFileChange = (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
emit('uploadImage', file)
// 清空file,选同一个图片也能触发change
target.value = ''
}
// 键盘事件:回车发送,Shift+Enter换行
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
// shift+enter 原生换行,不拦截
return
}
// 直接回车发送
e.preventDefault()
handleSend()
}
}
// 发送消息
const handleSend = () => {
const rawText = inputText.value.trim()
if (!rawText) return
// 把换行 \n 替换成 <br/>
const htmlText = rawText.replaceAll('\n', '<br/>')
emit('send', htmlText)
// 发送成功后清空输入框
inputText.value = ''
}
</script>
<style lang="scss" scoped>
.chat-input-wrap {
flex-shrink: 0;
padding: 12px;
background: #fff;
}
.input-area {
display: flex;
gap: 8px;
align-items: flex-end;
:deep(.el-textarea__inner) {
background-color: #f7f9fc;
}
}
.btn-img {
flex-shrink: 0;
margin-top: 12px;
padding: 9px 13px;
color: rgba(75, 85, 99, 1);
font-size: 12px;
}
.btn-send {
flex-shrink: 0;
}
:deep(.el-textarea__inner) {
padding: 8px 12px;
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<div :class="currentClass" class="chat-tip-bar">
<!-- 提示条区域,放在MessageList内部最顶部,跟随滚动 -->
<span>{{ currentText }}</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>,
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;
overflow: hidden;
margin: 7px auto;
min-height: 24px;
padding: 5px 12px;
text-align: left;
border-radius: 12px;
text-wrap: wrap;
}
.chat-tip-bar span {
text-wrap: wrap;
}
.agent {
color: rgba(156, 163, 175, 1);
background-color: rgba(233, 236, 241, 1);
font-size: 11px;
}
.await {
color: rgba(234, 88, 12, 1);
background-color: rgba(255, 247, 237, 1);
font-size: 11px;
}
.accept {
background-color: rgba(232, 247, 238, 1);
color: rgba(22, 163, 74, 1);
font-size: 11px;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<div class="agent-message">
<img :src="avatar" class="avatar"/>
<div class="msg-body">
<div class="msg-header">
<span class="name">客服{{ name }}</span>
<span v-if="tag" class="tag agent-tag">{{ tag }}</span>
<span class="time">{{ time }}</span>
</div>
<!-- 文本消息 -->
<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" @click="$emit('preview', content)"/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
defineProps({
name: {type: String, default: '客服'},
avatar: {type: String, default: ''},
content: {type: String, required: true},
type: {type: String as () => 'text' | 'image', default: 'text'},
tag: {type: String, default: '人工客服'},
time: {type: String, default: ''}
})
defineEmits(['preview'])
</script>
<style scoped>
.agent-message {
display: flex;
margin-bottom: 16px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.msg-body {
margin-left: 8px;
max-width: 75%;
}
.msg-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.name {
font-size: 12px;
color: #333;
font-weight: 500;
}
.agent-tag {
background: #eff6ff;
color: #2563eb;
font-weight: bold;
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
}
.time {
font-size: 11px;
color: #999;
}
.bubble {
background: #fff;
border-radius: 4px 16px 16px 16px;
padding: 10px 14px;
font-size: 14px;
line-height: 22px;
color: rgba(31, 41, 55, 1);
}
.img-bubble {
padding: 4px;
}
.chat-img {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
display: block;
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="robot-message">
<div class="avatar">智</div>
<div class="msg-body">
<!-- 头部:客服名 + 标签 + 时间 -->
<div class="msg-header">
<span class="name">智能客服 小智</span>
<span class="tag robot-tag">机器人</span>
<span class="time">{{ time }}</span>
</div>
<!-- 气泡 -->
<div class="bubble" v-html="content"></div>
<!-- 快捷按钮 -->
<div v-if="quickActions?.length" class="quick-actions">
<button
v-for="btn in quickActions"
:key="btn.action"
:class="{ primary: btn.primary }"
class="action-btn"
@click="$emit('quickAction', btn.action)"
>
{{ btn.label }}
</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
interface QuickAction {
label: string
action: string
primary?: boolean
}
defineProps({
name: {type: String, default: '智能客服'},
avatar: {type: String, default: ''},
content: {type: String, required: true},
time: {type: String, default: ''},
quickActions: {type: Array as () => QuickAction[], default: () => []}
})
defineEmits(['quickAction'])
</script>
<style scoped>
.robot-message {
display: flex;
margin-bottom: 16px;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
background-color: rgba(15, 118, 110, 1);
}
.msg-body {
margin-left: 8px;
max-width: 75%;
}
.msg-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.name {
font-size: 12px;
color: rgba(156, 163, 175, 1);
font-weight: 500;
}
.robot-tag {
background: #f3f0ff;
color: #8b50ef;
font-weight: bold;
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
}
.time {
font-size: 11px;
color: #999;
}
.bubble {
background: #fff;
border-radius: 12px 12px 12px 2px;
padding: 10px 14px;
font-size: 14px;
line-height: 1.6;
color: #333;
white-space: pre-wrap;
word-break: break-all;
}
.quick-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.action-btn {
padding: 4px 14px;
border: 1px solid #2563eb;
border-radius: 14px;
background: #fff;
color: #2563eb;
font-size: 12px;
white-space: nowrap;
}
.action-btn.primary {
background: #2563eb;
color: #fff;
}
</style>

View File

@@ -0,0 +1,26 @@
<template>
<div class="system-message">
<span class="system-text">{{ text }}</span>
</div>
</template>
<script lang="ts" setup>
defineProps({
text: {type: String, required: true}
})
</script>
<style scoped>
.system-message {
text-align: center;
margin: 12px 0;
}
.system-text {
display: inline-block;
font-size: 12px;
color: #999;
background: transparent;
padding: 2px 8px;
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<div class="user-message">
<div class="msg-body">
<!-- 文本消息 -->
<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"/>
</div>
<div class="time">{{ time }}</div>
</div>
<img :src="avatar" class="avatar"/>
</div>
</template>
<script lang="ts" setup>
defineProps({
content: {type: String, required: true},
type: {type: String as () => 'text' | 'image', default: 'text'},
avatar: {type: String, default: ''},
time: {type: String, default: ''}
})
</script>
<style scoped>
.user-message {
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
.msg-body {
margin-right: 8px;
max-width: 363px;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.bubble {
background-color: rgba(37, 99, 235, 1);
color: #fff;
border-radius: 16px 4px 16px 16px;
padding: 10px 12px;
font-size: 14px;
word-break: break-all;
line-height: 22px;
color: rgba(255, 255, 255, 1);
}
.img-bubble {
padding: 4px;
}
.chat-img {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
display: block;
}
.time {
font-size: 11px;
color: #999;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,260 @@
<template>
<div
ref="listRef"
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. 用户消息 -->
<UserMessage
v-if="msg.role === 'user'"
:avatar="userAvatar"
:content="msg.content"
:msg-type="msg.msgType"
:status="msg.status"
:time="msg.time"
@retry="handleRetry(msg)"
/>
<!-- 3. 机器人消息 -->
<RobotMessage
v-else-if="msg.agentType === 'robot'"
:avatar="msg.avatar"
:content="msg.content"
:msg-type="msg.msgType"
:name="msg.agentName"
:quick-actions="msg.quickActions"
:time="msg.time"
@quick-action="handleQuickAction"
/>
<!-- 4. 人工客服消息 -->
<AgentMessage
v-else
:avatar="msg.avatar"
:content="msg.content"
:msg-type="msg.msgType"
:name="msg.agentName"
:time="msg.time"
@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'
interface ChatMessage {
id: string | number
type: 'system' | 'chat'
role?: 'user' | 'agent'
agentType?: 'robot' | 'human'
content: string
msgType?: 'text' | 'image'
agentName?: string
avatar?: string
time: string
status?: 'sending' | 'sent' | 'failed'
quickActions?: { label: string; action: string; primary?: boolean }[]
}
const props = defineProps<{
messages: ChatMessage[]
userAvatar: string
loadingHistory?: boolean
/** 是否还有更多历史可加载(可选,建议父组件传) */
hasMoreHistory?: boolean
}>()
const emit = defineEmits<{
(e: 'loadMore'): void
(e: 'quickAction', action: string): void
(e: 'retry', msg: ChatMessage): void
(e: 'preview', url: string): void
}>()
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 el = listRef.value
if (!el) return
el.scrollTo({
top: el.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
})
}
/* ========== 滚动事件 ========== */
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
) {
isLoadingMore = true
// 关键:在 emit 之前记录当前 scrollHeight 快照
prevScrollHeight = el.scrollHeight
emit('loadMore')
}
}
/* ========== 首屏 + 新消息滚动 ========== */
watch(
() => props.messages?.length ?? 0,
async (newLen, oldLen) => {
await nextTick()
if (isFirstRender) {
// 场景 ①:首次进入页面,无动画直接滚到底
scrollToBottom(false)
isFirstRender = false
isAtBottom.value = true
return
}
// 场景 ②:新消息到达 && 用户在底部 → 平滑滚到底
// 场景 ③:用户在翻历史 → 不滚动
if (newLen > oldLen && isAtBottom.value) {
scrollToBottom(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(() => {
nextTick(() => {
scrollToBottom(false)
isAtBottom.value = true
isFirstRender = false
})
})
/* ========== 对外暴露手动滚底(如发送消息成功后) ========== */
defineExpose({
scrollToBottom: (smooth = true) => scrollToBottom(smooth)
})
/* ========== 事件转发 ========== */
const handleQuickAction = (action: string) => emit('quickAction', action)
const handleRetry = (msg: ChatMessage) => emit('retry', msg)
const handlePreview = (url: string) => emit('preview', url)
</script>
<style scoped>
.message-list {
flex: 1;
min-height: 0; /* ⚠️ 关键:flex 子项内滚动必须加 */
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
-webkit-overflow-scrolling: touch;
/* 隐藏滚动条(Chrome / Firefox / IE) */
scrollbar-width: none;
-ms-overflow-style: none;
}
.message-list::-webkit-scrollbar {
display: none;
}
.load-more-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
text-align: center;
font-size: 12px;
color: #999;
padding: 8px 0;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid #ddd;
border-top-color: #409eff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.bottom-space {
height: 8px;
}
</style>

View File

@@ -0,0 +1,287 @@
// src/mock/chatData.ts
export interface QuickAction {
label: string
action: string
primary?: boolean
}
import {ChatMessage} from './types'
/** 用户头像(也可以从 store / 用户信息里取) */
export const userAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix'
/** 机器人头像 */
export const robotAvatar = 'https://api.dicebear.com/7.x/bottts/svg?seed=Robot'
/** 人工客服头像 */
export const agentAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka'
/**
* 初始消息(首屏展示)
* 涵盖:系统消息 / 机器人消息(含快捷按钮) / 用户消息(各种状态) / 人工客服消息(文本+图片)
*/
export const initMessages: ChatMessage[] = [
// ========== 系统消息:时间分割 ==========
{
id: 'sys_001',
type: 'system',
content: '2026-01-15 14:00',
time: '14:00',
},
// ========== 系统消息:排队提示 ==========
{
id: 'sys_002',
type: 'system',
content: '您已进入排队,当前前面有 2 位用户,预计等待 1 分钟',
time: '14:00',
},
// ========== 机器人欢迎消息 ==========
{
id: 'robot_001',
type: 'chat',
role: 'agent',
agentType: 'robot',
agentName: '智能助手',
avatar: robotAvatar,
content: '您好!我是智能客服小助手 🤖\n请问有什么可以帮您的?\n\n您可以选择下面的常见问题,或直接输入您的问题。',
msgType: 'text',
time: '14:01',
quickActions: [
{label: '查询订单', action: 'query_order', primary: true},
{label: '退换货政策', action: 'refund_policy'},
{label: '发票问题', action: 'invoice'},
{label: '联系人工', action: 'transfer_agent'},
],
},
// ========== 用户消息:正常文本 ==========
{
id: 'user_001',
type: 'chat',
role: 'user',
content: '我想查一下我的订单状态',
msgType: 'text',
time: '14:02',
status: 'sent',
},
// ========== 机器人回复 + 快捷按钮 ==========
{
id: 'robot_002',
type: 'chat',
role: 'agent',
agentType: 'robot',
agentName: '智能助手',
avatar: robotAvatar,
content: '好的,请提供您的订单编号,我帮您查询。\n订单编号格式为:ORD-XXXXXXXX',
msgType: 'text',
time: '14:02',
quickActions: [
{label: '不知道订单号', action: 'no_order_id'},
{label: '转人工', action: 'transfer_agent', primary: true},
],
},
// ========== 用户消息:图片 ==========
{
id: 'user_002',
type: 'chat',
role: 'user',
content: 'https://picsum.photos/400/300?random=1',
msgType: 'image',
time: '14:03',
status: 'sent',
},
// ========== 系统消息:转接人工 ==========
{
id: 'sys_003',
type: 'system',
content: '正在为您转接人工客服,请稍候...',
time: '14:03',
},
// ========== 系统消息:人工接入 ==========
{
id: 'sys_004',
type: 'system',
content: '人工客服已接入,正在为您服务',
time: '14:04',
},
// ========== 人工客服:文本 ==========
{
id: 'agent_001',
type: 'chat',
role: 'agent',
agentType: 'human',
agentName: '客服小林',
avatar: agentAvatar,
content: '您好,我是人工客服小林,很高兴为您服务!\n请把订单号发给我,我马上帮您查。',
msgType: 'text',
time: '14:04',
},
// ========== 人工客服:图片(订单截图示例) ==========
{
id: 'agent_002',
type: 'chat',
role: 'agent',
agentType: 'human',
agentName: '客服小林',
avatar: agentAvatar,
content: 'https://picsum.photos/400/300?random=2',
msgType: 'image',
time: '14:05',
},
// ========== 用户消息:发送中 ==========
{
id: 'user_003',
type: 'chat',
role: 'user',
content: '好的,我看到了,谢谢',
msgType: 'text',
time: '14:05',
status: 'sending',
},
// ========== 用户消息:发送成功 ==========
{
id: 'user_004',
type: 'chat',
role: 'user',
content: '请问大概什么时候能到?',
msgType: 'text',
time: '14:06',
status: 'sent',
},
// ========== 用户消息:发送失败(红色感叹号,可重发) ==========
{
id: 'user_005',
type: 'chat',
role: 'user',
content: '快递单号能再发一下吗?',
msgType: 'text',
time: '14:06',
status: 'failed',
},
// ========== 人工客服:富文本/长文本换行 ==========
{
id: 'agent_003',
type: 'chat',
role: 'agent',
agentType: 'human',
agentName: '客服小林',
avatar: agentAvatar,
content:
'订单已于今天上午发货 📦\n物流单号:SF1234567890\n\n预计 1 月 16 日下午送达,请保持手机畅通。\n如未按时收到,可以随时联系我~',
msgType: 'text',
time: '14:07',
},
]
/**
* 加载历史消息(模拟分页返回,用于验证"保持视觉位置"逻辑)
* 每次调用返回 5~8 条更早的消息,id 递增
*/
let historyBatch = 0
export function fetchMoreHistory(): ChatMessage[] {
historyBatch += 1
const base = 100 * historyBatch
return [
// 时间分割
{
id: `history_${base}_00`,
type: 'system',
content: `2026-01-15 ${String(13 - historyBatch).padStart(2, '0')}:50`,
time: `${String(13 - historyBatch).padStart(2, '0')}:50`,
},
// 用户历史消息
{
id: `history_${base}_01`,
type: 'chat',
role: 'user',
content: `这是第 ${historyBatch} 批历史消息中的用户提问 1`,
msgType: 'text',
time: `${String(13 - historyBatch).padStart(2, '0')}:50`,
status: 'sent',
},
// 机器人回复
{
id: `history_${base}_02`,
type: 'chat',
role: 'agent',
agentType: 'robot',
agentName: '智能助手',
avatar: robotAvatar,
content: `这是机器人对历史问题 ${historyBatch}-1 的回复。`,
msgType: 'text',
time: `${String(13 - historyBatch).padStart(2, '0')}:51`,
},
// 用户历史图片
{
id: `history_${base}_03`,
type: 'chat',
role: 'user',
content: `https://picsum.photos/400/300?random=${base + 10}`,
msgType: 'image',
time: `${String(13 - historyBatch).padStart(2, '0')}:51`,
status: 'sent',
},
// 人工客服历史回复
{
id: `history_${base}_04`,
type: 'chat',
role: 'agent',
agentType: 'human',
agentName: '客服小林',
avatar: agentAvatar,
content: `历史人工回复:已为您记录,请稍候。`,
msgType: 'text',
time: `${String(13 - historyBatch).padStart(2, '0')}:52`,
},
// 系统消息
{
id: `history_${base}_05`,
type: 'system',
content: `—— 以上为第 ${historyBatch} 批历史消息 ——`,
time: `${String(13 - historyBatch).padStart(2, '0')}:52`,
},
]
}
/**
* 模拟机器人回复(发送消息后延迟触发)
*/
export function mockRobotReply(userText: string): ChatMessage {
return {
id: `robot_${Date.now()}`,
type: 'chat',
role: 'agent',
agentType: 'robot',
agentName: '智能助手',
avatar: robotAvatar,
content: `收到您的问题:"${userText}"\n正在为您查询,请稍候...`,
msgType: 'text',
time: formatNowTime(),
}
}
/** 格式化当前时间为 HH:mm */
export function formatNowTime(): string {
const d = new Date()
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}

View File

@@ -0,0 +1,37 @@
// 消息类型
type MessageType = 'text' | 'image' | 'system' | 'robot' | 'action'
// chatNavBar
// export type ChatNavBarType = {
// /** 需要转人工 */
// agent: 'agent',
// /** 排队提醒 */
// await: 'await',
// /** 客服已接入会话*/
// accept: 'accept',
// }
export enum ChatNavBarType {
/** 需要转人工 */
agent,
/** 排队提醒 */
await,
/** 客服已接入会话*/
accept,
}
export interface ChatMessage {
id: string | number
type: 'system' | 'chat'
role?: 'user' | 'agent'
agentType?: 'robot' | 'human'
content: string
msgType?: 'text' | 'image'
agentName?: string
avatar?: string
time: string
status?: 'sending' | 'sent' | 'failed'
quickActions?: { label: string; action: string; primary?: boolean }[]
}

View File

@@ -0,0 +1,413 @@
<script lang="ts" setup>
import pageCrumb from '@/components/Pagecrumb/index.vue'
import ChatNavBar from "./compontents/ChatNavBar.vue";
import {ChatNavBarType} from "./compontents/types";
import MessageList from './compontents/MessageList/index.vue'
import {initMessages, userAvatar} from "./compontents/data";
import ChatInput from "@/views/system/conversation/compontents/ChatInput.vue";
const defaultImage = import.meta.env.VITE_DEFAULT_USER_IMAGE;
const userDevicesList = [
{
label: 'Z2 空气净化器 · 客厅',
model: 'CZ-Z2023',
mac: "AA:BB:CC:DD:A3:B6",
status: 0,
},
{
label: 'Z2 空气净化器 · 主卧',
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,
agentInfo: {
no: '8823',
name: '小雨'
},
message: []
})
onMounted(() => {
form.value.message = [...initMessages]
})
const sending = ref(false)
// 发送文本消息
const onSendMsg = async (text: string) => {
sending.value = true
// 组装消息,推入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>
<template>
<div class="Main beautify_scroll">
<pageCrumb>
<template #rightSlot>
<el-button plain type="info">返回会话列表</el-button>
</template>
</pageCrumb>
<div class="commonCard">
<card-nav
:show-line="false"
subTitle="开始时间 2026-09-10 12:44:52 · 等待时长 00:38 · 接入方式:机器人接待 → 转人工"
title="CS20260918100038"
>
<template #rightTitle>
<el-tag type="primary">人工接待</el-tag>
</template>
<template #rightSlot>
<el-button plain type="default">导出聊天记录</el-button>
<el-button plain type="danger">关闭会话</el-button>
</template>
</card-nav>
</div>
<el-row :gutter="20">
<el-col :lg="16" :md="15" :span="24" :xl="18">
<div class="commonCard userWindowBox">
<div class="userWindowNav userWindowNavItem">
<el-image :src="defaultImage" class="userAvatar"></el-image>
<div class="userWindowNavItem-l">
<div class="userWindowNavItem-l-info">
<div>
<span class="name">林筱曼</span>
<span class="phone">138****6688</span>
</div>
<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 class="content beautify_scroll">
<ChatNavBar
:agent-info="form.agentInfo"
:await-num="form.awaitNum"
:time="form.time"
:type="form.status"
></ChatNavBar>
<MessageList :messages="form.message" :user-avatar="userAvatar"></MessageList>
</div>
<div class="footer">
<!-- 输入框组件 -->
<ChatInput :sending="sending" @send="onSendMsg" @uploadImage="onUploadImage"/>
</div>
</div>
</el-col>
<el-col :lg="8" :md="9" :span="24" :xl="6">
<div class="commonCard cardUserInfoBox">
<card-nav title="用户信息">
<template slot="rightTitle">
<el-tag type="primary">用户详情</el-tag>
</template>
</card-nav>
<ul class="cardUserInfo">
<li class="cardUserInfo_item">
<span class="label">用户ID</span>
<span class="value">U20260918001</span>
</li>
<li class="cardUserInfo_item">
<span class="label">昵称</span>
<span class="value">林小满</span>
</li>
<li class="cardUserInfo_item">
<span class="label">手机号</span>
<span class="value">138****6688</span>
</li>
<li class="cardUserInfo_item">
<span class="label">注册时间</span>
<span class="value">2026-01-10 14:20</span>
</li>
</ul>
<div class="userDevicesBox">
<card-nav :show-line="false">
<template #title>
<span class="titleName">名下设备(3台)</span>
</template>
<template #rightSlot>
<span class="tip">点击可跳转设备详情</span>
</template>
</card-nav>
<ul class="userDevicesList beautify_scroll">
<li v-for="(item,index) in userDevicesList" :key="index" class="userDevicesItem">
<div class="userDevicesItem_item">
<div class="userDevicesName">
<span>{{ item.label }}</span>
</div>
<el-tag v-if="item.status === 0">在线</el-tag>
<el-tag v-else>离线</el-tag>
</div>
<div class="userDevices_describe">
{{ item.model }} · {{ item.mac }}
</div>
</li>
</ul>
<div class="commonCard">
<card-nav line-color="#14b8a6" title="会话信息"></card-nav>
<ul class="cardUserInfo" style="margin-bottom: 0">
<li class="cardUserInfo_item">
<span class="label">会话 ID</span>
<span class="value">U20260918001</span>
</li>
<li class="cardUserInfo_item">
<span class="label">会话开始时间</span>
<span class="value">2026-09-10 12:44:52</span>
</li>
<li class="cardUserInfo_item">
<span class="label">接入方式</span>
<span class="value">机器人接待 → 转人工</span>
</li>
<li class="cardUserInfo_item">
<span class="label">当前等待时长</span>
<span class="value" style="color: #EA580C">00:38</span>
</li>
</ul>
</div>
</div>
</div>
</el-col>
</el-row>
</div>
</template>
<style lang="scss" scoped>
.Main {
height: 100%;
box-sizing: border-box;
max-height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 40px;
padding-top: 20px;
.cardUserInfoBox {
.cardUserInfo {
margin-bottom: 40px;
min-height: 128px;
margin-top: 27px;
display: flex;
flex-direction: column;
justify-content: space-between;
.cardUserInfo_item {
display: flex;
align-items: center;
justify-content: space-between;
.label {
height: 14px;
line-height: 14px;
color: rgba(107, 114, 128, 1);
font-size: 12px;
}
.value {
height: 16px;
line-height: 16px;
color: rgba(17, 24, 39, 1);
font-size: 13px;
}
}
}
.userDevicesBox {
.titleName {
color: rgba(31, 41, 55, 1);
font-size: 13px;
}
.tip {
line-height: 14px;
color: rgba(156, 163, 175, 1);
font-size: 12px;
}
.userDevicesList {
padding-right: 10px;
min-height: (75+8)*3px;
max-height: (75+8)*3px;
margin-top: 20px;
overflow-y: auto;
.userDevicesItem {
min-height: 64px;
border-radius: 12px 12px 12px 12px;
background-color: rgba(247, 249, 252, 1);
border: 1px solid rgba(238, 240, 243, 1);
padding: 15px 13px;
margin-bottom: 8px;
.userDevicesItem_item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
.userDevicesName {
width: 230px;
overflow: hidden;
text-overflow: ellipsis;
line-height: 16px;
color: rgba(17, 24, 39, 1);
font-size: 13px;
}
}
.userDevices_describe {
line-height: 14px;
color: rgba(156, 163, 175, 1);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
.userWindowBox {
padding: 25px 0;
background-color: #fff;
.userWindowNav.userWindowNavItem {
display: flex;
align-items: center;
padding: 0 21px;
padding-bottom: 19px;
.userAvatar {
width: 41px;
height: 40px;
border-radius: 20px;
background-color: rgba(241, 245, 249, 1);
object-fit: contain;
margin-right: 12px;
}
.userWindowNavItem-l {
flex: 1;
.userWindowNavItem-l-info {
display: flex;
align-items: center;
justify-content: space-between;
.name {
height: 18px;
line-height: 18px;
color: rgba(17, 24, 39, 1);
font-size: 15px;
}
.phone, .time {
line-height: 14px;
color: rgba(156, 163, 175, 1);
font-size: 12px;
}
}
.userWindowNavItem-l-status {
display: flex;
align-items: center;
color: rgba(107, 114, 128, 1);
font-size: 12px;
.circle {
display: block;
width: 7px;
height: 6px;
border-radius: 3px;
margin-right: 6px;
background-color: rgba(22, 163, 74, 1);
}
}
}
}
.content {
height: 650px;
background-color: rgba(247, 248, 250, 1);
padding: 20px;
overflow-x: hidden;
overflow-y: auto;
}
.footer {
padding: 0 21px;
}
}
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="p-2 app-container monitor-online-page">
<Pagecrumb></Pagecrumb>
<Pagecrumb style="margin-top:22px"></Pagecrumb>
<div class="search-wrap">
<el-card class="search-panel" shadow="hover">
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="query-form" label-position="top">
@@ -22,10 +22,8 @@
size="large"
style="width: 150px"
>
<el-option label="机器人" value="0"></el-option>
<el-option label="排队" value="1"></el-option>
<el-option label="人工" value="2"></el-option>
<el-option label="已结束" value="3"></el-option>
<el-option v-for="(item,index) in com_session_state " :key="index" :label="item.label"
:value="item.value"></el-option>
</el-select>
</el-form-item>
<el-form-item label="问题类型" prop="problemType">
@@ -87,10 +85,10 @@
<span style="margin:0 8px;color: #fff;">全部</span>
<span style="color: #fff;font-weight: bold">1286</span>
</el-button>
<tableTag circle-color="#7c3aed" num="862" title="机器人接待"></tableTag>
<tableTag circle-color="#ea580c" num="7" title="排队等待"></tableTag>
<tableTag num="12" title="人工接待"></tableTag>
<tableTag circle-color="#94a3b8" num="405" title="已结束"></tableTag>
<tableTag :num="862" circle-color="#7c3aed" title="机器人接待"></tableTag>
<tableTag :num="7" circle-color="#ea580c" title="排队等待"></tableTag>
<tableTag :num="12" title="人工接待"></tableTag>
<tableTag :num="405" circle-color="#94a3b8" title="已结束"></tableTag>
</div>
<div>
<el-button plain type="info">批量导出</el-button>
@@ -106,41 +104,55 @@
style="width: 100%"
>
<el-table-column align="center" type="selection"></el-table-column>
<el-table-column :show-overflow-tooltip="true" align="center" label="会话ID" prop="tokenId"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="用户信息" prop="userName"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="关联设备" prop="clientKey"/>
<el-table-column align="center" label="会话状态">
<el-table-column :show-overflow-tooltip="true" align="center" label="会话ID" prop="sessionNo"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="用户信息" prop="userNickName"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="关联设备" prop="deviceId"/>
<el-table-column align="center" label="会话状态" width="100">
<template #default="scope">
<el-tag type="primary">在线</el-tag>
<el-tag type="danger">离线</el-tag>
<dict-tag :options="com_session_state" :value="scope.row.status"></dict-tag>
</template>
</el-table-column>
<el-table-column :show-overflow-tooltip="true" align="center" label="当前接待人" prop="deptName"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="用户问题摘要" prop="ipaddr"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="排队序号" prop="loginLocation"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="等待时长" prop="os"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="最后消息时间" prop="browser"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="当前接待人" prop="agentName" width="160">
<template #default="scope">
<div class="name">{{ scope.row.agentName }}</div>
<div class="no"></div>
</template>
</el-table-column>
<el-table-column :show-overflow-tooltip="true" align="center" label="用户问题摘要" prop="summary"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="排队序号" prop="queueNo" width="100"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="等待时长" prop="os" width="100"/>
<el-table-column :show-overflow-tooltip="true" align="center" label="最后消息时间" prop="lastMessageTime"
width="160"/>
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" min-width="200">
<template #default="scope">
<div>
<el-button
v-if="scope.row.staus !== '3'"
v-hasPermi="['cs:session:query']"
type="primary"
@click="handleOpenConversation(scope.row)"
>进入会话
</el-button>
<el-button
v-hasPermi="['monitor:online:forceLogout']"
type="primary"
v-else
@click="handleMain(scope.row)"
>进入会话
>查看会话
</el-button>
<el-button
v-hasPermi="['monitor:online:forceLogout']"
plain
type="info"
@click="handleMain(scope.row)"
@click="handleUserMain(scope.row)"
>用户档案
</el-button>
<el-button
v-hasPermi="['monitor:online:forceLogout']"
plain
type="danger"
@click="handleMain(scope.row)"
v-if="scope.row.status === '2'"
@click="handleCloseConversation(scope.row)"
>关闭会话
</el-button>
</div>
@@ -160,13 +172,13 @@
<script lang="ts" name="Online" setup>
import {to} from 'await-to-js';
import {listCsSession as initData} from '@/api/system/conversation/index';
import {closeCsSession, listCsSession as initData} from '@/api/system/conversation/index';
import {CsSessionQuery, CsSessionVO} from '@/api/system/conversation/types';
import modal from '@/plugins/modal';
import {useDict} from '@/utils/dict';
import {parseTime} from '@/utils/ruoyi';
const {sys_device_type} = toRefs<any>(useDict('sys_device_type'));
const {com_session_state} = toRefs<any>(useDict('com_session_state'));
const router = useRouter();
@@ -202,7 +214,35 @@ const resetQuery = () => {
handleQuery();
};
function handleMain(row) {
// 用户 档案
function handleUserMain(row: Partial<CsSessionVO>) {
router.push({
path: '/customer/customerMain',
query: {
id: row.userId,
}
})
}
// 进入会话
function handleOpenConversation(row: Partial<CsSessionVO>) {
router.push({
path: '/conversation/conversationMain',
query: {
id: row.sessionId,
}
})
}
// close conversation
function handleCloseConversation(row: Partial<CsSessionVO>) {
closeCsSession(row.sessionId).then(() => {
getList();
modal.msgSuccess('关闭会话成功')
})
}
function handleMain(row: Partial<CsSessionVO>) {
console.log(row);
router.push({
path: '/conversation/conversationMain'

View File

@@ -0,0 +1,408 @@
<script lang="ts" setup>
import pageCrumb from '@/components/Pagecrumb/index.vue'
import ChatNavBar from "./compontents/ChatNavBar.vue";
import {ChatNavBarType} from "./compontents/types";
import MessageList from './compontents/MessageList/index.vue'
import {initMessages, userAvatar} from "./compontents/data";
import ChatInput from "@/views/system/conversation/compontents/ChatInput.vue";
const defaultImage = import.meta.env.VITE_DEFAULT_USER_IMAGE;
const userDevicesList = [
{
label: 'Z2 空气净化器 · 客厅',
model: 'CZ-Z2023',
mac: "AA:BB:CC:DD:A3:B6",
status: 0,
},
{
label: 'Z2 空气净化器 · 主卧',
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,
agentInfo: {
no: '8823',
name: '小雨'
},
message: []
})
onMounted(() => {
form.value.message = [...initMessages]
})
const sending = ref(false)
// 发送文本消息
const onSendMsg = async (text: string) => {
sending.value = true
// 组装消息,推入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)
}
//
</script>
<template>
<div class="Main beautify_scroll">
<pageCrumb>
<template #rightSlot>
<el-button plain type="info">返回会话列表</el-button>
</template>
</pageCrumb>
<div class="commonCard">
<card-nav
:show-line="false"
subTitle="开始时间 2026-09-10 12:44:52 · 等待时长 00:38 · 接入方式:机器人接待 → 转人工"
title="CS20260918100038"
>
<template #rightTitle>
<el-tag type="primary">人工接待</el-tag>
</template>
<template #rightSlot>
<el-button plain type="default">导出聊天记录</el-button>
<el-button plain type="danger">关闭会话</el-button>
</template>
</card-nav>
</div>
<el-row :gutter="20">
<el-col :lg="16" :md="15" :span="24" :xl="18">
<div class="commonCard userWindowBox">
<div class="userWindowNav userWindowNavItem">
<el-image :src="defaultImage" class="userAvatar"></el-image>
<div class="userWindowNavItem-l">
<div class="userWindowNavItem-l-info">
<div>
<span class="name">林筱曼</span>
<span class="phone">138****6688</span>
</div>
<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 class="content beautify_scroll">
<ChatNavBar
:agent-info="form.agentInfo"
:await-num="form.awaitNum"
:time="form.time"
:type="form.status"
></ChatNavBar>
<MessageList :messages="form.message" :user-avatar="userAvatar"></MessageList>
</div>
<div class="footer">
<!-- 输入框组件 -->
<ChatInput :sending="sending" @send="onSendMsg" @uploadImage="onUploadImage"/>
</div>
</div>
</el-col>
<el-col :lg="8" :md="9" :span="24" :xl="6">
<div class="commonCard cardUserInfoBox">
<card-nav title="用户信息">
<template slot="rightTitle">
<el-tag type="primary">用户详情</el-tag>
</template>
</card-nav>
<ul class="cardUserInfo">
<li class="cardUserInfo_item">
<span class="label">用户ID</span>
<span class="value">U20260918001</span>
</li>
<li class="cardUserInfo_item">
<span class="label">昵称</span>
<span class="value">林小满</span>
</li>
<li class="cardUserInfo_item">
<span class="label">手机号</span>
<span class="value">138****6688</span>
</li>
<li class="cardUserInfo_item">
<span class="label">注册时间</span>
<span class="value">2026-01-10 14:20</span>
</li>
</ul>
<div class="userDevicesBox">
<card-nav :show-line="false">
<template #title>
<span class="titleName">名下设备(3台)</span>
</template>
<template #rightSlot>
<span class="tip">点击可跳转设备详情</span>
</template>
</card-nav>
<ul class="userDevicesList beautify_scroll">
<li v-for="(item,index) in userDevicesList" :key="index" class="userDevicesItem">
<div class="userDevicesItem_item">
<div class="userDevicesName">
<span>{{ item.label }}</span>
</div>
<el-tag v-if="item.status === 0">在线</el-tag>
<el-tag v-else>离线</el-tag>
</div>
<div class="userDevices_describe">
{{ item.model }} · {{ item.mac }}
</div>
</li>
</ul>
<div class="commonCard">
<card-nav line-color="#14b8a6" title="会话信息"></card-nav>
<ul class="cardUserInfo" style="margin-bottom: 0">
<li class="cardUserInfo_item">
<span class="label">会话 ID</span>
<span class="value">U20260918001</span>
</li>
<li class="cardUserInfo_item">
<span class="label">会话开始时间</span>
<span class="value">2026-09-10 12:44:52</span>
</li>
<li class="cardUserInfo_item">
<span class="label">接入方式</span>
<span class="value">机器人接待 → 转人工</span>
</li>
<li class="cardUserInfo_item">
<span class="label">当前等待时长</span>
<span class="value" style="color: #EA580C">00:38</span>
</li>
</ul>
</div>
</div>
</div>
</el-col>
</el-row>
</div>
</template>
<style lang="scss" scoped>
.Main {
height: 100%;
box-sizing: border-box;
max-height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 40px;
padding-top: 20px;
.cardUserInfoBox {
.cardUserInfo {
margin-bottom: 40px;
min-height: 128px;
margin-top: 27px;
display: flex;
flex-direction: column;
justify-content: space-between;
.cardUserInfo_item {
display: flex;
align-items: center;
justify-content: space-between;
.label {
height: 14px;
line-height: 14px;
color: rgba(107, 114, 128, 1);
font-size: 12px;
}
.value {
height: 16px;
line-height: 16px;
color: rgba(17, 24, 39, 1);
font-size: 13px;
}
}
}
.userDevicesBox {
.titleName {
color: rgba(31, 41, 55, 1);
font-size: 13px;
}
.tip {
line-height: 14px;
color: rgba(156, 163, 175, 1);
font-size: 12px;
}
.userDevicesList {
padding-right: 10px;
min-height: (75+8)*3px;
max-height: (75+8)*3px;
margin-top: 20px;
overflow-y: auto;
.userDevicesItem {
min-height: 64px;
border-radius: 12px 12px 12px 12px;
background-color: rgba(247, 249, 252, 1);
border: 1px solid rgba(238, 240, 243, 1);
padding: 15px 13px;
margin-bottom: 8px;
.userDevicesItem_item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
.userDevicesName {
width: 230px;
overflow: hidden;
text-overflow: ellipsis;
line-height: 16px;
color: rgba(17, 24, 39, 1);
font-size: 13px;
}
}
.userDevices_describe {
line-height: 14px;
color: rgba(156, 163, 175, 1);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
}
.userWindowBox {
padding: 25px 0;
background-color: #fff;
.userWindowNav.userWindowNavItem {
display: flex;
align-items: center;
padding: 0 21px;
padding-bottom: 19px;
.userAvatar {
width: 41px;
height: 40px;
border-radius: 20px;
background-color: rgba(241, 245, 249, 1);
object-fit: contain;
margin-right: 12px;
}
.userWindowNavItem-l {
flex: 1;
.userWindowNavItem-l-info {
display: flex;
align-items: center;
justify-content: space-between;
.name {
height: 18px;
line-height: 18px;
color: rgba(17, 24, 39, 1);
font-size: 15px;
}
.phone, .time {
line-height: 14px;
color: rgba(156, 163, 175, 1);
font-size: 12px;
}
}
.userWindowNavItem-l-status {
display: flex;
align-items: center;
color: rgba(107, 114, 128, 1);
font-size: 12px;
.circle {
display: block;
width: 7px;
height: 6px;
border-radius: 3px;
margin-right: 6px;
background-color: rgba(22, 163, 74, 1);
}
}
}
}
.content {
height: 650px;
background-color: rgba(247, 248, 250, 1);
padding: 20px;
overflow-x: hidden;
overflow-y: auto;
}
.footer {
padding: 0 21px;
}
}
}
</style>