chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端 AI 助手面板容器
|
||||
// =============================================================================
|
||||
// 说明:AI 助手面板的容器组件,使用 Vant4 Tab 组件
|
||||
// 4个Tab:相似问题 | 审批流程 | 软件下载 | 搜索
|
||||
// 右上角展开/收起按钮(移动端)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="ai-helper-panel">
|
||||
<!-- 面板头部:标题 + 收起按钮 -->
|
||||
<div class="ai-helper-panel__header">
|
||||
<span class="ai-helper-panel__title">AI 助手</span>
|
||||
<!-- 移动端收起按钮 -->
|
||||
<van-icon
|
||||
name="cross"
|
||||
size="18"
|
||||
color="var(--text-tertiary)"
|
||||
class="ai-helper-panel__close"
|
||||
@click="handleClose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换区域 -->
|
||||
<van-tabs v-model:active="activeTab" sticky animated swipeable>
|
||||
<!-- Tab1: 相似问题(暂未实现,显示占位符) -->
|
||||
<van-tab title="相似问题">
|
||||
<ComingSoon title="相似问题与做法" />
|
||||
</van-tab>
|
||||
|
||||
<!-- Tab2: 审批流程 -->
|
||||
<van-tab title="审批流程">
|
||||
<ApprovalLinks />
|
||||
</van-tab>
|
||||
|
||||
<!-- Tab3: 软件下载 -->
|
||||
<van-tab title="软件下载">
|
||||
<SoftwareDownloads />
|
||||
</van-tab>
|
||||
|
||||
<!-- Tab4: 搜索(暂未实现,显示占位符) -->
|
||||
<van-tab title="搜索">
|
||||
<ComingSoon title="知识库搜索" />
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AiHelperPanel AI 助手面板容器
|
||||
* 4个Tab模块:
|
||||
* - 相似问题:占位符"即将上线"
|
||||
* - 审批流程:真实功能(从 API 获取数据)
|
||||
* - 软件下载:真实功能(从 API 获取数据)
|
||||
* - 搜索:占位符"即将上线"
|
||||
* 移动端可通过右上角按钮收起面板
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import ComingSoon from './ComingSoon.vue'
|
||||
import ApprovalLinks from './ApprovalLinks.vue'
|
||||
import SoftwareDownloads from './SoftwareDownloads.vue'
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 当前激活的 Tab 索引(0=相似问题, 1=审批流程, 2=软件下载, 3=搜索) */
|
||||
const activeTab = ref<number>(1) // 默认显示审批流程
|
||||
|
||||
/**
|
||||
* 收起面板(移动端使用)
|
||||
* 通知 store 切换面板可见性
|
||||
*/
|
||||
function handleClose(): void {
|
||||
store.toggleAssistantPanel()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* AI 助手面板容器 */
|
||||
.ai-helper-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 面板头部 */
|
||||
.ai-helper-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 面板标题 */
|
||||
.ai-helper-panel__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 收起按钮 */
|
||||
.ai-helper-panel__close {
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Tab 区域占满剩余空间 */
|
||||
.ai-helper-panel :deep(.van-tabs) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ai-helper-panel :deep(.van-tabs__content) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ai-helper-panel :deep(.van-tab__panel) {
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端审批流程链接组件
|
||||
// =============================================================================
|
||||
// 说明:展示所有审批流程链接,按分类分组
|
||||
// 使用 Vant4 CellGroup + Cell 组件
|
||||
// 点击链接在企微内置浏览器中打开
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="approval-links">
|
||||
<!-- 加载中提示 -->
|
||||
<div v-if="loading" class="approval-links__loading">
|
||||
<van-loading size="24px" vertical>加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<div v-else-if="Object.keys(groupedLinks).length === 0" class="approval-links__empty">
|
||||
<van-empty description="暂无审批流程" image="search" />
|
||||
</div>
|
||||
|
||||
<!-- 按分类展示审批链接 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(links, category) in groupedLinks"
|
||||
:key="category"
|
||||
class="approval-links__group"
|
||||
>
|
||||
<!-- 分类标题 -->
|
||||
<div class="approval-links__category">{{ category }}</div>
|
||||
<!-- 该分类下的链接列表 -->
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
:title="link.title"
|
||||
:icon="link.icon || 'link-o'"
|
||||
is-link
|
||||
@click="openLink(link.url)"
|
||||
>
|
||||
<!-- 右侧箭头 -->
|
||||
<template #right-icon>
|
||||
<van-icon name="arrow" />
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ApprovalLinks 审批流程链接组件
|
||||
* 从 API 获取审批流程数据,按分类分组展示
|
||||
* 点击链接在企微内置浏览器中打开
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 加载状态:当审批链接列表为空且未初始化时视为加载中 */
|
||||
const loading = computed(() => {
|
||||
return store.approvalLinks.length === 0 && !store.initialized
|
||||
})
|
||||
|
||||
/** 审批链接按分类分组(从 store 计算属性获取) */
|
||||
const groupedLinks = computed(() => store.approvalLinksByCategory)
|
||||
|
||||
/**
|
||||
* 打开审批流程链接
|
||||
* 在企微内置浏览器中打开目标链接
|
||||
* @param url 审批流程链接地址
|
||||
*/
|
||||
function openLink(url: string): void {
|
||||
// 在企微 WebView 中直接使用 window.open 即可在内置浏览器中打开
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 审批链接容器 */
|
||||
.approval-links {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 加载中状态 */
|
||||
.approval-links__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* 空数据状态 */
|
||||
.approval-links__empty {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 分类分组容器 */
|
||||
.approval-links__group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 分类标题 */
|
||||
.approval-links__category {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 8px 16px 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端"即将上线"占位组件
|
||||
// =============================================================================
|
||||
// 说明:通用占位组件,用于尚未实现的功能模块
|
||||
// 接收 title prop,显示灰色图标 + "{title} · 即将上线"
|
||||
// 用于"相似问题与做法"和"知识库搜索"两个模块
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="coming-soon">
|
||||
<!-- 灰色占位图标 -->
|
||||
<div class="coming-soon__icon">
|
||||
<van-icon name="clock-o" size="48" color="var(--text-placeholder)" />
|
||||
</div>
|
||||
<!-- 模块名称 + 即将上线提示 -->
|
||||
<p class="coming-soon__text">{{ title }} · 即将上线</p>
|
||||
<p class="coming-soon__subtext">功能开发中,敬请期待</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ComingSoon 占位组件
|
||||
* @prop title - 模块名称(如"相似问题与做法"、"知识库搜索")
|
||||
*/
|
||||
|
||||
defineProps<{
|
||||
/** 模块名称,显示在"即将上线"前 */
|
||||
title: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 占位容器:居中布局 */
|
||||
.coming-soon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
/* 灰色图标 */
|
||||
.coming-soon__icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 主提示文字:模块名 + 即将上线 */
|
||||
.coming-soon__text {
|
||||
font-size: 15px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 副提示文字 */
|
||||
.coming-soon__subtext {
|
||||
font-size: 12px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,629 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端右侧面板
|
||||
// =============================================================================
|
||||
// 说明:桌面端右侧面板,三段式布局:
|
||||
// 1. 上方:AI推送区(根据排查步骤和会话内容动态推送)
|
||||
// 2. 中部:固定常用资源标签页(资源申请流程入口、常用必装软件)
|
||||
// 3. 下方:趣味问答(答对可提高用户积分和等级)
|
||||
// 注意:此面板仅在桌面端(≥500px)显示,手机端隐藏
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="right-panel">
|
||||
<!-- ====== 上方:AI推送区 ====== -->
|
||||
<div class="right-panel__section right-panel__ai-push">
|
||||
<div class="right-panel__section-header">
|
||||
<span class="right-panel__section-icon">🤖</span>
|
||||
<span class="right-panel__section-title">AI 推荐</span>
|
||||
</div>
|
||||
<div class="right-panel__section-body">
|
||||
<!-- 推荐卡片列表(根据排查步骤和会话内容动态推送) -->
|
||||
<div
|
||||
v-for="item in aiPushItems"
|
||||
:key="item.id"
|
||||
class="ai-push-card"
|
||||
:class="`ai-push-card--${item.type}`"
|
||||
@click="handlePushClick(item)"
|
||||
>
|
||||
<div class="ai-push-card__header">
|
||||
<span class="ai-push-card__icon">{{ item.icon }}</span>
|
||||
<span class="ai-push-card__type-label">{{ item.typeLabel }}</span>
|
||||
</div>
|
||||
<div class="ai-push-card__title">{{ item.title }}</div>
|
||||
<div v-if="item.subtitle" class="ai-push-card__subtitle">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
<!-- 暂无推荐 -->
|
||||
<div v-if="aiPushItems.length === 0" class="right-panel__empty">
|
||||
<span>💡 对话过程中会自动推送相关资源</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 中部:常用资源标签页 ====== -->
|
||||
<div class="right-panel__section right-panel__resources">
|
||||
<div class="right-panel__section-header">
|
||||
<span class="right-panel__section-icon">📚</span>
|
||||
<span class="right-panel__section-title">常用资源</span>
|
||||
</div>
|
||||
<!-- 标签切换 -->
|
||||
<div class="right-panel__tabs">
|
||||
<button
|
||||
class="right-panel__tab"
|
||||
:class="{ 'right-panel__tab--active': activeResourceTab === 'process' }"
|
||||
@click="activeResourceTab = 'process'"
|
||||
>申请流程</button>
|
||||
<button
|
||||
class="right-panel__tab"
|
||||
:class="{ 'right-panel__tab--active': activeResourceTab === 'software' }"
|
||||
@click="activeResourceTab = 'software'"
|
||||
>必装软件</button>
|
||||
</div>
|
||||
<!-- 申请流程标签页内容 -->
|
||||
<div v-if="activeResourceTab === 'process'" class="right-panel__tab-content">
|
||||
<div
|
||||
v-for="item in processItems"
|
||||
:key="item.id"
|
||||
class="resource-item"
|
||||
@click="handleProcessClick(item)"
|
||||
>
|
||||
<span class="resource-item__icon">{{ item.icon }}</span>
|
||||
<div class="resource-item__info">
|
||||
<span class="resource-item__title">{{ item.title }}</span>
|
||||
<span v-if="item.desc" class="resource-item__desc">{{ item.desc }}</span>
|
||||
</div>
|
||||
<span class="resource-item__arrow">→</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 必装软件标签页内容 -->
|
||||
<div v-if="activeResourceTab === 'software'" class="right-panel__tab-content">
|
||||
<div
|
||||
v-for="item in softwareItems"
|
||||
:key="item.id"
|
||||
class="resource-item"
|
||||
@click="handleSoftwareClick(item)"
|
||||
>
|
||||
<span class="resource-item__icon">{{ item.icon }}</span>
|
||||
<div class="resource-item__info">
|
||||
<span class="resource-item__title">{{ item.title }}</span>
|
||||
<span v-if="item.desc" class="resource-item__desc">{{ item.desc }}</span>
|
||||
</div>
|
||||
<span class="resource-item__arrow">→</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 下方:趣味问答 ====== -->
|
||||
<div class="right-panel__section right-panel__quiz">
|
||||
<div class="right-panel__section-header">
|
||||
<span class="right-panel__section-icon">🎯</span>
|
||||
<span class="right-panel__section-title">趣味问答</span>
|
||||
<span class="right-panel__quiz-score">🏆 {{ userScore }}分</span>
|
||||
</div>
|
||||
<div class="right-panel__section-body">
|
||||
<template v-if="currentQuiz">
|
||||
<div class="quiz-question">{{ currentQuiz.question }}</div>
|
||||
<div class="quiz-options">
|
||||
<button
|
||||
v-for="(option, idx) in currentQuiz.options"
|
||||
:key="idx"
|
||||
class="quiz-option"
|
||||
:class="{
|
||||
'quiz-option--correct': quizAnswered && idx === currentQuiz.correctIndex,
|
||||
'quiz-option--wrong': quizAnswered && quizSelectedIndex === idx && idx !== currentQuiz.correctIndex
|
||||
}"
|
||||
:disabled="quizAnswered"
|
||||
@click="handleQuizAnswer(idx)"
|
||||
>
|
||||
<span class="quiz-option__label">{{ optionLabels[idx] }}</span>
|
||||
<span class="quiz-option__text">{{ option }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 答题结果 -->
|
||||
<div v-if="quizAnswered" class="quiz-result">
|
||||
<span v-if="quizSelectedIndex === currentQuiz.correctIndex" class="quiz-result--correct">
|
||||
✅ 答对啦!+10积分
|
||||
</span>
|
||||
<span v-else class="quiz-result--wrong">
|
||||
❌ 答错了!正确答案是 {{ optionLabels[currentQuiz.correctIndex] }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="right-panel__empty">
|
||||
<span>暂无问答题目</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* RightPanel 右侧面板组件
|
||||
* 三段式布局:AI推送区 / 常用资源标签页 / 趣味问答
|
||||
* 仅在桌面端(≥500px)显示
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
// 阶段二接入 Dify 动态推送时启用:
|
||||
// import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
// const store = useConversationStore() // 阶段二接入 Dify 动态推送时启用
|
||||
|
||||
// ── AI推送区 ──
|
||||
|
||||
/** AI推送条目类型 */
|
||||
interface AiPushItem {
|
||||
id: string
|
||||
type: 'guide' | 'process' | 'download' // 处理指南/申请流程/软件下载
|
||||
icon: string
|
||||
typeLabel: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
/** AI推送数据(阶段一使用静态数据,阶段二接入Dify动态推送) */
|
||||
const aiPushItems = computed<AiPushItem[]>(() => {
|
||||
// TODO: 阶段二根据排查步骤和会话内容动态生成推送
|
||||
// 当前使用示例数据
|
||||
return [
|
||||
{
|
||||
id: 'push-1',
|
||||
type: 'guide',
|
||||
icon: '📖',
|
||||
typeLabel: '处理指南',
|
||||
title: 'WiFi连接问题处理指南',
|
||||
subtitle: '已解决28次类似问题',
|
||||
},
|
||||
{
|
||||
id: 'push-2',
|
||||
type: 'process',
|
||||
icon: '📋',
|
||||
typeLabel: '申请流程',
|
||||
title: '网络连接申请流程',
|
||||
subtitle: '在线申请,1-3个工作日',
|
||||
},
|
||||
{
|
||||
id: 'push-3',
|
||||
type: 'download',
|
||||
icon: '💾',
|
||||
typeLabel: '软件下载',
|
||||
title: '无线网卡驱动下载',
|
||||
subtitle: '适用于 Windows 10/11',
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* 点击AI推送卡片
|
||||
*/
|
||||
function handlePushClick(item: AiPushItem): void {
|
||||
// TODO: 阶段二实现推送跳转
|
||||
console.log('[RightPanel] AI推送点击:', item.title)
|
||||
}
|
||||
|
||||
// ── 常用资源标签页 ──
|
||||
|
||||
/** 当前激活的资源标签页 */
|
||||
const activeResourceTab = ref<'process' | 'software'>('process')
|
||||
|
||||
/** 申请流程列表 */
|
||||
const processItems = ref([
|
||||
{ id: 'p-1', icon: '💻', title: 'IT设备申请', desc: '电脑/显示器/外设' },
|
||||
{ id: 'p-2', icon: '🔐', title: '权限申请', desc: '系统/文件夹/VPN' },
|
||||
{ id: 'p-3', icon: '🌐', title: 'VPN申请', desc: '远程办公网络' },
|
||||
{ id: 'p-4', icon: '📧', title: '邮箱别名申请', desc: '别名/分发组' },
|
||||
])
|
||||
|
||||
/** 必装软件列表 */
|
||||
const softwareItems = ref([
|
||||
{ id: 's-1', icon: '📝', title: 'Office 365', desc: 'Word/Excel/PPT' },
|
||||
{ id: 's-2', icon: '📄', title: 'Adobe Acrobat', desc: 'PDF阅读/编辑' },
|
||||
{ id: 's-3', icon: '💬', title: '企业微信', desc: '即时通讯/协作' },
|
||||
{ id: 's-4', icon: '🛡️', title: '火绒安全', desc: '杀毒/终端防护' },
|
||||
])
|
||||
|
||||
/**
|
||||
* 点击申请流程项
|
||||
*/
|
||||
function handleProcessClick(item: { id: string; title: string }): void {
|
||||
// TODO: 阶段二实现流程跳转
|
||||
console.log('[RightPanel] 申请流程点击:', item.title)
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击软件下载项
|
||||
*/
|
||||
function handleSoftwareClick(item: { id: string; title: string }): void {
|
||||
// TODO: 阶段二实现软件下载
|
||||
console.log('[RightPanel] 软件下载点击:', item.title)
|
||||
}
|
||||
|
||||
// ── 趣味问答 ──
|
||||
|
||||
/** 问答题目类型 */
|
||||
interface QuizQuestion {
|
||||
id: string
|
||||
question: string
|
||||
options: string[]
|
||||
correctIndex: number // 正确答案的索引
|
||||
}
|
||||
|
||||
/** 选项标签 */
|
||||
const optionLabels = ['A', 'B', 'C', 'D']
|
||||
|
||||
/** 用户积分 */
|
||||
const userScore = ref(0)
|
||||
|
||||
/** 是否已答题 */
|
||||
const quizAnswered = ref(false)
|
||||
|
||||
/** 用户选择的答案索引 */
|
||||
const quizSelectedIndex = ref(-1)
|
||||
|
||||
/** 问答题目列表(阶段一使用静态数据) */
|
||||
const quizQuestions = ref<QuizQuestion[]>([
|
||||
{
|
||||
id: 'q-1',
|
||||
question: 'IT服务台电话分机号是?',
|
||||
options: ['8001', '8002', '8003', '8004'],
|
||||
correctIndex: 0,
|
||||
},
|
||||
{
|
||||
id: 'q-2',
|
||||
question: '电脑无法连接WiFi时,首先应该检查什么?',
|
||||
options: ['重启路由器', 'WiFi适配器是否禁用', '联系网络管理员', '重新安装系统'],
|
||||
correctIndex: 1,
|
||||
},
|
||||
{
|
||||
id: 'q-3',
|
||||
question: 'VPN申请一般需要几个工作日?',
|
||||
options: ['1个工作日', '1-3个工作日', '3-5个工作日', '5个工作日以上'],
|
||||
correctIndex: 1,
|
||||
},
|
||||
])
|
||||
|
||||
/** 当前问答题目 */
|
||||
const currentQuiz = computed<QuizQuestion | null>(() => {
|
||||
return quizQuestions.value[0] || null
|
||||
})
|
||||
|
||||
/**
|
||||
* 用户回答问答
|
||||
* @param index - 用户选择的选项索引
|
||||
*/
|
||||
function handleQuizAnswer(index: number): void {
|
||||
if (quizAnswered.value) return
|
||||
quizAnswered.value = true
|
||||
quizSelectedIndex.value = index
|
||||
|
||||
// 答对加10分
|
||||
if (currentQuiz.value && index === currentQuiz.value.correctIndex) {
|
||||
userScore.value += 10
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ====== 右侧面板容器 ====== */
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ====== 面板区域通用样式 ====== */
|
||||
.right-panel__section {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.right-panel__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.right-panel__section-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.right-panel__section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.right-panel__section-body {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.right-panel__empty {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
/* ====== AI推送区 ====== */
|
||||
.right-panel__ai-push {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ai-push-card {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ai-push-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ai-push-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.ai-push-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.ai-push-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ai-push-card__icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ai-push-card__type-label {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 处理指南类型 */
|
||||
.ai-push-card--guide .ai-push-card__type-label {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* 申请流程类型 */
|
||||
.ai-push-card--process .ai-push-card__type-label {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 软件下载类型 */
|
||||
.ai-push-card--download .ai-push-card__type-label {
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.ai-push-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ai-push-card__subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ====== 常用资源标签页 ====== */
|
||||
.right-panel__resources {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.right-panel__tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.right-panel__tab {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.right-panel__tab--active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.right-panel__tab:hover:not(.right-panel__tab--active) {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.right-panel__tab-content {
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.resource-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.resource-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.resource-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.resource-item:active {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.resource-item__icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resource-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resource-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.resource-item__desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.resource-item__arrow {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ====== 趣味问答 ====== */
|
||||
.right-panel__quiz {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.right-panel__quiz-score {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--color-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quiz-question {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.quiz-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.quiz-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quiz-option:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.quiz-option:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.quiz-option:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 正确选项 */
|
||||
.quiz-option--correct {
|
||||
border-color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
|
||||
/* 错误选项 */
|
||||
.quiz-option--wrong {
|
||||
border-color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.quiz-option__label {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quiz-option--correct .quiz-option__label {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.quiz-option--wrong .quiz-option__label {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.quiz-option__text {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quiz-result {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quiz-result--correct {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.quiz-result--wrong {
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端软件下载入口组件
|
||||
// =============================================================================
|
||||
// 说明:展示所有可下载的软件,按分类分组
|
||||
// 显示名称 + 版本号 + 平台标签
|
||||
// 点击下载链接直接下载
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="software-downloads">
|
||||
<!-- 加载中提示 -->
|
||||
<div v-if="loading" class="software-downloads__loading">
|
||||
<van-loading size="24px" vertical>加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<!-- 无数据提示 -->
|
||||
<div v-else-if="Object.keys(groupedDownloads).length === 0" class="software-downloads__empty">
|
||||
<van-empty description="暂无软件下载" image="search" />
|
||||
</div>
|
||||
|
||||
<!-- 按分类展示软件下载列表 -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(items, category) in groupedDownloads"
|
||||
:key="category"
|
||||
class="software-downloads__group"
|
||||
>
|
||||
<!-- 分类标题 -->
|
||||
<div class="software-downloads__category">{{ category }}</div>
|
||||
<!-- 该分类下的软件列表 -->
|
||||
<van-cell-group inset>
|
||||
<van-cell
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:label="versionLabel(item)"
|
||||
:icon="item.icon || 'down'"
|
||||
is-link
|
||||
@click="handleDownload(item)"
|
||||
>
|
||||
<!-- 右侧平台标签 -->
|
||||
<template #right-icon>
|
||||
<div class="software-downloads__platforms">
|
||||
<span
|
||||
v-for="platform in item.platforms"
|
||||
:key="platform"
|
||||
class="software-downloads__tag"
|
||||
>
|
||||
{{ platform }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SoftwareDownloads 软件下载入口组件
|
||||
* 从 API 获取软件下载数据,按分类分组展示
|
||||
* 显示名称、版本号、平台标签,点击下载
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import type { SoftwareDownload } from '@/api/conversation'
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 加载状态:当软件下载列表为空且未初始化时视为加载中 */
|
||||
const loading = computed(() => {
|
||||
return store.softwareDownloads.length === 0 && !store.initialized
|
||||
})
|
||||
|
||||
/** 软件下载按分类分组(从 store 计算属性获取) */
|
||||
const groupedDownloads = computed(() => store.softwareDownloadsByCategory)
|
||||
|
||||
/**
|
||||
* 生成版本号标签文字
|
||||
* @param item 软件下载项
|
||||
* @returns 版本号文字(如 "v2.1.0")
|
||||
*/
|
||||
function versionLabel(item: SoftwareDownload): string {
|
||||
return item.version ? `版本: ${item.version}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理下载点击
|
||||
* 在企微内置浏览器中打开下载链接
|
||||
* @param item 软件下载项
|
||||
*/
|
||||
function handleDownload(item: SoftwareDownload): void {
|
||||
window.open(item.download_url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 软件下载容器 */
|
||||
.software-downloads {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 加载中状态 */
|
||||
.software-downloads__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* 空数据状态 */
|
||||
.software-downloads__empty {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* 分类分组容器 */
|
||||
.software-downloads__group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 分类标题 */
|
||||
.software-downloads__category {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 8px 16px 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 平台标签容器 */
|
||||
.software-downloads__platforms {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 单个平台标签 */
|
||||
.software-downloads__tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background-color: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,732 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — H5用户端「呼叫坐席」动画弹窗
|
||||
=============================================================================
|
||||
流程(简化版):
|
||||
1. 按钮出现后点击 → 弹出此弹窗
|
||||
2. 立即随机播放趣味动画 + 话术,同时发 shake 请求
|
||||
3. 发送成功 → 显示成功提示,3秒后自动关闭
|
||||
|
||||
注意:问题描述已在聊天中完成(AI 实质性回复 >= 3 轮),弹窗不需要再录入。
|
||||
|
||||
七种动画场景(随机选一种):
|
||||
1. 🙋 招手 — "看这里!看我这里...我有个问题!"
|
||||
2. 🪑 拍桌子 — "快快快!我等不及了!"
|
||||
3. 💀 劈稻草人 — "这个问题不解决我就要原地爆炸了💥"
|
||||
4. 🍉 砍西瓜 — "IT!救我!这个问题卡住了🍉"
|
||||
5. 🔔 摇铃铛 — "叮叮叮!有人吗!IT 在线吗!"
|
||||
6. 💣 大炮发射 — "开炮!这个问题必须解决了!"
|
||||
7. 🚀 导弹发射 — "发射!紧急呼叫 IT 特种部队!"
|
||||
============================================================================= -->
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div v-if="visible" class="call-modal__overlay" @click.self="handleClose">
|
||||
<Transition name="modal-zoom" appear>
|
||||
<div class="call-modal call-modal--compact" v-if="visible">
|
||||
|
||||
<!-- ========== 动画场景 + 话术 ========== -->
|
||||
<div class="call-modal__step">
|
||||
<div class="call-modal__header">
|
||||
<span class="call-modal__icon">🔔</span>
|
||||
<h3>摇传菜铃呼叫人工坐席...</h3>
|
||||
</div>
|
||||
<div class="call-modal__body call-modal__body--center">
|
||||
|
||||
<!-- 场景SVG动画区域(根据 selectedScene 渲染对应场景) -->
|
||||
|
||||
<!-- 场景1:招手 🙋 -->
|
||||
<div v-if="selectedScene === 1" class="scene scene--hand">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="140" width="200" height="40" fill="#E8EAF6"/>
|
||||
<rect x="45" y="75" width="110" height="65" rx="10" fill="#ECEFF1"/>
|
||||
<rect x="50" y="80" width="100" height="55" rx="8" fill="#CFD8DC"/>
|
||||
<rect x="55" y="85" width="40" height="25" rx="3" fill="#90CAF9"/>
|
||||
<rect x="100" y="85" width="40" height="25" rx="3" fill="#90CAF9"/>
|
||||
<circle cx="135" cy="70" r="16" fill="#F5C6A0"/>
|
||||
<circle cx="130" cy="67" r="2" fill="#333"/>
|
||||
<circle cx="140" cy="67" r="2" fill="#333"/>
|
||||
<path d="M130 75 Q135 79 140 75" stroke="#333" stroke-width="1.5" fill="none"/>
|
||||
<rect x="12" y="72" width="30" height="50" rx="8" fill="#F5C6A0"/>
|
||||
<g class="hand-arm">
|
||||
<line x1="42" y1="80" x2="55" y2="55" stroke="#F5C6A0" stroke-width="8" stroke-linecap="round"/>
|
||||
</g>
|
||||
<g class="bubble-float">
|
||||
<rect x="55" y="36" width="100" height="24" rx="12" fill="#FF9800"/>
|
||||
<text x="105" y="52" text-anchor="middle" fill="white" font-size="11" font-weight="bold">看这里!🙋</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景2:拍桌子 🪑 -->
|
||||
<div v-else-if="selectedScene === 2" class="scene scene--pound">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="140" width="200" height="40" fill="#D7CCC8"/>
|
||||
<rect x="30" y="105" width="140" height="35" rx="6" fill="#8D6E63"/>
|
||||
<rect x="35" y="110" width="130" height="25" rx="4" fill="#A1887F"/>
|
||||
<rect x="20" y="75" width="35" height="50" rx="8" fill="#795548"/>
|
||||
<circle cx="37" cy="60" r="16" fill="#F5C6A0"/>
|
||||
<g class="pound-fists">
|
||||
<circle cx="75" cy="98" r="9" fill="#F5C6A0"/>
|
||||
<circle cx="105" cy="98" r="9" fill="#F5C6A0"/>
|
||||
</g>
|
||||
<g class="splash-lines">
|
||||
<line x1="68" y1="90" x2="62" y2="82" stroke="#FF5722" stroke-width="2"/>
|
||||
<line x1="80" y1="88" x2="80" y2="78" stroke="#FF5722" stroke-width="2"/>
|
||||
<line x1="90" y1="88" x2="95" y2="78" stroke="#FF5722" stroke-width="2"/>
|
||||
<line x1="110" y1="90" x2="110" y2="80" stroke="#FF5722" stroke-width="2"/>
|
||||
<line x1="120" y1="90" x2="126" y2="82" stroke="#FF5722" stroke-width="2"/>
|
||||
</g>
|
||||
<g class="bubble-float">
|
||||
<rect x="90" y="36" width="100" height="24" rx="12" fill="#FF5722"/>
|
||||
<text x="140" y="52" text-anchor="middle" fill="white" font-size="11" font-weight="bold">快快快!🪑</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景3-7略(同原版,保留全部7个场景的SVG) -->
|
||||
<!-- 场景3:劈稻草人 💀 -->
|
||||
<div v-else-if="selectedScene === 3" class="scene scene--scarecrow">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="140" width="200" height="40" fill="#C8E6C9"/>
|
||||
<rect x="145" y="100" width="6" height="40" fill="#795548"/>
|
||||
<rect x="133" y="95" width="30" height="5" rx="2" fill="#795548"/>
|
||||
<circle cx="148" cy="85" r="14" fill="#FFE082"/>
|
||||
<line x1="148" y1="74" x2="148" y2="70" stroke="#333" stroke-width="1.5"/>
|
||||
<rect x="25" y="72" width="30" height="50" rx="8" fill="#1565C0"/>
|
||||
<circle cx="40" cy="57" r="16" fill="#F5C6A0"/>
|
||||
<g class="slash-blade">
|
||||
<rect x="55" y="40" width="6" height="50" rx="2" fill="#B0BEC5"/>
|
||||
<rect x="52" y="38" width="12" height="8" rx="2" fill="#90A4AE"/>
|
||||
</g>
|
||||
<g class="explosion-effect">
|
||||
<circle cx="148" cy="85" r="10" fill="#FF5722" opacity="0"/>
|
||||
</g>
|
||||
<g class="bubble-float">
|
||||
<rect x="5" y="5" width="105" height="24" rx="12" fill="#D32F2F"/>
|
||||
<text x="57" y="21" text-anchor="middle" fill="white" font-size="10" font-weight="bold">我要爆炸了💥</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景4:砍西瓜 🍉 -->
|
||||
<div v-else-if="selectedScene === 4" class="scene scene--watermelon">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="140" width="200" height="40" fill="#C8E6C9"/>
|
||||
<g class="melon">
|
||||
<ellipse cx="140" cy="125" rx="25" ry="18" fill="#4CAF50"/>
|
||||
<ellipse cx="140" cy="122" rx="18" ry="10" fill="#F44336"/>
|
||||
</g>
|
||||
<rect x="25" y="75" width="30" height="47" rx="8" fill="#1565C0"/>
|
||||
<circle cx="40" cy="60" r="16" fill="#F5C6A0"/>
|
||||
<g class="knife">
|
||||
<rect x="55" y="42" width="6" height="45" rx="2" fill="#B0BEC5"/>
|
||||
<rect x="52" y="40" width="12" height="8" rx="2" fill="#90A4AE"/>
|
||||
</g>
|
||||
<g class="juice-splash">
|
||||
<circle cx="130" cy="110" r="2" fill="#F44336" opacity="0"/>
|
||||
<circle cx="145" cy="105" r="2.5" fill="#F44336" opacity="0"/>
|
||||
<circle cx="155" cy="112" r="1.5" fill="#F44336" opacity="0"/>
|
||||
</g>
|
||||
<g class="bubble-float">
|
||||
<rect x="5" y="5" width="105" height="24" rx="12" fill="#43A047"/>
|
||||
<text x="57" y="21" text-anchor="middle" fill="white" font-size="10" font-weight="bold">IT救我!🍉</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景5:摇传菜铃 🔔 -->
|
||||
<div v-else-if="selectedScene === 5" class="scene scene--bell">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="140" width="200" height="40" fill="#FFF3E0"/>
|
||||
<rect x="25" y="70" width="30" height="52" rx="8" fill="#FF9800"/>
|
||||
<circle cx="40" cy="55" r="16" fill="#F5C6A0"/>
|
||||
<g class="bell-left">
|
||||
<path d="M52 72 Q52 58 60 50 Q68 42 72 50 Q76 58 74 72" fill="#FFD54F" stroke="#FFA000" stroke-width="1.5"/>
|
||||
<ellipse cx="63" cy="72" rx="9" ry="3" fill="#FFA000"/>
|
||||
</g>
|
||||
<g class="bell-right">
|
||||
<path d="M92 72 Q92 58 100 50 Q108 42 112 50 Q116 58 114 72" fill="#FFD54F" stroke="#FFA000" stroke-width="1.5"/>
|
||||
<ellipse cx="103" cy="72" rx="9" ry="3" fill="#FFA000"/>
|
||||
</g>
|
||||
<g class="sound-waves">
|
||||
<circle cx="160" cy="55" r="10" fill="none" stroke="#FF9800" stroke-width="1.5" opacity="0"/>
|
||||
<circle cx="160" cy="55" r="16" fill="none" stroke="#FF9800" stroke-width="1.5" opacity="0"/>
|
||||
<circle cx="160" cy="55" r="22" fill="none" stroke="#FF9800" stroke-width="1" opacity="0"/>
|
||||
</g>
|
||||
<g class="bubble-float">
|
||||
<rect x="5" y="5" width="105" height="24" rx="12" fill="#FF9800"/>
|
||||
<text x="57" y="21" text-anchor="middle" fill="white" font-size="10" font-weight="bold">叮叮叮!有人吗</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景6:大炮发射 💣 -->
|
||||
<div v-else-if="selectedScene === 6" class="scene scene--cannon">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="145" width="200" height="35" fill="#D7CCC8"/>
|
||||
<rect x="55" y="132" width="50" height="16" rx="3" fill="#5D4037"/>
|
||||
<circle cx="80" cy="138" r="9" fill="#4E342E"/>
|
||||
<g class="cannon-barrel">
|
||||
<rect x="58" y="110" width="55" height="14" rx="4" fill="#616161"/>
|
||||
<rect x="108" y="108" width="12" height="18" rx="3" fill="#757575"/>
|
||||
</g>
|
||||
<g class="fuse">
|
||||
<path d="M58 117 Q52 115 48 118 Q44 121 40 117" stroke="#FF9800" stroke-width="2" fill="none"/>
|
||||
<circle cx="38" cy="116" r="3" fill="#FF5722" class="fuse-spark"/>
|
||||
</g>
|
||||
<g class="cannonball">
|
||||
<circle cx="120" cy="105" r="7" fill="#37474F"/>
|
||||
</g>
|
||||
<g class="explosion">
|
||||
<circle cx="170" cy="60" r="18" fill="#FF5722" opacity="0" class="blast-main"/>
|
||||
<circle cx="170" cy="60" r="12" fill="#FFC107" opacity="0" class="blast-inner"/>
|
||||
<line x1="170" y1="40" x2="170" y2="30" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-1"/>
|
||||
<line x1="185" y1="48" x2="192" y2="42" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-2"/>
|
||||
<line x1="188" y1="60" x2="196" y2="60" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-3"/>
|
||||
<line x1="185" y1="72" x2="192" y2="78" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-4"/>
|
||||
<line x1="170" y1="80" x2="170" y2="90" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-5"/>
|
||||
<line x1="155" y1="72" x2="148" y2="78" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-6"/>
|
||||
<line x1="152" y1="60" x2="144" y2="60" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-7"/>
|
||||
<line x1="155" y1="48" x2="148" y2="42" stroke="#FF5722" stroke-width="3" opacity="0" class="blast-ray ray-8"/>
|
||||
</g>
|
||||
<g class="target">
|
||||
<circle cx="170" cy="55" r="14" fill="none" stroke="#F44336" stroke-width="2"/>
|
||||
<circle cx="170" cy="55" r="8" fill="none" stroke="#F44336" stroke-width="1.5"/>
|
||||
<circle cx="170" cy="55" r="2" fill="#F44336"/>
|
||||
<text x="170" y="38" text-anchor="middle" fill="#F44336" font-size="9" font-weight="bold">BUG</text>
|
||||
</g>
|
||||
<rect x="5" y="75" width="30" height="57" rx="7" fill="#795548"/>
|
||||
<circle cx="20" cy="60" r="16" fill="#F5C6A0"/>
|
||||
<circle cx="15" cy="57" r="2" fill="#333"/>
|
||||
<circle cx="25" cy="57" r="2" fill="#333"/>
|
||||
<path d="M14 66 Q20 70 26 66" stroke="#333" stroke-width="1.5" fill="none"/>
|
||||
<line x1="35" y1="85" x2="48" y2="100" stroke="#F5C6A0" stroke-width="7" stroke-linecap="round" class="gunner-arm"/>
|
||||
<g class="bubble-float">
|
||||
<rect x="5" y="5" width="95" height="24" rx="12" fill="#FF5722"/>
|
||||
<text x="52" y="21" text-anchor="middle" fill="white" font-size="11" font-weight="bold">开炮!💣</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 场景7:导弹发射 🚀 -->
|
||||
<div v-else-if="selectedScene === 7" class="scene scene--missile">
|
||||
<svg viewBox="0 0 200 180" xmlns="http://www.w3.org/2000/svg" class="scene__svg">
|
||||
<rect x="0" y="0" width="200" height="140" fill="#E3F2FD"/>
|
||||
<rect x="0" y="140" width="200" height="40" fill="#BDBDBD"/>
|
||||
<rect x="85" y="130" width="30" height="12" rx="2" fill="#616161"/>
|
||||
<rect x="88" y="142" width="8" height="10" fill="#757575"/>
|
||||
<rect x="104" y="142" width="8" height="10" fill="#757575"/>
|
||||
<g class="missile-body">
|
||||
<rect x="94" y="50" width="12" height="65" rx="4" fill="#E0E0E0"/>
|
||||
<path d="M94 50 L100 35 L106 50 Z" fill="#F44336"/>
|
||||
<polygon points="94,108 86,115 94,112" fill="#F44336"/>
|
||||
<polygon points="106,108 114,115 106,112" fill="#F44336"/>
|
||||
</g>
|
||||
<g class="rocket-flame">
|
||||
<ellipse cx="100" cy="118" rx="4" ry="10" fill="#FF9800" class="flame-outer"/>
|
||||
<ellipse cx="100" cy="116" rx="2" ry="6" fill="#FFEB3B" class="flame-inner"/>
|
||||
</g>
|
||||
<g class="smoke">
|
||||
<circle cx="88" cy="145" r="8" fill="#CFD8DC" opacity="0" class="smoke-puff puff-1"/>
|
||||
<circle cx="112" cy="145" r="8" fill="#CFD8DC" opacity="0" class="smoke-puff puff-2"/>
|
||||
<circle cx="100" cy="155" r="10" fill="#CFD8DC" opacity="0" class="smoke-puff puff-3"/>
|
||||
</g>
|
||||
<g class="trail">
|
||||
<path d="M100 130 L100 145" stroke="#FFC107" stroke-width="2" opacity="0" class="trail-line"/>
|
||||
</g>
|
||||
<rect x="15" y="80" width="28" height="52" rx="7" fill="#1565C0"/>
|
||||
<circle cx="29" cy="65" r="16" fill="#F5C6A0"/>
|
||||
<rect x="18" y="58" width="22" height="8" rx="3" fill="#333" opacity="0.7"/>
|
||||
<circle cx="24" cy="62" r="1.5" fill="#FFF"/>
|
||||
<circle cx="34" cy="62" r="1.5" fill="#FFF"/>
|
||||
<ellipse cx="29" cy="72" rx="4" ry="3" fill="#333"/>
|
||||
<line x1="43" y1="90" x2="60" y2="100" stroke="#F5C6A0" stroke-width="7" stroke-linecap="round"/>
|
||||
<rect x="55" y="105" width="15" height="20" rx="3" fill="#424242"/>
|
||||
<circle cx="62.5" cy="112" r="3" fill="#F44336" class="launch-button"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 话术文字 -->
|
||||
<div class="call-modal__speech-text">
|
||||
<span class="call-modal__speech-emoji">{{ sceneEmoji }}</span>
|
||||
<span class="call-modal__speech-content">{{ sceneText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 发送状态 -->
|
||||
<div v-if="sending" class="call-modal__sending">
|
||||
<span class="call-modal__dot-flashing"></span>
|
||||
<span>正在通知 IT 坐席...</span>
|
||||
</div>
|
||||
<div v-if="sendSuccess" class="call-modal__success">
|
||||
✅ 呼叫成功!坐席马上就来~
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:关闭按钮 -->
|
||||
<div class="call-modal__footer call-modal__footer--center">
|
||||
<button
|
||||
v-if="sendSuccess"
|
||||
class="call-modal__btn call-modal__btn--primary call-modal__btn--large"
|
||||
@click="handleClose"
|
||||
>好的,返回聊天</button>
|
||||
<button
|
||||
v-else
|
||||
class="call-modal__btn call-modal__btn--cancel"
|
||||
@click="handleClose"
|
||||
>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* CallAgentModal — 「呼叫坐席」动画弹窗(简化版)
|
||||
*
|
||||
* 流程:打开 → 随机播放趣味动画 + 发 shake 请求 → 关闭
|
||||
* 前提:用户已在聊天中描述问题,AI 已回复 >= 3 轮
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
(e: 'call-success'): void
|
||||
}>()
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
// ── 状态 ──
|
||||
const selectedScene = ref<number>(1)
|
||||
const sending = ref<boolean>(false)
|
||||
const sendSuccess = ref<boolean>(false)
|
||||
|
||||
// ── 场景配置 ──
|
||||
const scenes = [
|
||||
{ id: 1, emoji: '🙋', text: '看这里!看我这里...我有个问题!', weight: 3 },
|
||||
{ id: 2, emoji: '🪑', text: '快快快!我等不及了!', weight: 3 },
|
||||
{ id: 3, emoji: '💀', text: '这个问题不解决我就要原地爆炸了💥', weight: 1.5 },
|
||||
{ id: 4, emoji: '🍉', text: 'IT!救我!这个问题卡住我了🍉', weight: 1.5 },
|
||||
{ id: 5, emoji: '🔔', text: '叮叮叮!有人吗!IT 在线吗!', weight: 1 },
|
||||
{ id: 6, emoji: '💣', text: '开炮!💣 这个问题必须解决了!', weight: 1.5 },
|
||||
{ id: 7, emoji: '🚀', text: '发射!🚀 紧急呼叫 IT 特种部队!', weight: 1.5 },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 固定使用场景5:摇铃铛 🔔
|
||||
* 不再随机选择,回归统一的摇铃呼叫体验
|
||||
*/
|
||||
function pickScene(): number {
|
||||
return 5
|
||||
}
|
||||
|
||||
const sceneText = computed(() => scenes.find(s => s.id === selectedScene.value)?.text ?? '')
|
||||
const sceneEmoji = computed(() => scenes.find(s => s.id === selectedScene.value)?.emoji ?? '🙋')
|
||||
|
||||
// ── 弹窗打开时自动发起呼叫 ──
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
startCall()
|
||||
}
|
||||
})
|
||||
|
||||
async function startCall(): Promise<void> {
|
||||
selectedScene.value = pickScene()
|
||||
sending.value = true
|
||||
sendSuccess.value = false
|
||||
|
||||
try {
|
||||
await store.shakeAgent()
|
||||
sendSuccess.value = true
|
||||
emit('call-success')
|
||||
|
||||
// 3秒后自动关闭
|
||||
setTimeout(() => {
|
||||
if (sendSuccess.value) handleClose()
|
||||
}, 4000)
|
||||
} catch (err) {
|
||||
// 发送失败,关闭弹窗
|
||||
handleClose()
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose(): void {
|
||||
emit('update:visible', false)
|
||||
// 重置状态
|
||||
setTimeout(() => {
|
||||
sending.value = false
|
||||
sendSuccess.value = false
|
||||
}, 300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
弹窗容器
|
||||
========================================================================== */
|
||||
.call-modal__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.call-modal {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
width: 88vw;
|
||||
max-width: 360px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
.call-modal--compact {
|
||||
max-width: 340px;
|
||||
}
|
||||
.call-modal__header {
|
||||
text-align: center;
|
||||
padding: 20px 16px 8px;
|
||||
}
|
||||
.call-modal__icon { font-size: 32px; }
|
||||
.call-modal__header h3 {
|
||||
margin: 8px 0 4px;
|
||||
font-size: 17px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.call-modal__body { padding: 12px 16px; }
|
||||
.call-modal__body--center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.call-modal__footer {
|
||||
padding: 12px 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.call-modal__footer--center { justify-content: center; }
|
||||
.call-modal__btn {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.call-modal__btn--cancel { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
.call-modal__btn--primary { background: var(--color-warning); color: var(--bg-secondary); }
|
||||
.call-modal__btn--large { padding: 12px 48px; font-size: 15px; }
|
||||
.call-modal__btn:active { transform: scale(0.96); }
|
||||
|
||||
/* ==========================================================================
|
||||
话术文字
|
||||
========================================================================== */
|
||||
.call-modal__speech-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.call-modal__speech-emoji { font-size: 22px; }
|
||||
|
||||
/* ==========================================================================
|
||||
发送状态
|
||||
========================================================================== */
|
||||
.call-modal__sending {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.call-modal__dot-flashing {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning);
|
||||
animation: dot-flash 0.6s infinite alternate;
|
||||
}
|
||||
@keyframes dot-flash {
|
||||
0% { opacity: 0.2; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
.call-modal__success {
|
||||
margin-top: 12px;
|
||||
font-size: 15px;
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
SVG 场景容器
|
||||
========================================================================== */
|
||||
.scene { margin-bottom: 4px; }
|
||||
.scene__svg { width: 180px; height: 150px; display: block; margin: 0 auto; }
|
||||
|
||||
/* ==========================================================================
|
||||
场景1:招手动画
|
||||
========================================================================== */
|
||||
.hand-arm {
|
||||
animation: hand-wave 0.6s ease-in-out infinite;
|
||||
transform-origin: 42px 80px;
|
||||
}
|
||||
@keyframes hand-wave {
|
||||
0%, 100% { transform: rotate(-5deg); }
|
||||
50% { transform: rotate(-30deg); }
|
||||
}
|
||||
.bubble-float { animation: bubble-updown 2s ease-in-out infinite; }
|
||||
@keyframes bubble-updown {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景2:拍桌子动画
|
||||
========================================================================== */
|
||||
.pound-fists {
|
||||
animation: desk-pound 0.4s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes desk-pound {
|
||||
0% { transform: translateY(0); }
|
||||
100% { transform: translateY(-8px); }
|
||||
}
|
||||
.scene--pound {
|
||||
animation: desk-shake 0.4s ease-in-out infinite alternate;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
@keyframes desk-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-3px); }
|
||||
75% { transform: translateX(3px); }
|
||||
}
|
||||
.splash-lines line:nth-child(1) { animation: splash 0.6s ease-out infinite; }
|
||||
.splash-lines line:nth-child(2) { animation: splash 0.6s ease-out 0.1s infinite; }
|
||||
.splash-lines line:nth-child(3) { animation: splash 0.6s ease-out 0.2s infinite; }
|
||||
.splash-lines line:nth-child(4) { animation: splash 0.6s ease-out 0.15s infinite; }
|
||||
.splash-lines line:nth-child(5) { animation: splash 0.6s ease-out 0.25s infinite; }
|
||||
@keyframes splash {
|
||||
0% { opacity: 1; transform: translate(0,0); }
|
||||
100% { opacity: 0; transform: translate(var(--sx, 0), -12px); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景3:劈稻草人
|
||||
========================================================================== */
|
||||
.slash-blade {
|
||||
animation: blade-slash 1.0s ease-in-out infinite;
|
||||
transform-origin: 58px 38px;
|
||||
}
|
||||
@keyframes blade-slash {
|
||||
0%, 15% { transform: rotate(-60deg); }
|
||||
30%, 55% { transform: rotate(20deg); }
|
||||
70%, 100% { transform: rotate(-60deg); }
|
||||
}
|
||||
.explosion-effect circle {
|
||||
animation: boom-flash 1.0s ease-in-out infinite;
|
||||
}
|
||||
@keyframes boom-flash {
|
||||
0%, 50% { opacity: 0; transform: scale(0.5); }
|
||||
55% { opacity: 0.9; transform: scale(2); }
|
||||
65% { opacity: 0; transform: scale(3); }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景4:砍西瓜
|
||||
========================================================================== */
|
||||
.knife {
|
||||
animation: knife-chop 0.8s ease-in-out infinite;
|
||||
transform-origin: 58px 40px;
|
||||
}
|
||||
@keyframes knife-chop {
|
||||
0%, 20% { transform: rotate(-50deg); }
|
||||
40%, 60% { transform: rotate(15deg); }
|
||||
80%, 100% { transform: rotate(-50deg); }
|
||||
}
|
||||
.juice-splash circle:nth-child(1) { animation: juice-splash 0.8s ease-out 0.4s infinite; }
|
||||
.juice-splash circle:nth-child(2) { animation: juice-splash 0.8s ease-out 0.45s infinite; }
|
||||
.juice-splash circle:nth-child(3) { animation: juice-splash 0.8s ease-out 0.5s infinite; }
|
||||
@keyframes juice-splash {
|
||||
0% { opacity: 0; transform: translate(0,0) scale(0); }
|
||||
50% { opacity: 1; transform: translate(-5px, -10px) scale(1); }
|
||||
100% { opacity: 0; transform: translate(-10px, -20px) scale(0.5); }
|
||||
}
|
||||
.melon {
|
||||
animation: melon-shake 0.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes melon-shake {
|
||||
0%, 37% { transform: translate(0,0); }
|
||||
42%, 58% { transform: translate(-3px, 2px); }
|
||||
62% { transform: translate(0,0); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景5:摇传菜铃
|
||||
========================================================================== */
|
||||
.bell-left {
|
||||
animation: bell-ring-left 0.5s ease-in-out infinite;
|
||||
transform-origin: 63px 58px;
|
||||
}
|
||||
.bell-right {
|
||||
animation: bell-ring-right 0.5s ease-in-out 0.25s infinite;
|
||||
transform-origin: 103px 58px;
|
||||
}
|
||||
@keyframes bell-ring-left {
|
||||
0%, 100% { transform: rotate(0); }
|
||||
50% { transform: rotate(-12deg); }
|
||||
}
|
||||
@keyframes bell-ring-right {
|
||||
0%, 100% { transform: rotate(0); }
|
||||
50% { transform: rotate(12deg); }
|
||||
}
|
||||
.sound-waves circle:nth-child(1) { animation: wave-expand 1s ease-out infinite; }
|
||||
.sound-waves circle:nth-child(2) { animation: wave-expand 1s ease-out 0.3s infinite; }
|
||||
.sound-waves circle:nth-child(3) { animation: wave-expand 1s ease-out 0.6s infinite; }
|
||||
@keyframes wave-expand {
|
||||
0% { opacity: 1; stroke-width: 2; }
|
||||
100% { opacity: 0; stroke-width: 0.5; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景6:大炮发射
|
||||
========================================================================== */
|
||||
.cannon-barrel {
|
||||
animation: cannon-recoil 1.2s ease-in-out infinite;
|
||||
transform-origin: 80px 117px;
|
||||
}
|
||||
@keyframes cannon-recoil {
|
||||
0%, 15% { transform: translateX(0); }
|
||||
20%, 25% { transform: translateX(-8px); }
|
||||
30%, 100% { transform: translateX(0); }
|
||||
}
|
||||
.fuse-spark {
|
||||
animation: spark-flicker 0.15s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes spark-flicker {
|
||||
0% { r: 2; fill: #FF5722; opacity: 0.6; }
|
||||
100% { r: 3.5; fill: #FFEB3B; opacity: 1; }
|
||||
}
|
||||
.cannonball {
|
||||
animation: cannonball-fly 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes cannonball-fly {
|
||||
0%, 25% { transform: translate(0, 0); opacity: 0; }
|
||||
28% { transform: translate(15px, -20px); opacity: 1; }
|
||||
50% { transform: translate(50px, -48px); opacity: 1; }
|
||||
100% { transform: translate(50px, -48px); opacity: 1; }
|
||||
}
|
||||
.blast-main { animation: blast-appear 1.2s ease-in-out infinite; }
|
||||
@keyframes blast-appear {
|
||||
0%, 55% { opacity: 0; transform: scale(0); }
|
||||
58% { opacity: 1; transform: scale(1.5); }
|
||||
65% { opacity: 0.9; transform: scale(1.2); }
|
||||
75% { opacity: 0; transform: scale(1.8); }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
.blast-inner { animation: blast-inner 1.2s ease-in-out infinite; }
|
||||
@keyframes blast-inner {
|
||||
0%, 58% { opacity: 0; transform: scale(0); }
|
||||
62% { opacity: 1; transform: scale(1); }
|
||||
68% { opacity: 0.7; transform: scale(1.3); }
|
||||
75% { opacity: 0; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
.blast-ray { animation: blast-ray 1.2s ease-in-out infinite; }
|
||||
.ray-1, .ray-5 { animation-delay: 0.55s; }
|
||||
.ray-2, .ray-6 { animation-delay: 0.56s; }
|
||||
.ray-3, .ray-7 { animation-delay: 0.57s; }
|
||||
.ray-4, .ray-8 { animation-delay: 0.58s; }
|
||||
@keyframes blast-ray {
|
||||
0%, 60% { opacity: 0; }
|
||||
62% { opacity: 1; }
|
||||
70% { opacity: 0; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
.target { animation: target-shake 1.2s ease-in-out infinite; }
|
||||
@keyframes target-shake {
|
||||
0%, 56% { transform: translate(0,0) rotate(0); }
|
||||
60% { transform: translate(3px,-3px) rotate(5deg); }
|
||||
64% { transform: translate(-3px,2px) rotate(-5deg); }
|
||||
68% { transform: translate(0,0) rotate(0); }
|
||||
100% { transform: translate(0,0) rotate(0); }
|
||||
}
|
||||
.gunner-arm {
|
||||
animation: gunner-arm 1.2s ease-in-out infinite;
|
||||
transform-origin: 35px 85px;
|
||||
}
|
||||
@keyframes gunner-arm {
|
||||
0%, 25% { transform: rotate(0deg); }
|
||||
28% { transform: rotate(15deg); }
|
||||
35% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
场景7:导弹发射
|
||||
========================================================================== */
|
||||
.missile-body { animation: missile-launch 1.5s ease-in-out infinite; }
|
||||
@keyframes missile-launch {
|
||||
0%, 10% { transform: translateY(0); }
|
||||
15%, 70% { transform: translateY(-80px); }
|
||||
80%, 100% { transform: translateY(-80px); }
|
||||
}
|
||||
.rocket-flame { animation: missile-launch 1.5s ease-in-out infinite; }
|
||||
.flame-outer { animation: flame-pulse 0.2s ease-in-out infinite alternate; }
|
||||
.flame-inner { animation: flame-pulse 0.15s ease-in-out infinite alternate; }
|
||||
@keyframes flame-pulse {
|
||||
0% { transform: scaleY(1); opacity: 0.8; }
|
||||
100% { transform: scaleY(1.3); opacity: 1; }
|
||||
}
|
||||
.smoke-puff { animation: smoke-burst 1.5s ease-out infinite; }
|
||||
.puff-1 { animation-delay: 0.2s; }
|
||||
.puff-2 { animation-delay: 0.3s; }
|
||||
.puff-3 { animation-delay: 0.4s; }
|
||||
@keyframes smoke-burst {
|
||||
0% { opacity: 0; transform: scale(0.5) translateY(0); }
|
||||
15% { opacity: 0.7; transform: scale(1) translateY(0); }
|
||||
100% { opacity: 0; transform: scale(2.5) translateY(-20px); }
|
||||
}
|
||||
.trail-line { animation: trail-glow 1.5s ease-out infinite; }
|
||||
@keyframes trail-glow {
|
||||
0%, 15% { opacity: 0; }
|
||||
20% { opacity: 0.8; }
|
||||
70% { opacity: 0.6; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
.launch-button { animation: button-blink 0.5s ease-in-out infinite alternate; }
|
||||
@keyframes button-blink {
|
||||
0% { fill: #F44336; }
|
||||
100% { fill: #FFEB3B; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
过渡动画
|
||||
========================================================================== */
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active { transition: opacity 0.25s ease; }
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to { opacity: 0; }
|
||||
.modal-zoom-enter-active { transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||
.modal-zoom-leave-active { transition: all 0.2s ease-in; }
|
||||
.modal-zoom-enter-from { opacity: 0; transform: scale(0.8); }
|
||||
.modal-zoom-leave-to { opacity: 0; transform: scale(0.9); }
|
||||
</style>
|
||||
@@ -0,0 +1,438 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端对话区面板
|
||||
// =============================================================================
|
||||
// 说明:对话区主面板,包含:
|
||||
// 1. 标题栏(IT智能服务台 + 坐席状态 + 🔔呼叫 + 主题切换)
|
||||
// 2. 排查步骤(固定在消息区顶部,不随滚动消失)
|
||||
// 3. 消息列表(自动滚动到底部)
|
||||
// 4. 消息类型渲染:员工(右蓝) / 坐席(左白) / AI(左绿+AI标签) / 系统(居中灰)
|
||||
// 5. 底部输入栏(工具栏+输入+发送+引导条)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="chat-panel">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="chat-panel__header">
|
||||
<div class="chat-panel__header-left">
|
||||
<span class="chat-panel__title">IT智能服务台</span>
|
||||
<!-- 坐席在线状态 -->
|
||||
<span v-if="store.agentOnline" class="chat-panel__status chat-panel__status--online">
|
||||
<span class="chat-panel__status-dot"></span>
|
||||
坐席在线
|
||||
</span>
|
||||
<span v-else class="chat-panel__status chat-panel__status--offline">
|
||||
<span class="chat-panel__status-dot"></span>
|
||||
坐席离线
|
||||
</span>
|
||||
</div>
|
||||
<div class="chat-panel__header-actions">
|
||||
<!-- 🔔 呼叫坐席按钮 -->
|
||||
<button
|
||||
v-if="store.canCallAgent"
|
||||
class="chat-panel__bell-btn"
|
||||
:disabled="!store.isLoggedIn || store.shaking"
|
||||
title="摇铃呼叫人工坐席"
|
||||
@click="showCallModal = true"
|
||||
>
|
||||
<span class="chat-panel__bell-icon">🔔</span>
|
||||
<span class="chat-panel__bell-text">呼叫</span>
|
||||
</button>
|
||||
<!-- 主题切换开关(☀️ 滑轨 🌙) -->
|
||||
<div
|
||||
class="theme-switch"
|
||||
:title="themeStore.currentTheme === 'light' ? '切换到深色模式' : '切换到浅色模式'"
|
||||
@click="themeStore.toggleTheme()"
|
||||
>
|
||||
<span class="switch-icon">☀️</span>
|
||||
<div class="switch-track">
|
||||
<div class="switch-thumb"></div>
|
||||
</div>
|
||||
<span class="switch-icon">🌙</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 排查步骤:固定在消息区顶部(标题栏下方、所有消息之上,不随滚动消失) -->
|
||||
<div v-if="store.troubleshootingSteps?.length" class="chat-panel__troubleshoot-fixed">
|
||||
<TroubleshootFlow />
|
||||
</div>
|
||||
|
||||
<!-- 参与者横幅:当有被邀请参与者时显示(邀请功能 P0-09~P0-11) -->
|
||||
<div
|
||||
v-if="store.participants.length > 0"
|
||||
class="chat-panel__participant-banner"
|
||||
@click="store.toggleParticipantPanel()"
|
||||
>
|
||||
<span class="chat-panel__participant-icon">👥</span>
|
||||
<span class="chat-panel__participant-text">
|
||||
{{ store.joinedParticipantCount }}人已加入,共{{ store.participants.length }}人被邀请
|
||||
</span>
|
||||
<span
|
||||
class="chat-panel__participant-arrow"
|
||||
:class="{ 'chat-panel__participant-arrow--open': store.participantPanelVisible }"
|
||||
>▾</span>
|
||||
</div>
|
||||
|
||||
<!-- 参与者面板(展开时显示) -->
|
||||
<div v-if="store.participantPanelVisible" class="chat-panel__participant-panel">
|
||||
<ParticipantList />
|
||||
</div>
|
||||
|
||||
<!-- 消息列表区域(可滚动) -->
|
||||
<div ref="messageListRef" class="chat-panel__messages" @scroll="handleScroll">
|
||||
<!-- 未登录提示 -->
|
||||
<div v-if="!store.isLoggedIn" class="chat-panel__not-logged">
|
||||
<van-empty description="正在获取身份信息..." image="search" />
|
||||
</div>
|
||||
|
||||
<!-- 无消息提示 -->
|
||||
<div v-else-if="store.messages.length === 0" class="chat-panel__empty">
|
||||
<div class="chat-panel__empty-icon">💬</div>
|
||||
<p class="chat-panel__empty-text">暂无消息</p>
|
||||
<p class="chat-panel__empty-hint">输入问题咨询,或 🔔 摇铃呼叫坐席</p>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<template v-else>
|
||||
<MessageBubble
|
||||
v-for="msg in store.messages"
|
||||
:key="msg.message_id"
|
||||
:msg="msg"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 底部输入栏 -->
|
||||
<InputBar />
|
||||
|
||||
<!-- 呼叫坐席弹窗(描述问题 → AI确认 → 动画) -->
|
||||
<CallAgentModal
|
||||
:visible="showCallModal"
|
||||
@update:visible="showCallModal = $event"
|
||||
@call-success="handleCallSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ChatPanel 对话区面板
|
||||
* 包含标题栏、排查步骤(固定顶部)、消息列表和底部输入栏
|
||||
* 消息列表自动滚动到底部
|
||||
* 排查步骤固定在消息区顶部,不随消息滚动消失
|
||||
*/
|
||||
|
||||
import { ref, watch, nextTick, onMounted } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import InputBar from './InputBar.vue'
|
||||
import CallAgentModal from './CallAgentModal.vue'
|
||||
import TroubleshootFlow from './TroubleshootFlow.vue'
|
||||
import ParticipantList from './ParticipantList.vue'
|
||||
|
||||
const store = useConversationStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
/** 消息列表容器的 DOM 引用 */
|
||||
const messageListRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 是否显示「呼叫坐席」弹窗 */
|
||||
const showCallModal = ref<boolean>(false)
|
||||
|
||||
/** 是否应该自动滚动到底部(用户手动上滚时暂停自动滚动) */
|
||||
const shouldAutoScroll = ref<boolean>(true)
|
||||
|
||||
/**
|
||||
* 滚动到消息列表底部
|
||||
* 使用 nextTick 确保 DOM 更新后再滚动
|
||||
*/
|
||||
async function scrollToBottom(): Promise<void> {
|
||||
await nextTick()
|
||||
if (messageListRef.value && shouldAutoScroll.value) {
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理滚动事件
|
||||
* 如果用户滚动到接近底部,恢复自动滚动
|
||||
* 如果用户向上滚动离开底部,暂停自动滚动
|
||||
*/
|
||||
function handleScroll(): void {
|
||||
if (!messageListRef.value) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = messageListRef.value
|
||||
// 距离底部 100px 以内时恢复自动滚动
|
||||
shouldAutoScroll.value = scrollHeight - scrollTop - clientHeight < 100
|
||||
}
|
||||
|
||||
/** 呼叫成功回调:刷新会话状态 */
|
||||
function handleCallSuccess(): void {
|
||||
store.fetchCurrentConversation()
|
||||
}
|
||||
|
||||
// 监听消息列表变化,自动滚动到底部
|
||||
watch(
|
||||
() => store.messages.length,
|
||||
() => {
|
||||
scrollToBottom()
|
||||
}
|
||||
)
|
||||
|
||||
// 组件挂载后滚动到底部
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 对话区面板容器 */
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* 兼容不支持 dvh 的浏览器 */
|
||||
@supports not (height: 100dvh) {
|
||||
.chat-panel {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部标题栏 */
|
||||
.chat-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 标题栏左侧 */
|
||||
.chat-panel__header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 标题文字 */
|
||||
.chat-panel__title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 坐席在线状态 */
|
||||
.chat-panel__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.chat-panel__status--online {
|
||||
background-color: var(--color-success-soft, rgba(34, 197, 94, 0.1));
|
||||
color: var(--color-success, #22c55e);
|
||||
}
|
||||
|
||||
.chat-panel__status--offline {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.chat-panel__status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.chat-panel__status--online .chat-panel__status-dot {
|
||||
background-color: var(--color-success, #22c55e);
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.chat-panel__status--offline .chat-panel__status-dot {
|
||||
background-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* 标题栏右侧操作区 */
|
||||
.chat-panel__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 🔔 呼叫坐席按钮(标题栏) */
|
||||
.chat-panel__bell-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--color-warning, #FF9800);
|
||||
background: linear-gradient(135deg, #FFF8E1, #FFE082);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #E65100;
|
||||
animation: bell-idle 2s ease-in-out infinite;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-panel__bell-btn:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.4);
|
||||
animation: bell-ring 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.chat-panel__bell-btn:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.chat-panel__bell-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.chat-panel__bell-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-panel__bell-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 静止时轻微摇摆 */
|
||||
@keyframes bell-idle {
|
||||
0%, 100% { transform: rotate(0); }
|
||||
25% { transform: rotate(-3deg); }
|
||||
75% { transform: rotate(3deg); }
|
||||
}
|
||||
|
||||
/* 悬停时快速摇铃 */
|
||||
@keyframes bell-ring {
|
||||
0%, 100% { transform: rotate(0) scale(1.05); }
|
||||
15% { transform: rotate(-10deg) scale(1.05); }
|
||||
30% { transform: rotate(8deg) scale(1.05); }
|
||||
45% { transform: rotate(-6deg) scale(1.05); }
|
||||
60% { transform: rotate(4deg) scale(1.05); }
|
||||
75% { transform: rotate(-2deg) scale(1.05); }
|
||||
90% { transform: rotate(1deg) scale(1.05); }
|
||||
}
|
||||
|
||||
/* 排查步骤:固定在消息区顶部 */
|
||||
.chat-panel__troubleshoot-fixed {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* 参与者横幅:点击可展开/收起参与者面板 */
|
||||
.chat-panel__participant-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 16px;
|
||||
background-color: var(--accent-soft, rgba(59, 130, 246, 0.06));
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.chat-panel__participant-banner:hover {
|
||||
background-color: var(--accent-soft, rgba(59, 130, 246, 0.12));
|
||||
}
|
||||
|
||||
.chat-panel__participant-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-panel__participant-text {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-panel__participant-arrow {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chat-panel__participant-arrow--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 参与者面板(展开时显示 ParticipantList) */
|
||||
.chat-panel__participant-panel {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 消息列表区域 */
|
||||
.chat-panel__messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 未登录提示 */
|
||||
.chat-panel__not-logged {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 无消息空状态 */
|
||||
.chat-panel__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 16px;
|
||||
}
|
||||
|
||||
/* 空状态图标 */
|
||||
.chat-panel__empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 空状态提示文字 */
|
||||
.chat-panel__empty-text {
|
||||
font-size: 15px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 空状态辅助提示 */
|
||||
.chat-panel__empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-placeholder);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,872 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端输入栏组件
|
||||
// =============================================================================
|
||||
// 说明:底部输入栏,固定在消息列表下方,包含:
|
||||
// [工具栏:表情/图片/文件/拍照] [文本输入框] [发送按钮]
|
||||
// - 输入框默认3行可见,高度随内容动态适应
|
||||
// - 输入框顶部拖拽手柄可手动调节高度
|
||||
// - Enter 发送,Shift+Enter 换行
|
||||
// - 支持粘贴图片上传(Ctrl+V 粘贴截图)
|
||||
// - 支持图片/文件选择上传
|
||||
// - 底部引导条提示摇铃功能
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="input-bar">
|
||||
<!-- 拖拽手柄:可手动调节输入栏高度 -->
|
||||
<div
|
||||
class="input-bar__resize-handle"
|
||||
@mousedown="handleInputResizeStart"
|
||||
>
|
||||
<div class="input-bar__resize-track"></div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域:工具栏 + 输入框 + 发送按钮 -->
|
||||
<div class="input-bar__row">
|
||||
<!-- 工具栏:表情/图片/文件/拍照/截图 -->
|
||||
<div class="input-bar__toolbar">
|
||||
<button class="input-bar__tool-btn" title="表情" @click="handleEmoji">
|
||||
<span>😊</span>
|
||||
</button>
|
||||
<button class="input-bar__tool-btn" title="图片" @click="handleImage">
|
||||
<span>🖼️</span>
|
||||
</button>
|
||||
<button class="input-bar__tool-btn" title="文件" @click="handleFile">
|
||||
<span>📎</span>
|
||||
</button>
|
||||
<button class="input-bar__tool-btn" title="拍照" @click="handleCamera">
|
||||
<span>📷</span>
|
||||
</button>
|
||||
<button class="input-bar__tool-btn" title="截图" @click="handleScreenshot">
|
||||
<span>✂️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 表情选择面板(简易版:常用 Emoji 网格) -->
|
||||
<div v-if="showEmojiPanel" class="emoji-panel">
|
||||
<div class="emoji-panel__grid">
|
||||
<button
|
||||
v-for="emoji in commonEmojis"
|
||||
:key="emoji"
|
||||
class="emoji-panel__item"
|
||||
@click="onEmojiClick(emoji)"
|
||||
>{{ emoji }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入行:输入框 + 发送按钮 -->
|
||||
<div class="input-bar__input-row">
|
||||
<!-- 文本输入框 — 默认3行,自适应内容高度 -->
|
||||
<van-field
|
||||
ref="inputFieldRef"
|
||||
v-model="inputText"
|
||||
class="input-bar__field"
|
||||
placeholder="请输入消息..."
|
||||
type="textarea"
|
||||
:autosize="{ minHeight: 54, maxHeight: 200 }"
|
||||
rows="3"
|
||||
:disabled="!store.isLoggedIn"
|
||||
@keydown.enter="handleEnterKey"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<van-button
|
||||
class="input-bar__send-btn"
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!canSend"
|
||||
:loading="store.loading"
|
||||
@click="handleSend"
|
||||
>
|
||||
发送
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部引导条:根据是否可呼叫坐席切换文案 -->
|
||||
<div v-if="store.canCallAgent" class="input-bar__guide input-bar__guide--active">
|
||||
🔔 摇传菜铃通道已开启,点击标题栏传菜铃呼叫 IT 坐席
|
||||
</div>
|
||||
|
||||
<!-- 表情面板打开时的半透明遮罩(点击关闭表情面板) -->
|
||||
<div v-if="showEmojiPanel" class="emoji-panel__overlay" @click="showEmojiPanel = false"></div>
|
||||
<div v-else class="input-bar__guide">
|
||||
请描述你遇到的问题,AI 助手会帮你分析 💡
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的文件输入框(图片/文件上传用,由工具栏按钮触发) -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 隐藏的拍照输入框(移动端直接调用摄像头) -->
|
||||
<input
|
||||
ref="cameraInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
style="display: none"
|
||||
@change="handleCameraCapture"
|
||||
/>
|
||||
|
||||
<!-- 截图区域选择编辑器(对标微信/企微截图体验) -->
|
||||
<ScreenshotEditor
|
||||
v-if="showScreenshotEditor"
|
||||
:screenshot-canvas="screenshotCanvas"
|
||||
@confirm="onScreenshotConfirm"
|
||||
@cancel="onScreenshotCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* InputBar 输入栏组件
|
||||
* 布局:[工具栏] [输入框] [发送按钮]
|
||||
* 输入框固定底部,默认3行可见,高度动态适应
|
||||
* 顶部拖拽手柄可手动调节输入栏整体高度
|
||||
* 工具栏:表情/图片/文件/拍照
|
||||
* 支持粘贴图片上传(Ctrl+V)和文件选择上传
|
||||
*/
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import html2canvas from 'html2canvas-pro'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { uploadFile } from '@/api/upload'
|
||||
import ScreenshotEditor from './ScreenshotEditor.vue'
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数:安全提取错误详情(防止 [object Object])
|
||||
// ============================================================================
|
||||
/**
|
||||
* 将 error.response.data.detail 安全转换为字符串
|
||||
* FastAPI 422 时 detail 是数组,直接拼接会变成 "[object Object]"
|
||||
*/
|
||||
function formatErrorDetail(detail: any): string {
|
||||
if (!detail) return ''
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
// FastAPI 验证错误:取每个元素的 msg 和 loc
|
||||
return detail.map((d: any) => {
|
||||
if (d.loc && d.msg) return `${d.loc.slice(1).join('.')}: ${d.msg}`
|
||||
if (d.msg) return d.msg
|
||||
return JSON.stringify(d)
|
||||
}).join('; ')
|
||||
}
|
||||
if (typeof detail === 'object') return JSON.stringify(detail)
|
||||
return String(detail)
|
||||
}
|
||||
|
||||
const store = useConversationStore()
|
||||
// 保留 emit 声明供阶段二使用(呼叫坐席事件将改为由标题栏传菜铃触发)
|
||||
defineEmits<{
|
||||
(e: 'call-agent'): void
|
||||
}>()
|
||||
|
||||
/** 输入框文本 */
|
||||
const inputText = ref<string>('')
|
||||
|
||||
/** 输入框组件引用(用于获取 DOM 元素) */
|
||||
const inputFieldRef = ref<any>(null)
|
||||
|
||||
/** 隐藏文件输入框 DOM 引用(用于触发系统文件选择器) */
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 隐藏拍照输入框 DOM 引用(用于调用移动端摄像头) */
|
||||
const cameraInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 是否可以发送消息(输入框有内容且未在加载中) */
|
||||
const canSend = computed(() => {
|
||||
return inputText.value.trim().length > 0 && !store.loading && store.isLoggedIn
|
||||
})
|
||||
|
||||
/** 是否正在拖拽调节输入栏高度 */
|
||||
const isInputResizing = ref<boolean>(false)
|
||||
|
||||
/** 表情面板是否可见 */
|
||||
const showEmojiPanel = ref<boolean>(false)
|
||||
|
||||
/** 截图编辑器是否可见 */
|
||||
const showScreenshotEditor = ref<boolean>(false)
|
||||
|
||||
/** html2canvas 生成的完整页面截图 Canvas 对象(传给 ScreenshotEditor) */
|
||||
let screenshotCanvas: HTMLCanvasElement | null = null
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期:Document 级别粘贴监听(修复 van-field @paste 不触发文件粘贴的问题)
|
||||
// ============================================================================
|
||||
onMounted(() => {
|
||||
document.addEventListener('paste', handleDocPaste)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('paste', handleDocPaste)
|
||||
})
|
||||
|
||||
/**
|
||||
* Document 级别粘贴处理(备用方案)
|
||||
* 做什么:捕获 van-field @paste 未处理的文件粘贴
|
||||
*/
|
||||
async function handleDocPaste(event: ClipboardEvent): Promise<void> {
|
||||
// 如果事件目标是输入框,说明 @paste 已经处理,跳过
|
||||
const target = event.target as HTMLElement
|
||||
if (target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
||||
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
await handleFileUpload(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 常用表情列表(8行x8列 = 64 个常用表情)
|
||||
* 按使用频率排序,覆盖日常沟通场景
|
||||
*/
|
||||
const commonEmojis = [
|
||||
'😀','😃','😄','😁','😆','😅','🤣','😂',
|
||||
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
|
||||
'😚','😙','😋','😛','😜','🤪','😝','🤑',
|
||||
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
|
||||
'😶','😏','😒','🙄','😬','😮','🤯','😲',
|
||||
'😳','🥺','😢','😭','😤','😠','😡','🤬',
|
||||
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
|
||||
'❤️','🧡','💛','💚','💙','💜','💯','✅',
|
||||
]
|
||||
|
||||
/**
|
||||
* 选择表情后插入到输入框
|
||||
*
|
||||
* 做什么:将表情字符追加到输入框末尾
|
||||
*
|
||||
* 为什么简单追加而非操作 DOM 光标位置:
|
||||
* Vant van-field 的内部 textarea 有自己的状态管理,
|
||||
* 直接操作 DOM(selectionStart/selectionEnd)可能与 Vant 内部状态冲突。
|
||||
* 简单追加到 inputText.value 是最可靠的方式——v-model 会自动同步到 textarea。
|
||||
*/
|
||||
function onEmojiClick(emoji: string): void {
|
||||
inputText.value += emoji
|
||||
showEmojiPanel.value = false
|
||||
nextTick(() => {
|
||||
// 让输入框获得焦点,用户可以继续输入
|
||||
const el = inputFieldRef.value?.$el?.querySelector('textarea') as HTMLTextAreaElement | null
|
||||
el?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 消息发送
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 处理 Enter 键
|
||||
* 桌面端 Enter 发送,Shift+Enter 换行
|
||||
* 移动端不拦截 Enter(让系统默认行为处理)
|
||||
*/
|
||||
function handleEnterKey(event: KeyboardEvent): void {
|
||||
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||
if (isMobile) return
|
||||
|
||||
// 桌面端:Shift+Enter 换行,Enter 发送
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息
|
||||
* 调用 store 发送消息方法,发送后清空输入框
|
||||
*/
|
||||
async function handleSend(): Promise<void> {
|
||||
const content = inputText.value.trim()
|
||||
if (!content || store.loading) return
|
||||
|
||||
inputText.value = ''
|
||||
|
||||
await store.sendNewMessage(content)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 文件/图片上传
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 处理粘贴事件
|
||||
* 做什么:检测剪贴板中的图片或文件,自动上传并发送
|
||||
* 为什么:用户需要从剪贴板直接粘贴图片/文件
|
||||
*
|
||||
* 支持:
|
||||
* 1. 粘贴图片(截图工具 Ctrl+V / 复制图片)
|
||||
* 2. 粘贴文件(从文件管理器复制的文件)
|
||||
* 3. 纯文本粘贴(默认行为,不拦截)
|
||||
*/
|
||||
async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
// 处理所有文件类型(图片 + 普通文件)
|
||||
if (item.kind === 'file') {
|
||||
event.preventDefault() // 阻止默认粘贴(文件不插入文本)
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
|
||||
// 根据文件类型选择上传方式
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
await handleFileUpload(file)
|
||||
}
|
||||
return // 每次粘贴只处理第一个文件
|
||||
}
|
||||
}
|
||||
// 纯文本:不拦截,浏览器默认行为(插入文本到输入框)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片并发送图片消息
|
||||
* 做什么:粘贴或选择图片后,上传到服务器并发送消息
|
||||
* 为什么:用户粘贴图片时期望直接发送,而不是只上传不发送
|
||||
*
|
||||
* 注意:H5 端当前后端 sendMessage API 只支持文本消息,
|
||||
* 所以图片上传后以文本形式发送截图链接,后续后端支持图片消息后可升级
|
||||
*
|
||||
* @param file - 图片文件对象(File 或 Blob)
|
||||
*/
|
||||
async function handleImageUpload(file: File | Blob): Promise<void> {
|
||||
try {
|
||||
showToast('图片上传中...')
|
||||
|
||||
// 1. 上传图片到服务器
|
||||
const result = await uploadFile(file)
|
||||
|
||||
// 2. 以图片消息类型发送(携带 media_url,MessageBubble 会渲染缩略图)
|
||||
const fileName = file instanceof File ? file.name : '截图'
|
||||
await store.sendNewMessage(`[图片] ${fileName}`, {
|
||||
msg_type: 'image',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
|
||||
showToast('图片发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('图片上传失败:', error)
|
||||
showToast(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'图片上传失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件并发送文件消息
|
||||
* 做什么:粘贴或选择文件后,上传到服务器并发送消息
|
||||
* 为什么:用户粘贴文件时期望直接发送
|
||||
*
|
||||
* 注意:H5 端当前后端 sendMessage API 只支持文本消息,
|
||||
* 所以文件上传后以文本形式发送文件链接
|
||||
*
|
||||
* @param file - 文件对象(File 或 Blob)
|
||||
*/
|
||||
async function handleFileUpload(file: File | Blob): Promise<void> {
|
||||
try {
|
||||
const fileName = file instanceof File ? file.name : '文件'
|
||||
showToast(`文件上传中: ${fileName}`)
|
||||
|
||||
// 1. 上传文件到服务器
|
||||
const result = await uploadFile(file)
|
||||
|
||||
// 2. 以文件消息类型发送(携带 media_url,MessageBubble 会渲染文件卡片)
|
||||
await store.sendNewMessage(`[文件] ${result.filename}`, {
|
||||
msg_type: 'file',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
|
||||
showToast('文件发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('文件上传失败:', error)
|
||||
showToast(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'文件上传失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件选择上传
|
||||
* 通过隐藏的 <input type="file"> 触发
|
||||
*/
|
||||
async function handleFileSelect(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
showToast(`正在上传: ${file.name}`)
|
||||
|
||||
// 1. 上传文件到服务器
|
||||
const result = await uploadFile(file)
|
||||
|
||||
showToast(`${file.name} 上传成功`)
|
||||
console.log('[InputBar] 文件上传成功:', result.url)
|
||||
} catch (error: any) {
|
||||
console.error('文件上传失败:', error)
|
||||
showToast(`${file.name} 上传失败`)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置 input,允许重复选择同一文件
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理表情按钮点击
|
||||
* 弹出简易表情选择器(常用 Emoji 面板)
|
||||
*/
|
||||
function handleEmoji(): void {
|
||||
showEmojiPanel.value = !showEmojiPanel.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图片按钮点击
|
||||
* 触发文件选择器(限定图片类型)
|
||||
*/
|
||||
function handleImage(): void {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = 'image/*'
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件按钮点击
|
||||
* 触发文件选择器(不限类型)
|
||||
*/
|
||||
function handleFile(): void {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = ''
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理拍照按钮点击
|
||||
* 触发移动端摄像头拍照(capture="environment" 调用后置摄像头)
|
||||
* 桌面端降级为普通图片选择器
|
||||
*/
|
||||
function handleCamera(): void {
|
||||
if (cameraInputRef.value) {
|
||||
// 重置 input,允许重复拍照
|
||||
cameraInputRef.value.value = ''
|
||||
cameraInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理拍照/选择图片后的回调
|
||||
* 将拍摄的图片上传并发送图片消息
|
||||
*/
|
||||
async function handleCameraCapture(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const file = files[0]
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
}
|
||||
|
||||
// 重置 input,允许重复拍照
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理截图按钮点击(对标微信/企微)
|
||||
*
|
||||
* 做什么:截取当前页面,然后进入区域选择模式
|
||||
* 为什么:用户反馈截图不好用,需要对标微信/企微的截图体验
|
||||
*
|
||||
* 交互流程:
|
||||
* 1. 用 html2canvas 截取整个页面
|
||||
* 2. 显示 ScreenshotEditor 组件(全屏遮罩+区域选择)
|
||||
* 3. 用户在编辑器中选择区域并确认
|
||||
* 4. 接收裁剪后的图片 Blob,上传并发送
|
||||
* 5. 如果 html2canvas 失败(企微内置浏览器兼容问题),fallback 到手动选择图片
|
||||
*/
|
||||
async function handleScreenshot(): Promise<void> {
|
||||
try {
|
||||
showToast('正在截取页面...')
|
||||
|
||||
// 1. 截取整个页面(增加兼容性配置:useCORS + allowTaint 提升企微浏览器兼容性)
|
||||
const canvas = await html2canvas(document.body, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: window.devicePixelRatio || 1,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff',
|
||||
// 企微内置浏览器兼容性优化
|
||||
foreignObjectRendering: false,
|
||||
removeContainer: true,
|
||||
})
|
||||
|
||||
// 2. 保存 canvas 并显示截图编辑器
|
||||
screenshotCanvas = canvas
|
||||
showScreenshotEditor.value = true
|
||||
} catch (error) {
|
||||
console.error('截图失败,尝试 fallback 方案:', error)
|
||||
// Fallback:html2canvas 在企微内置浏览器中可能失败
|
||||
// 降级为手动选择图片(从相册选取或重新拍照)
|
||||
try {
|
||||
if (cameraInputRef.value) {
|
||||
showToast('截图功能不可用,请选择图片替代')
|
||||
cameraInputRef.value.value = ''
|
||||
// 不使用 capture 属性,允许从相册选择
|
||||
cameraInputRef.value.removeAttribute('capture')
|
||||
cameraInputRef.value.click()
|
||||
// 恢复 capture 属性
|
||||
nextTick(() => {
|
||||
if (cameraInputRef.value) {
|
||||
cameraInputRef.value.setAttribute('capture', 'environment')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
showToast('截图失败,请重试')
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error('截图 fallback 也失败:', fallbackError)
|
||||
showToast('截图失败,请重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图编辑器确认回调(对标微信/企微)
|
||||
* 接收裁剪后的图片 Blob,上传并发送
|
||||
*
|
||||
* 注意:H5 端当前后端 sendMessage API 只支持文本消息,
|
||||
* 所以截图上传后以文本形式发送截图链接,后续后端支持图片消息后可升级
|
||||
*/
|
||||
async function onScreenshotConfirm(blob: Blob): Promise<void> {
|
||||
try {
|
||||
console.log('[InputBar] 截图确认,开始上传,blob size:', blob.size)
|
||||
showToast('截图上传中...')
|
||||
|
||||
const result = await uploadFile(blob, 'screenshot')
|
||||
console.log('[InputBar] 上传成功,result:', result)
|
||||
|
||||
// 以图片消息类型发送截图(携带 media_url,MessageBubble 会渲染缩略图)
|
||||
console.log('[InputBar] 开始调用 store.sendNewMessage,media_url:', result.url)
|
||||
await store.sendNewMessage('[截图]', {
|
||||
msg_type: 'image',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
console.log('[InputBar] store.sendNewMessage 完成,当前消息数:', store.messages.length)
|
||||
|
||||
showToast('截图发送成功')
|
||||
console.log('[InputBar] 截图发送成功 toast 已显示')
|
||||
} catch (error: any) {
|
||||
console.error('[InputBar] 截图发送失败:', error)
|
||||
showToast(
|
||||
`截图发送失败:${
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'未知错误'
|
||||
}`
|
||||
)
|
||||
} finally {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
console.log('[InputBar] 截图流程 finally 块执行完成')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图编辑器取消回调
|
||||
*/
|
||||
function onScreenshotCancel(): void {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 拖拽调节输入栏高度
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 拖拽开始:记录初始 Y 坐标
|
||||
* 向上拖拽增大输入栏高度,向下拖拽减小
|
||||
*/
|
||||
function handleInputResizeStart(event: MouseEvent): void {
|
||||
isInputResizing.value = true
|
||||
|
||||
const startY = event.clientY
|
||||
const inputBar = (event.target as HTMLElement).closest('.input-bar') as HTMLElement
|
||||
const startHeight = inputBar ? inputBar.offsetHeight : 100
|
||||
|
||||
/**
|
||||
* 拖拽中:根据鼠标移动距离更新输入栏高度
|
||||
*/
|
||||
function handleMouseMove(e: MouseEvent): void {
|
||||
if (!isInputResizing.value) return
|
||||
|
||||
const deltaY = startY - e.clientY // 向上拖拽正值,向下负值
|
||||
let newHeight = startHeight + deltaY
|
||||
|
||||
// 限制最小/最大高度:100px ~ 400px
|
||||
newHeight = Math.max(100, Math.min(400, newHeight))
|
||||
|
||||
if (inputBar) {
|
||||
inputBar.style.height = `${newHeight}px`
|
||||
inputBar.style.flexShrink = '0'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽结束:移除事件监听
|
||||
*/
|
||||
function handleMouseUp(): void {
|
||||
isInputResizing.value = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
document.body.style.cursor = 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
输入栏容器 — 固定在消息列表底部
|
||||
========================================================================== */
|
||||
.input-bar {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 0 12px 8px;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ── 拖拽手柄:顶部横条,可拖拽调节输入栏高度 ── */
|
||||
.input-bar__resize-handle {
|
||||
height: 8px;
|
||||
cursor: row-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 -12px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.input-bar__resize-track {
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--border-color);
|
||||
transition: background-color 0.2s, width 0.2s;
|
||||
}
|
||||
|
||||
.input-bar__resize-handle:hover .input-bar__resize-track {
|
||||
background-color: var(--accent);
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
/* 输入行:工具栏 + 输入框 + 发送按钮 */
|
||||
.input-bar__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 工具栏:表情/图片/文件/截图 */
|
||||
.input-bar__toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 0 4px 0;
|
||||
}
|
||||
|
||||
/* ── 表情选择面板 ── */
|
||||
.emoji-panel {
|
||||
position: relative;
|
||||
z-index: 200;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.emoji-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 36px);
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.emoji-panel__item {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.emoji-panel__item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.emoji-panel__item:active {
|
||||
background: var(--accent-soft, rgba(59,130,246,0.15));
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* 表情面板遮罩(点击关闭) */
|
||||
.emoji-panel__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.input-bar__tool-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
transition: all 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-bar__tool-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input-bar__tool-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* 输入行:输入框 + 发送按钮 */
|
||||
.input-bar__input-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
文本输入框
|
||||
========================================================================== */
|
||||
.input-bar__field {
|
||||
flex: 1;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input-bar__field:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* 覆盖 Vant Field 默认样式 */
|
||||
.input-bar__field :deep(.van-field__control) {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.input-bar__field :deep(.van-field__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
发送按钮
|
||||
========================================================================== */
|
||||
.input-bar__send-btn {
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
底部引导条
|
||||
========================================================================== */
|
||||
.input-bar__guide {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 6px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* 呼叫坐席通道已开启(更醒目) */
|
||||
.input-bar__guide--active {
|
||||
color: var(--color-warning);
|
||||
font-weight: 500;
|
||||
animation: guide-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes guide-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,656 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端输入框组件
|
||||
// =============================================================================
|
||||
// 说明:底部输入框组件,包含:
|
||||
// - 输入框默认3行高度,自动扩展(max-height: 150px)
|
||||
// - 底部显示字数统计(当前/最大,如:120/500)
|
||||
// - 右下角发送按钮(icon)
|
||||
// - Enter键发送,Shift+Enter换行
|
||||
// - 空内容时禁用发送按钮
|
||||
// - 支持粘贴图片上传
|
||||
// - 支持图片/文件选择上传
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="input-box">
|
||||
<!-- 工具栏:表情/图片/文件/拍照/截图 -->
|
||||
<div class="input-box__toolbar">
|
||||
<button class="input-box__tool-btn" title="表情" @click="handleEmoji">
|
||||
<span>😊</span>
|
||||
</button>
|
||||
<button class="input-box__tool-btn" title="图片" @click="handleImage">
|
||||
<span>🖼️</span>
|
||||
</button>
|
||||
<button class="input-box__tool-btn" title="文件" @click="handleFile">
|
||||
<span>📎</span>
|
||||
</button>
|
||||
<button class="input-box__tool-btn" title="拍照" @click="handleCamera">
|
||||
<span>📷</span>
|
||||
</button>
|
||||
<button class="input-box__tool-btn" title="截图" @click="handleScreenshot">
|
||||
<span>✂️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 表情选择面板(简易版:常用 Emoji 网格) -->
|
||||
<div v-if="showEmojiPanel" class="emoji-panel">
|
||||
<div class="emoji-panel__grid">
|
||||
<button
|
||||
v-for="emoji in commonEmojis"
|
||||
:key="emoji"
|
||||
class="emoji-panel__item"
|
||||
@click="onEmojiClick(emoji)"
|
||||
>{{ emoji }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<div class="input-box__area">
|
||||
<!-- 文本输入框 — 默认3行,自适应内容高度 -->
|
||||
<textarea
|
||||
ref="inputRef"
|
||||
v-model="inputText"
|
||||
class="input-box__textarea"
|
||||
placeholder="请输入消息..."
|
||||
:rows="3"
|
||||
:style="{ height: textareaHeight + 'px' }"
|
||||
:disabled="!store.isLoggedIn"
|
||||
@keydown="handleEnterKey"
|
||||
@input="handleInput"
|
||||
@paste="handlePaste"
|
||||
></textarea>
|
||||
|
||||
<!-- 发送按钮 — 右下角,icon样式 -->
|
||||
<button
|
||||
class="input-box__send-btn"
|
||||
:class="{ 'input-box__send-btn--active': canSend }"
|
||||
:disabled="!canSend"
|
||||
:loading="store.loading"
|
||||
@click="handleSend"
|
||||
>
|
||||
<svg v-if="!store.loading" class="send-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15-2-15-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 字数统计 -->
|
||||
<div class="input-box__counter">
|
||||
{{ charCount }}/{{ maxChars }}
|
||||
</div>
|
||||
|
||||
<!-- 底部引导条 -->
|
||||
<div v-if="store.canCallAgent" class="input-box__guide input-box__guide--active">
|
||||
🔔 摇铃通道已开启,点击标题栏传菜铃呼叫 IT 坐席
|
||||
</div>
|
||||
<div v-else class="input-box__guide">
|
||||
请描述你遇到的问题,AI 助手会帮你分析 💡
|
||||
</div>
|
||||
|
||||
<!-- 表情面板打开时的半透明遮罩(点击关闭表情面板) -->
|
||||
<div v-if="showEmojiPanel" class="emoji-panel__overlay" @click="showEmojiPanel = false"></div>
|
||||
|
||||
<!-- 隐藏的文件输入框(图片/文件上传用,由工具栏按钮触发) -->
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- 隐藏的拍照输入框(移动端直接调用摄像头) -->
|
||||
<input
|
||||
ref="cameraInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
style="display: none"
|
||||
@change="handleCameraCapture"
|
||||
/>
|
||||
|
||||
<!-- 截图区域选择编辑器 -->
|
||||
<ScreenshotEditor
|
||||
v-if="showScreenshotEditor"
|
||||
:screenshot-canvas="screenshotCanvas"
|
||||
@confirm="onScreenshotConfirm"
|
||||
@cancel="onScreenshotCancel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* InputBox 输入框组件
|
||||
* 输入框默认3行高度,自动扩展(max-height: 150px)
|
||||
* 底部显示字数统计,右下角发送按钮
|
||||
* Enter发送,Shift+Enter换行
|
||||
*/
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import html2canvas from 'html2canvas-pro'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { uploadFile } from '@/api/upload'
|
||||
import ScreenshotEditor from './ScreenshotEditor.vue'
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数:安全提取错误详情
|
||||
// ============================================================================
|
||||
function formatErrorDetail(detail: any): string {
|
||||
if (!detail) return ''
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((d: any) => {
|
||||
if (d.loc && d.msg) return `${d.loc.slice(1).join('.')}: ${d.msg}`
|
||||
if (d.msg) return d.msg
|
||||
return JSON.stringify(d)
|
||||
}).join('; ')
|
||||
}
|
||||
if (typeof detail === 'object') return JSON.stringify(detail)
|
||||
return String(detail)
|
||||
}
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 输入框文本 */
|
||||
const inputText = ref<string>('')
|
||||
|
||||
/** 输入框 DOM 引用 */
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
/** 隐藏文件输入框 DOM 引用 */
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 隐藏拍照输入框 DOM 引用 */
|
||||
const cameraInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 最大字符数 */
|
||||
const maxChars = 500
|
||||
|
||||
/** textarea 高度 */
|
||||
const textareaHeight = ref(60)
|
||||
|
||||
/** 是否显示表情面板 */
|
||||
const showEmojiPanel = ref(false)
|
||||
|
||||
/** 截图编辑器是否可见 */
|
||||
const showScreenshotEditor = ref(false)
|
||||
|
||||
/** html2canvas 生成的截图 Canvas */
|
||||
let screenshotCanvas: HTMLCanvasElement | null = null
|
||||
|
||||
/** 当前字符数 */
|
||||
const charCount = computed(() => inputText.value.length))
|
||||
|
||||
/** 是否可以发送消息 */
|
||||
const canSend = computed(() => {
|
||||
return inputText.value.trim().length > 0 && !store.loading && store.isLoggedIn && charCount.value <= maxChars
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 常用表情列表
|
||||
// ============================================================================
|
||||
const commonEmojis = [
|
||||
'😀','😃','😄','😁','😆','😅','🤣','😂',
|
||||
'🙂','😊','😇','🥰','😍','🤩','😘','😗',
|
||||
'😚','😙','😋','😛','😜','🤪','😝','🤑',
|
||||
'🤗','🤭','🤫','🤔','🤐','🤨','😐','😑',
|
||||
'😶','😏','😒','🙄','😬','😮','🤯','😲',
|
||||
'😳','🥺','😢','😭','😤','😠','😡','🤬',
|
||||
'👍','👎','👏','🙌','🤝','💪','✌️','🤞',
|
||||
'❤️','🧡','💛','💚','💙','💜','💯','✅',
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// ============================================================================
|
||||
onMounted(() => {
|
||||
document.addEventListener('paste', handleDocPaste)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('paste', handleDocPaste)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 输入框高度自适应
|
||||
// ============================================================================
|
||||
function handleInput(): void {
|
||||
// 计算内容高度
|
||||
nextTick(() => {
|
||||
if (inputRef.value) {
|
||||
const scrollHeight = inputRef.value.scrollHeight
|
||||
const newHeight = Math.min(Math.max(scrollHeight, 60), 150)
|
||||
textareaHeight.value = newHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Document 级别粘贴监听
|
||||
// ============================================================================
|
||||
async function handleDocPaste(event: ClipboardEvent): Promise<void> {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
||||
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
await handleFileUpload(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 表情处理
|
||||
// ============================================================================
|
||||
function onEmojiClick(emoji: string): void {
|
||||
inputText.value += emoji
|
||||
showEmojiPanel.value = false
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function handleEmoji(): void {
|
||||
showEmojiPanel.value = !showEmojiPanel.value
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 键盘事件
|
||||
// ============================================================================
|
||||
function handleEnterKey(event: KeyboardEvent): void {
|
||||
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||
if (isMobile) return
|
||||
|
||||
// 桌面端:Shift+Enter 换行,Enter 发送
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 发送消息
|
||||
// ============================================================================
|
||||
async function handleSend(): Promise<void> {
|
||||
const content = inputText.value.trim()
|
||||
if (!content || store.loading) return
|
||||
|
||||
inputText.value = ''
|
||||
textareaHeight.value = 60
|
||||
|
||||
await store.sendNewMessage(content)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 文件上传
|
||||
// ============================================================================
|
||||
async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
await handleFileUpload(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImageUpload(file: File | Blob): Promise<void> {
|
||||
try {
|
||||
showToast('图片上传中...')
|
||||
const result = await uploadFile(file)
|
||||
const fileName = file instanceof File ? file.name : '截图'
|
||||
await store.sendNewMessage(`[图片] ${fileName}`, {
|
||||
msg_type: 'image',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
showToast('图片发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('图片上传失败:', error)
|
||||
showToast(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'图片上传失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(file: File | Blob): Promise<void> {
|
||||
try {
|
||||
const fileName = file instanceof File ? file.name : '文件'
|
||||
showToast(`文件上传中: ${fileName}`)
|
||||
const result = await uploadFile(file)
|
||||
await store.sendNewMessage(`[文件] ${result.filename}`, {
|
||||
msg_type: 'file',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
showToast('文件发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('文件上传失败:', error)
|
||||
showToast(
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'文件上传失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
showToast(`正在上传: ${file.name}`)
|
||||
const result = await uploadFile(file)
|
||||
showToast(`${file.name} 上传成功`)
|
||||
console.log('[InputBox] 文件上传成功:', result.url)
|
||||
} catch (error: any) {
|
||||
console.error('文件上传失败:', error)
|
||||
showToast(`${file.name} 上传失败`)
|
||||
}
|
||||
}
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function handleImage(): void {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = 'image/*'
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
function handleFile(): void {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.accept = ''
|
||||
fileInputRef.value.multiple = true
|
||||
fileInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCamera(): void {
|
||||
if (cameraInputRef.value) {
|
||||
cameraInputRef.value.value = ''
|
||||
cameraInputRef.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCameraCapture(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const file = files[0]
|
||||
if (file.type.startsWith('image/')) {
|
||||
await handleImageUpload(file)
|
||||
}
|
||||
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function handleScreenshot(): Promise<void> {
|
||||
try {
|
||||
showToast('正在截取页面...')
|
||||
const canvas = await html2canvas(document.body, {
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: window.devicePixelRatio || 1,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff',
|
||||
foreignObjectRendering: false,
|
||||
removeContainer: true,
|
||||
})
|
||||
screenshotCanvas = canvas
|
||||
showScreenshotEditor.value = true
|
||||
} catch (error) {
|
||||
console.error('截图失败:', error)
|
||||
showToast('截图失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
async function onScreenshotConfirm(blob: Blob): Promise<void> {
|
||||
try {
|
||||
showToast('截图上传中...')
|
||||
const result = await uploadFile(blob, 'screenshot')
|
||||
await store.sendNewMessage('[截图]', {
|
||||
msg_type: 'image',
|
||||
media_url: result.url,
|
||||
file_name: result.filename,
|
||||
file_size: result.file_size,
|
||||
})
|
||||
showToast('截图发送成功')
|
||||
} catch (error: any) {
|
||||
console.error('[InputBox] 截图发送失败:', error)
|
||||
showToast(
|
||||
`截图发送失败:${
|
||||
formatErrorDetail(error?.response?.data?.detail) ||
|
||||
error?.message ||
|
||||
'未知错误'
|
||||
}`
|
||||
)
|
||||
} finally {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
}
|
||||
}
|
||||
|
||||
function onScreenshotCancel(): void {
|
||||
showScreenshotEditor.value = false
|
||||
screenshotCanvas = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
输入框容器
|
||||
========================================================================== */
|
||||
.input-box {
|
||||
background-color: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 8px 12px;
|
||||
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.input-box__toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-box__tool-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-box__tool-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* 输入区域 */
|
||||
.input-box__area {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-box__textarea {
|
||||
flex: 1;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
resize: none;
|
||||
line-height: 1.5;
|
||||
min-height: 60px;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-box__textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input-box__textarea:disabled {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* 发送按钮 */
|
||||
.input-box__send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
cursor: not-allowed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.input-box__send-btn--active {
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-box__send-btn--active:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.send-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.input-box__send-btn--active .send-icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 字数统计 */
|
||||
.input-box__counter {
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 底部引导条 */
|
||||
.input-box__guide {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.input-box__guide--active {
|
||||
color: var(--color-warning);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 表情选择面板 */
|
||||
.emoji-panel {
|
||||
position: relative;
|
||||
z-index: 200;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.emoji-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 36px);
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.emoji-panel__item {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.emoji-panel__item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.emoji-panel__item:active {
|
||||
background: var(--accent-soft, rgba(59,130,246,0.15));
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.emoji-panel__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,675 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端消息气泡组件
|
||||
// =============================================================================
|
||||
// 说明:根据消息类型渲染不同样式的消息气泡
|
||||
// - 员工消息:靠右蓝底白字 (var(--accent))
|
||||
// - 坐席消息:靠左白底+边框,显示坐席名称
|
||||
// - AI消息:靠左绿底 (#07C160) + "[AI回复]" 标签
|
||||
// - 系统消息:居中灰字
|
||||
// 同步坐席端功能:
|
||||
// - 图片消息:缩略图展示(点击查看大图)
|
||||
// - 文件消息:文件卡片展示(点击下载)
|
||||
// - 引用回复:蓝色竖线 + 发送者 + 摘要
|
||||
// - 消息复制:长按/点击复制按钮
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<!-- 系统消息:居中灰色文字 -->
|
||||
<div v-if="msg.message_type === 'system'" class="message-bubble message-bubble--system">
|
||||
<span class="message-bubble__system-text">{{ msg.content }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 员工/坐席/AI 消息:气泡样式 -->
|
||||
<div
|
||||
v-else
|
||||
class="message-bubble"
|
||||
:class="bubbleClass"
|
||||
@mouseenter="showActions = true"
|
||||
@mouseleave="showActions = false"
|
||||
>
|
||||
<!-- 发送者名称(坐席和 AI 消息显示在左侧) -->
|
||||
<div v-if="msg.message_type !== 'employee'" class="message-bubble__sender">
|
||||
<!-- AI 消息显示 [AI回复] 标签 -->
|
||||
<span v-if="msg.message_type === 'ai'" class="message-bubble__ai-tag">[AI回复]</span>
|
||||
<span class="message-bubble__sender-name">{{ senderName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息内容气泡 -->
|
||||
<div class="message-bubble__content" :class="contentClass">
|
||||
<!-- 引用回复摘要(当此消息回复了某条消息时显示) -->
|
||||
<div
|
||||
v-if="msg.reply_to_id && replyToContent"
|
||||
class="reply-quote"
|
||||
@click.stop="$emit('scrollToMessage', msg.reply_to_id)"
|
||||
>
|
||||
<div class="reply-quote__bar"></div>
|
||||
<div class="reply-quote__text">
|
||||
<span class="reply-quote__sender">{{ replyToSender }}</span>
|
||||
{{ replyToContent }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本消息 -->
|
||||
<template v-if="!msg.msg_type || msg.msg_type === 'text'">
|
||||
<p class="message-bubble__text" style="white-space: pre-wrap;">{{ msg.content }}</p>
|
||||
</template>
|
||||
|
||||
<!-- 图片消息:显示缩略图(可点击查看大图) -->
|
||||
<template v-else-if="msg.msg_type === 'image'">
|
||||
<div class="image-message" @click="previewImage">
|
||||
<img
|
||||
v-if="msg.media_url || msg.extra_data?.pic_url"
|
||||
:src="msg.media_url || msg.extra_data?.pic_url"
|
||||
:alt="msg.file_name || '图片'"
|
||||
class="image-message__thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
<!-- 无 URL 时显示占位卡片 -->
|
||||
<div v-else class="media-card">
|
||||
<div class="media-card__icon">🖼️</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">图片消息</span>
|
||||
<span v-if="msg.file_name" class="media-card__name">{{ msg.file_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件消息:显示文件卡片 -->
|
||||
<template v-else-if="msg.msg_type === 'file'">
|
||||
<a
|
||||
v-if="msg.media_url"
|
||||
:href="msg.media_url"
|
||||
target="_blank"
|
||||
class="media-card media-card--link"
|
||||
@click.stop
|
||||
>
|
||||
<div class="media-card__icon">📎</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ msg.file_name || '文件消息' }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<!-- 无 URL 时显示纯卡片 -->
|
||||
<div v-else class="media-card">
|
||||
<div class="media-card__icon">📎</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ msg.file_name || '文件消息' }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其他非文本消息(语音/视频等):显示通用媒体卡片 -->
|
||||
<template v-else>
|
||||
<div class="media-card">
|
||||
<div class="media-card__icon">{{ mediaIcon }}</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ mediaTypeLabel }}</span>
|
||||
<span v-if="msg.file_name" class="media-card__name">{{ msg.file_name }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 操作按钮组(hover/长按 显示):复制 -->
|
||||
<div v-if="showActions" class="message-bubble__actions">
|
||||
<button
|
||||
v-if="!msg.msg_type || msg.msg_type === 'text'"
|
||||
class="action-btn"
|
||||
title="复制消息"
|
||||
@click.stop="copyMessage"
|
||||
>
|
||||
{{ copySuccess ? '✓' : '📋' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息时间 -->
|
||||
<div class="message-bubble__time" :class="timeClass">
|
||||
{{ formatTime(msg.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MessageBubble 消息气泡组件
|
||||
* 根据消息类型(employee/agent/ai/system)和内容类型(text/image/file)渲染不同样式
|
||||
* 同步坐席端功能:图片消息、文件消息、引用回复、消息复制
|
||||
* @prop msg - 消息对象
|
||||
*/
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { showToast } from 'vant'
|
||||
import type { Message } from '@/api/conversation'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 消息对象 */
|
||||
msg: Message
|
||||
}>()
|
||||
|
||||
// 引用回复点击事件(模板中使用 $emit 触发)
|
||||
defineEmits<{
|
||||
/** 点击引用回复摘要,滚动到被回复的消息 */
|
||||
(e: 'scrollToMessage', messageId: string): void
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 剪贴板相关(消息复制功能)
|
||||
// ============================================================================
|
||||
|
||||
/** useClipboard:VueUse 提供的剪贴板操作组合函数 */
|
||||
const { copy } = useClipboard()
|
||||
|
||||
/** 是否显示操作按钮(鼠标悬停时显示) */
|
||||
const showActions = ref(false)
|
||||
|
||||
/** 复制成功反馈标识(1.5秒后自动消失) */
|
||||
const copySuccess = ref(false)
|
||||
|
||||
/**
|
||||
* 复制消息内容到剪贴板。
|
||||
* 复制成功后显示 ✓ 图标 1.5 秒,并弹出 Vant Toast 提示。
|
||||
*/
|
||||
async function copyMessage(): Promise<void> {
|
||||
try {
|
||||
await copy(props.msg.content)
|
||||
copySuccess.value = true
|
||||
showToast('已复制')
|
||||
setTimeout(() => {
|
||||
copySuccess.value = false
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 气泡容器的 CSS 类名 */
|
||||
const bubbleClass = computed(() => {
|
||||
const classes = [`message-bubble--${props.msg.message_type}`]
|
||||
// 乐观更新:发送中/失败状态添加额外样式
|
||||
if (props.msg.status === 'sending') {
|
||||
classes.push('message-bubble--sending')
|
||||
} else if (props.msg.status === 'failed') {
|
||||
classes.push('message-bubble--failed')
|
||||
}
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
/** 消息内容的 CSS 类名 */
|
||||
const contentClass = computed(() => {
|
||||
return `message-bubble__content--${props.msg.message_type}`
|
||||
})
|
||||
|
||||
/** 时间的 CSS 类名(员工消息时间靠右,其他靠左) */
|
||||
const timeClass = computed(() => {
|
||||
return props.msg.message_type === 'employee'
|
||||
? 'message-bubble__time--right'
|
||||
: 'message-bubble__time--left'
|
||||
})
|
||||
|
||||
/** 发送者名称 */
|
||||
const senderName = computed(() => {
|
||||
if (props.msg.message_type === 'agent') {
|
||||
return props.msg.sender_name || 'IT坐席'
|
||||
}
|
||||
if (props.msg.message_type === 'ai') {
|
||||
return 'AI助手'
|
||||
}
|
||||
return props.msg.sender_name
|
||||
})
|
||||
|
||||
/** 消息类型对应的 Emoji 图标 */
|
||||
const mediaIcon = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
image: '🖼️',
|
||||
voice: '🎤',
|
||||
video: '🎬',
|
||||
file: '📎',
|
||||
location: '📍',
|
||||
}
|
||||
return icons[props.msg.msg_type || ''] || '📄'
|
||||
})
|
||||
|
||||
/** 消息类型对应的中文标签 */
|
||||
const mediaTypeLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
image: '图片消息',
|
||||
voice: '语音消息',
|
||||
video: '视频消息',
|
||||
file: '文件消息',
|
||||
location: '位置消息',
|
||||
}
|
||||
return labels[props.msg.msg_type || ''] || '媒体消息'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 引用回复相关计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 被回复的消息内容摘要(截取前50字) */
|
||||
const replyToContent = computed(() => {
|
||||
if (!props.msg.reply_to_id) return ''
|
||||
// H5 端暂从 store 的消息列表查找(消息列表可能不包含被引用消息)
|
||||
// 找不到时显示省略号
|
||||
return '...'
|
||||
})
|
||||
|
||||
/** 被回复的消息发送者 */
|
||||
const replyToSender = computed(() => {
|
||||
if (!props.msg.reply_to_id) return ''
|
||||
return ''
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 工具方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 格式化时间显示
|
||||
* 将 ISO 时间字符串格式化为 HH:mm 格式
|
||||
* @param isoTime ISO 格式的时间字符串
|
||||
* @returns 格式化后的时间(如 "14:30")
|
||||
*/
|
||||
function formatTime(isoTime: string): string {
|
||||
if (!isoTime) return ''
|
||||
const date = new Date(isoTime)
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小为人类可读字符串
|
||||
* @param bytes - 文件大小(字节)
|
||||
* @returns 格式化后的字符串,如 "1.5 MB"
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片预览:点击图片缩略图时,在新标签页打开大图
|
||||
*/
|
||||
function previewImage(): void {
|
||||
const url = props.msg.media_url || props.msg.extra_data?.pic_url
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// 消息气泡容器(通用)
|
||||
// ============================================================================ */
|
||||
.message-bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ====== 员工消息:靠右 ====== */
|
||||
.message-bubble--employee {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* ====== 坐席消息:靠左 ====== */
|
||||
.message-bubble--agent {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ====== AI 消息:靠左 ====== */
|
||||
.message-bubble--ai {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ====== 系统消息:居中 ====== */
|
||||
.message-bubble--system {
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 系统消息
|
||||
// ============================================================================ */
|
||||
.message-bubble__system-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-system-text);
|
||||
background-color: var(--color-system-bg);
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 发送者信息
|
||||
// ============================================================================ */
|
||||
|
||||
/* 发送者名称行 */
|
||||
.message-bubble__sender {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 3px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* AI 标签 */
|
||||
.message-bubble__ai-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
color: var(--color-ai-tag-text);
|
||||
background-color: var(--color-ai-tag-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 发送者名称 */
|
||||
.message-bubble__sender-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息内容气泡
|
||||
// ============================================================================ */
|
||||
|
||||
/* 通用内容气泡 */
|
||||
.message-bubble__content {
|
||||
max-width: 75%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 员工消息内容:蓝底白字 */
|
||||
.message-bubble__content--employee {
|
||||
background-color: var(--color-employee-bg);
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* 坐席消息内容:白底+边框(与坐席端一致的边框样式) */
|
||||
.message-bubble__content--agent {
|
||||
background-color: var(--color-agent-bg);
|
||||
border-top-left-radius: 4px;
|
||||
border: 1px solid var(--color-agent-border);
|
||||
}
|
||||
|
||||
/* AI 消息内容:绿底 */
|
||||
.message-bubble__content--ai {
|
||||
background-color: var(--color-ai-bg);
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息文字颜色
|
||||
// ============================================================================ */
|
||||
.message-bubble__text {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 员工消息文字:白色 */
|
||||
.message-bubble__content--employee .message-bubble__text {
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 坐席消息文字 */
|
||||
.message-bubble__content--agent .message-bubble__text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* AI 消息文字 */
|
||||
.message-bubble__content--ai .message-bubble__text {
|
||||
color: var(--color-ai-text);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息时间
|
||||
// ============================================================================ */
|
||||
.message-bubble__time {
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* 时间靠右(员工消息) */
|
||||
.message-bubble__time--right {
|
||||
text-align: right;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* 时间靠左(坐席/AI 消息) */
|
||||
.message-bubble__time--left {
|
||||
text-align: left;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 引用回复样式(同步坐席端 ReplyQuote)
|
||||
// ============================================================================ */
|
||||
.reply-quote {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.reply-quote:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.reply-quote__bar {
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-quote__text {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.reply-quote__sender {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 图片消息样式
|
||||
// ============================================================================ */
|
||||
.image-message {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.image-message__thumbnail {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
max-height: 180px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.image-message__thumbnail:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 文件消息媒体卡片样式
|
||||
// ============================================================================ */
|
||||
.media-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.media-card__icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.media-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-card__label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-card__name {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-card__size {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* 文件卡片链接样式 */
|
||||
.media-card--link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.media-card--link:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 操作按钮组 — 悬停在消息气泡上时显示
|
||||
// ============================================================================ */
|
||||
.message-bubble__actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 乐观更新 UI - 发送状态样式
|
||||
// ============================================================================ */
|
||||
|
||||
/* 发送中状态:半透明 + spinner */
|
||||
.message-bubble--sending .message-bubble__content {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.message-bubble--sending .message-bubble__content::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--text-placeholder);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 发送失败状态:红色边框 + 重试按钮 */
|
||||
.message-bubble--failed .message-bubble__content {
|
||||
border: 2px solid #ee0a24;
|
||||
}
|
||||
|
||||
.message-bubble--failed .message-bubble__content::after {
|
||||
content: '重试';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 12px;
|
||||
color: #ee0a24;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #ee0a24;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-bubble--failed .message-bubble__content::after:hover {
|
||||
background: #ee0a24;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,648 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端消息气泡组件
|
||||
// =============================================================================
|
||||
// 说明:单条消息的气泡展示
|
||||
// 功能:
|
||||
// - 长按/右键弹出操作菜单:复制、撤回(2分钟内)、删除
|
||||
// - 消息状态显示:发送中、已发送、已送达、已读
|
||||
// - 时间戳显示规则:同日期只显示时间,不同日期显示月日时间
|
||||
// - 消息类型:文本、图片、文件、语音、系统消息
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<!-- 系统消息:居中灰色文字 -->
|
||||
<div v-if="msg.message_type === 'system'" class="message-item message-item--system">
|
||||
<span class="message-item__system-text">{{ msg.content }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 非系统消息 -->
|
||||
<div
|
||||
v-else
|
||||
class="message-item"
|
||||
:class="bubbleClass"
|
||||
@contextmenu.prevent="showContextMenu"
|
||||
@longpress="showContextMenu"
|
||||
>
|
||||
<!-- 发送者名称(坐席和 AI 消息显示在左侧) -->
|
||||
<div v-if="msg.message_type !== 'employee'" class="message-item__sender">
|
||||
<span v-if="msg.message_type === 'ai'" class="message-item__ai-tag">[AI回复]</span>
|
||||
<span class="message-item__sender-name">{{ senderName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息内容气泡 -->
|
||||
<div class="message-item__content" :class="contentClass">
|
||||
<!-- 文本消息 -->
|
||||
<template v-if="!msg.msg_type || msg.msg_type === 'text'">
|
||||
<p class="message-item__text" style="white-space: pre-wrap;">{{ msg.content }}</p>
|
||||
</template>
|
||||
|
||||
<!-- 图片消息:显示缩略图(可点击查看大图) -->
|
||||
<template v-else-if="msg.msg_type === 'image'">
|
||||
<div class="image-message" @click="previewImage">
|
||||
<img
|
||||
v-if="msg.media_url || msg.extra_data?.pic_url"
|
||||
:src="msg.media_url || msg.extra_data?.pic_url"
|
||||
:alt="msg.file_name || '图片'"
|
||||
class="image-message__thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="media-card">
|
||||
<div class="media-card__icon">🖼️</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">图片消息</span>
|
||||
<span v-if="msg.file_name" class="media-card__name">{{ msg.file_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件消息:显示文件卡片 -->
|
||||
<template v-else-if="msg.msg_type === 'file'">
|
||||
<a
|
||||
v-if="msg.media_url"
|
||||
:href="msg.media_url"
|
||||
target="_blank"
|
||||
class="media-card media-card--link"
|
||||
>
|
||||
<div class="media-card__icon">📎</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ msg.file_name || '文件消息' }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<div v-else class="media-card">
|
||||
<div class="media-card__icon">📎</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ msg.file_name || '文件消息' }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 其他非文本消息 -->
|
||||
<template v-else>
|
||||
<div class="media-card">
|
||||
<div class="media-card__icon">{{ mediaIcon }}</div>
|
||||
<div class="media-card__info">
|
||||
<span class="media-card__label">{{ mediaTypeLabel }}</span>
|
||||
<span v-if="msg.file_name" class="media-card__name">{{ msg.file_name }}</span>
|
||||
<span v-if="msg.file_size" class="media-card__size">{{ formatFileSize(msg.file_size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 消息状态图标 -->
|
||||
<div v-if="showStatusIcon" class="message-item__status">
|
||||
{{ statusIcon }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间戳 -->
|
||||
<div class="message-item__time" :class="timeClass">
|
||||
{{ formatTime(msg.created_at) }}
|
||||
</div>
|
||||
|
||||
<!-- 操作菜单(长按/右键显示) -->
|
||||
<div v-if="contextMenuVisible" class="context-menu" :style="contextMenuStyle">
|
||||
<button class="context-menu__item" @click.stop="copyMessage">
|
||||
📋 复制
|
||||
</button>
|
||||
<button
|
||||
v-if="canRecall"
|
||||
class="context-menu__item"
|
||||
@click.stop="recallMessage"
|
||||
>
|
||||
↩️ 撤回
|
||||
</button>
|
||||
<button
|
||||
v-if="msg.message_type === 'employee'"
|
||||
class="context-menu__item context-menu__item--danger"
|
||||
@click.stop="deleteMessage"
|
||||
>
|
||||
🗑️ 删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 遮罩层(点击关闭菜单) -->
|
||||
<div v-if="contextMenuVisible" class="context-menu__overlay" @click="closeContextMenu"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MessageItem 消息气泡组件
|
||||
* 长按/右键弹出操作菜单:复制、撤回(2分钟内)、删除
|
||||
* 消息状态显示:发送中、已发送、已送达、已读
|
||||
* 时间戳显示规则:同日期只显示时间,不同日期显示月日时间
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { showToast } from 'vant'
|
||||
import type { Message } from '@/api/conversation'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 消息对象 */
|
||||
msg: Message
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 撤回消息 */
|
||||
(e: 'recall', messageId: string): void
|
||||
/** 删除消息 */
|
||||
(e: 'delete', messageId: string): void
|
||||
}>()
|
||||
|
||||
// ============================================================================
|
||||
// 剪贴板相关
|
||||
// ============================================================================
|
||||
const { copy } = useClipboard()
|
||||
|
||||
/** 是否显示操作菜单 */
|
||||
const contextMenuVisible = ref(false)
|
||||
|
||||
/** 操作菜单位置 */
|
||||
const contextMenuStyle = ref<Record<string, string>>({})
|
||||
|
||||
/** 复制成功反馈 */
|
||||
const copySuccess = ref(false)
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 气泡容器的 CSS 类名 */
|
||||
const bubbleClass = computed(() => {
|
||||
const classes = [`message-item--${props.msg.message_type}`]
|
||||
if (props.msg.status === 'sending') {
|
||||
classes.push('message-item--sending')
|
||||
} else if (props.msg.status === 'failed') {
|
||||
classes.push('message-item--failed')
|
||||
}
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
/** 消息内容的 CSS 类名 */
|
||||
const contentClass = computed(() => {
|
||||
return `message-item__content--${props.msg.message_type}`
|
||||
})
|
||||
|
||||
/** 时间的 CSS 类名 */
|
||||
const timeClass = computed(() => {
|
||||
return props.msg.message_type === 'employee'
|
||||
? 'message-item__time--right'
|
||||
: 'message-item__time--left'
|
||||
})
|
||||
|
||||
/** 发送者名称 */
|
||||
const senderName = computed(() => {
|
||||
if (props.msg.message_type === 'agent') {
|
||||
return props.msg.sender_name || 'IT坐席'
|
||||
}
|
||||
if (props.msg.message_type === 'ai') {
|
||||
return 'AI助手'
|
||||
}
|
||||
return props.msg.sender_name
|
||||
})
|
||||
|
||||
/** 是否显示状态图标 */
|
||||
const showStatusIcon = computed(() => {
|
||||
return props.msg.message_type === 'employee' && props.msg.status
|
||||
})
|
||||
|
||||
/** 状态图标 */
|
||||
const statusIcon = computed(() => {
|
||||
const statusMap: Record<string, string> = {
|
||||
sending: '⏳',
|
||||
sent: '✓',
|
||||
delivered: '✓✓',
|
||||
read: '✓✓',
|
||||
}
|
||||
return statusMap[props.msg.status || ''] || ''
|
||||
})
|
||||
|
||||
/** 是否可以撤回(2分钟内) */
|
||||
const canRecall = computed(() => {
|
||||
if (props.msg.message_type !== 'employee') return false
|
||||
if (!props.msg.created_at) return false
|
||||
const createdAt = new Date(props.msg.created_at)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - createdAt.getTime()
|
||||
const diffMinutes = diffMs / (1000 * 60)
|
||||
return diffMinutes <= 2
|
||||
})
|
||||
|
||||
/** 消息类型对应的 Emoji 图标 */
|
||||
const mediaIcon = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
image: '🖼️',
|
||||
voice: '🎤',
|
||||
video: '🎬',
|
||||
file: '📎',
|
||||
location: '📍',
|
||||
}
|
||||
return icons[props.msg.msg_type || ''] || '📄'
|
||||
})
|
||||
|
||||
/** 消息类型对应的中文标签 */
|
||||
const mediaTypeLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
image: '图片消息',
|
||||
voice: '语音消息',
|
||||
video: '视频消息',
|
||||
file: '文件消息',
|
||||
location: '位置消息',
|
||||
}
|
||||
return labels[props.msg.msg_type || ''] || '媒体消息'
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 操作菜单
|
||||
// ============================================================================
|
||||
function showContextMenu(event: MouseEvent | TouchEvent): void {
|
||||
// 计算菜单位置
|
||||
let clientX = 0
|
||||
let clientY = 0
|
||||
|
||||
if ('clientX' in event) {
|
||||
clientX = event.clientX
|
||||
clientY = event.clientY
|
||||
} else {
|
||||
clientX = event.touches[0].clientX
|
||||
clientY = event.touches[0].clientY
|
||||
}
|
||||
|
||||
contextMenuStyle.value = {
|
||||
left: `${clientX}px`,
|
||||
top: `${clientY}px`,
|
||||
}
|
||||
contextMenuVisible.value = true
|
||||
}
|
||||
|
||||
function closeContextMenu(): void {
|
||||
contextMenuVisible.value = false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 消息操作
|
||||
// ============================================================================
|
||||
async function copyMessage(): Promise<void> {
|
||||
try {
|
||||
await copy(props.msg.content)
|
||||
copySuccess.value = true
|
||||
showToast('已复制')
|
||||
closeContextMenu()
|
||||
setTimeout(() => {
|
||||
copySuccess.value = false
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function recallMessage(): void {
|
||||
emit('recall', props.msg.message_id)
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
function deleteMessage(): void {
|
||||
emit('delete', props.msg.message_id)
|
||||
closeContextMenu()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 工具方法
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 格式化时间显示
|
||||
* 同日期只显示时间(HH:mm),不同日期显示月日时间(MM-DD HH:mm)
|
||||
*/
|
||||
function formatTime(isoTime: string): string {
|
||||
if (!isoTime) return ''
|
||||
const date = new Date(isoTime)
|
||||
const now = new Date()
|
||||
const isSameDay = date.toDateString() === now.toDateString()
|
||||
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
|
||||
if (isSameDay) {
|
||||
return `${hours}:${minutes}`
|
||||
} else {
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
return `${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片预览
|
||||
*/
|
||||
function previewImage(): void {
|
||||
const url = props.msg.media_url || props.msg.extra_data?.pic_url
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// 消息气泡容器
|
||||
// ============================================================================ */
|
||||
.message-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 16px;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 员工消息:靠右 */
|
||||
.message-item--employee {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* 坐席消息:靠左 */
|
||||
.message-item--agent {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* AI 消息:靠左 */
|
||||
.message-item--ai {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* 系统消息:居中 */
|
||||
.message-item--system {
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 系统消息
|
||||
// ============================================================================ */
|
||||
.message-item__system-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-system-text);
|
||||
background-color: var(--color-system-bg);
|
||||
padding: 4px 12px;
|
||||
border-radius: 10px;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 发送者信息
|
||||
// ============================================================================ */
|
||||
.message-item__sender {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 3px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.message-item__ai-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
color: var(--color-ai-tag-text);
|
||||
background-color: var(--color-ai-tag-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message-item__sender-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息内容气泡
|
||||
// ============================================================================ */
|
||||
.message-item__content {
|
||||
max-width: 75%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 员工消息内容:蓝底白字 */
|
||||
.message-item__content--employee {
|
||||
background-color: var(--color-employee-bg);
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* 坐席消息内容:白底+边框 */
|
||||
.message-item__content--agent {
|
||||
background-color: var(--color-agent-bg);
|
||||
border-top-left-radius: 4px;
|
||||
border: 1px solid var(--color-agent-border);
|
||||
}
|
||||
|
||||
/* AI 消息内容:绿底 */
|
||||
.message-item__content--ai {
|
||||
background-color: var(--color-ai-bg);
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息文字颜色
|
||||
// ============================================================================ */
|
||||
.message-item__text {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-item__content--employee .message-item__text {
|
||||
color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.message-item__content--agent .message-item__text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.message-item__content--ai .message-item__text {
|
||||
color: var(--color-ai-text);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息状态
|
||||
// ============================================================================ */
|
||||
.message-item__status {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 消息时间
|
||||
// ============================================================================ */
|
||||
.message-item__time {
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.message-item__time--right {
|
||||
text-align: right;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.message-item__time--left {
|
||||
text-align: left;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 图片消息样式
|
||||
// ============================================================================ */
|
||||
.image-message {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.image-message__thumbnail {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
max-height: 180px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.image-message__thumbnail:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 文件消息媒体卡片样式
|
||||
// ============================================================================ */
|
||||
.media-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.media-card__icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.media-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.media-card__label {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-card__name {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-card__size {
|
||||
font-size: 11px;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.media-card--link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.media-card--link:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 操作菜单
|
||||
// ============================================================================ */
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.context-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.context-menu__item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.context-menu__item--danger {
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
.context-menu__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 发送状态样式
|
||||
// ============================================================================ */
|
||||
.message-item--sending .message-item__content {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.message-item--failed .message-item__content {
|
||||
border: 2px solid #ee0a24;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端消息列表组件
|
||||
// =============================================================================
|
||||
// 说明:消息列表容器,包含:
|
||||
// - 进入会话自动标记已读
|
||||
// - 会话列表显示最后更新时间
|
||||
// - 未读消息角标
|
||||
// - 消息轮询
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="listRef"
|
||||
class="message-list"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<!-- 消息列表 -->
|
||||
<MessageItem
|
||||
v-for="msg in messages"
|
||||
:key="msg.message_id"
|
||||
:msg="msg"
|
||||
@recall="handleRecall"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
|
||||
<!-- 加载更多指示器 -->
|
||||
<div v-if="loading" class="message-list__loading">
|
||||
<van-loading size="20px" />
|
||||
</div>
|
||||
|
||||
<!-- 无消息提示 -->
|
||||
<div v-if="!loading && messages.length === 0" class="message-list__empty">
|
||||
<div class="message-list__empty-icon">💬</div>
|
||||
<p>暂无消息</p>
|
||||
<p class="message-list__empty-hint">输入问题咨询,或 🔔 摇铃呼叫坐席</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MessageList 消息列表组件
|
||||
* 进入会话自动标记已读
|
||||
* 消息轮询
|
||||
*/
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { pollMessages, markConversationRead, recallMessage, deleteMessage } from '@/api/message'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 消息列表 DOM 引用 */
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 加载状态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 轮询定时器 */
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 消息列表 */
|
||||
const messages = ref(store.messages)
|
||||
|
||||
// ============================================================================
|
||||
// 生命周期
|
||||
// ============================================================================
|
||||
onMounted(() => {
|
||||
// 进入会话自动标记已读
|
||||
markAsRead()
|
||||
|
||||
// 启动轮询
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
watch(
|
||||
() => store.messages,
|
||||
(newMessages) => {
|
||||
messages.value = newMessages
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
|
||||
/** 标记已读 */
|
||||
async function markAsRead(): Promise<void> {
|
||||
const convId = store.currentConversation?.conversation_id
|
||||
if (!convId) return
|
||||
|
||||
try {
|
||||
await markConversationRead(convId)
|
||||
} catch (error) {
|
||||
console.error('标记已读失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动轮询 */
|
||||
function startPolling(): void {
|
||||
pollTimer = setInterval(async () => {
|
||||
await fetchNewMessages()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/** 停止轮询 */
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取新消息 */
|
||||
async function fetchNewMessages(): Promise<void> {
|
||||
if (store.loading) return
|
||||
|
||||
const convId = store.currentConversation?.conversation_id
|
||||
if (!convId) return
|
||||
|
||||
try {
|
||||
const lastMsg = messages.value[messages.value.length - 1]
|
||||
const afterMessageId = lastMsg?.message_id
|
||||
|
||||
const newMessages = await pollMessages(convId, afterMessageId)
|
||||
|
||||
if (newMessages && newMessages.length > 0) {
|
||||
// 添加新消息到列表
|
||||
for (const msg of newMessages) {
|
||||
store.messages.push(msg)
|
||||
}
|
||||
|
||||
// 自动滚动到底部
|
||||
scrollToBottom()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取新消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动到底部 */
|
||||
function scrollToBottom(): void {
|
||||
nextTick(() => {
|
||||
if (listRef.value) {
|
||||
listRef.value.scrollTop = listRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 处理滚动 */
|
||||
function handleScroll(): void {
|
||||
// 可以在这里实现加载更多历史消息
|
||||
}
|
||||
|
||||
/** 处理撤回 */
|
||||
async function handleRecall(messageId: string): Promise<void> {
|
||||
try {
|
||||
await recallMessage(messageId)
|
||||
showToast('消息已撤回')
|
||||
|
||||
// 从列表中移除消息
|
||||
const index = messages.value.findIndex((m: any) => m.message_id === messageId)
|
||||
if (index !== -1) {
|
||||
messages.value.splice(index, 1)
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || '撤回失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理删除 */
|
||||
async function handleDelete(messageId: string): Promise<void> {
|
||||
try {
|
||||
await deleteMessage(messageId)
|
||||
showToast('消息已删除')
|
||||
|
||||
// 从列表中移除消息
|
||||
const index = messages.value.findIndex((m: any) => m.message_id === messageId)
|
||||
if (index !== -1) {
|
||||
messages.value.splice(index, 1)
|
||||
}
|
||||
} catch (error: any) {
|
||||
showToast(error?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:nextTick
|
||||
function nextTick(fn: () => void): void {
|
||||
setTimeout(fn, 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// 消息列表容器
|
||||
// ============================================================================ */
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.message-list__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.message-list__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-list__empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message-list__empty p {
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message-list__empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-placeholder);
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端参与者列表组件
|
||||
// =============================================================================
|
||||
// 说明:展示会话中所有参与者信息
|
||||
// - 被邀请人列表(真实头像 + 姓名 + 部门 + 加入状态)
|
||||
// - 当前用户高亮标识
|
||||
// - 退出会话按钮(仅被邀请人可见)
|
||||
// - 头像降级策略:有avatar URL用<img>,无则显示姓名首字
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="participant-list">
|
||||
<!-- 参与者列表 -->
|
||||
<div class="participant-list__items">
|
||||
<!-- 原始员工(会话发起人) -->
|
||||
<div v-if="ownerInfo" class="participant-item participant-item--owner">
|
||||
<div class="participant-item__avatar">
|
||||
<!-- 有头像URL:渲染<img>,加载失败降级显示首字 -->
|
||||
<img
|
||||
v-if="ownerInfo.avatar"
|
||||
:src="ownerInfo.avatar"
|
||||
:alt="ownerInfo.name"
|
||||
class="participant-item__avatar-img"
|
||||
@error="onAvatarError($event)"
|
||||
/>
|
||||
<span v-else class="participant-item__avatar-letter">
|
||||
{{ avatarLetter(ownerInfo.name) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="participant-item__info">
|
||||
<span class="participant-item__name">{{ ownerInfo.name }}</span>
|
||||
<span v-if="ownerInfo.department" class="participant-item__dept">{{ ownerInfo.department }}</span>
|
||||
</div>
|
||||
<span class="participant-item__badge participant-item__badge--owner">发起人</span>
|
||||
</div>
|
||||
|
||||
<!-- 主责坐席 -->
|
||||
<div v-if="agentInfo" class="participant-item participant-item--agent">
|
||||
<div class="participant-item__avatar participant-item__avatar--agent">
|
||||
<span class="participant-item__avatar-letter">
|
||||
{{ avatarLetter(agentInfo.name) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="participant-item__info">
|
||||
<span class="participant-item__name">{{ agentInfo.name }}</span>
|
||||
<span class="participant-item__dept">IT坐席</span>
|
||||
</div>
|
||||
<span class="participant-item__badge participant-item__badge--agent">坐席</span>
|
||||
</div>
|
||||
|
||||
<!-- 被邀请参与者 -->
|
||||
<div
|
||||
v-for="p in invitedParticipants"
|
||||
:key="p.id"
|
||||
class="participant-item"
|
||||
:class="{ 'participant-item--self': p.id === currentUserId }"
|
||||
>
|
||||
<div class="participant-item__avatar">
|
||||
<!-- 有头像URL:渲染<img>,加载失败降级显示首字 -->
|
||||
<img
|
||||
v-if="p.avatar"
|
||||
:src="p.avatar"
|
||||
:alt="p.name"
|
||||
class="participant-item__avatar-img"
|
||||
@error="onAvatarError($event)"
|
||||
/>
|
||||
<span v-else class="participant-item__avatar-letter">
|
||||
{{ avatarLetter(p.name) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="participant-item__info">
|
||||
<span class="participant-item__name">
|
||||
{{ p.name }}
|
||||
<span v-if="p.id === currentUserId" class="participant-item__self-tag">(我)</span>
|
||||
</span>
|
||||
<span v-if="p.department" class="participant-item__dept">{{ p.department }}</span>
|
||||
</div>
|
||||
<!-- 加入状态 -->
|
||||
<span
|
||||
v-if="p.joined"
|
||||
class="participant-item__badge participant-item__badge--joined"
|
||||
>已加入</span>
|
||||
<span
|
||||
v-else
|
||||
class="participant-item__badge participant-item__badge--pending"
|
||||
>待加入</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 退出按钮(仅被邀请参与者可见) -->
|
||||
<div v-if="store.isParticipant" class="participant-list__footer">
|
||||
<button
|
||||
class="participant-list__leave-btn"
|
||||
:disabled="leaving"
|
||||
@click="handleLeave"
|
||||
>
|
||||
{{ leaving ? '退出中...' : '退出会话' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ParticipantList 参与者列表组件
|
||||
* 展示会话中所有参与者信息(含真实头像),被邀请人可退出
|
||||
*
|
||||
* 头像策略:
|
||||
* 1. 被邀请参与者:后端 invite_participants 时从 employees 表/企微API获取 avatar,存入 participants JSON
|
||||
* 2. 原始员工(发起人):从 employeeStore.employeeInfo.avatar 获取当前用户头像
|
||||
* 3. 坐席:暂无头像接口,使用首字母降级
|
||||
* 4. 所有 <img> 加载失败时,自动降级显示姓名首字
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { showToast, showConfirmDialog } from 'vant'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useEmployeeStore } from '@/stores/employee'
|
||||
import type { ParticipantItem } from '@/api/conversation'
|
||||
|
||||
const store = useConversationStore()
|
||||
const employeeStore = useEmployeeStore()
|
||||
|
||||
/** 退出操作进行中 */
|
||||
const leaving = ref(false)
|
||||
|
||||
/** 当前登录用户 ID */
|
||||
const currentUserId = computed(() => store.userInfo?.employee_id || '')
|
||||
|
||||
/** 原始员工信息(会话发起人),含头像 */
|
||||
const ownerInfo = computed(() => {
|
||||
const conv = store.currentConversation
|
||||
if (!conv) return null
|
||||
|
||||
// 判断发起人是否是当前用户
|
||||
const isSelf = conv.employee_id === currentUserId.value
|
||||
return {
|
||||
// 当前用户显示"我",其他用户显示真实姓名(从会话 employee_name 获取)
|
||||
name: isSelf ? '我' : (conv.employee_name || '员工'),
|
||||
department: '',
|
||||
// 当前用户的头像从 employeeStore 获取,其他用户暂无头像接口
|
||||
avatar: isSelf ? (employeeStore.employeeInfo?.avatar || '') : '',
|
||||
}
|
||||
})
|
||||
|
||||
/** 坐席信息(坐席头像暂无接口,使用首字母降级) */
|
||||
const agentInfo = computed(() => {
|
||||
const conv = store.currentConversation
|
||||
if (!conv?.agent_name) return null
|
||||
return { name: conv.agent_name }
|
||||
})
|
||||
|
||||
/** 被邀请的参与者(排除原始员工,avatar 由后端填充) */
|
||||
const invitedParticipants = computed<ParticipantItem[]>(() => {
|
||||
return store.participants || []
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取姓名首字作为降级头像
|
||||
* @param name - 姓名
|
||||
*/
|
||||
function avatarLetter(name: string): string {
|
||||
return (name || '?').charAt(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像加载失败时的降级处理
|
||||
* 做什么:隐藏 <img>,显示父容器的首字降级
|
||||
* 为什么:企微头像 URL 可能过期或网络异常
|
||||
*/
|
||||
function onAvatarError(event: Event): void {
|
||||
const img = event.target as HTMLImageElement
|
||||
// 隐藏图片元素,让 CSS 显示首字降级
|
||||
img.style.display = 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出会话
|
||||
* 做什么:被邀请人主动退出当前会话
|
||||
* 为什么:参与者不再需要参与时,可自行退出
|
||||
*/
|
||||
async function handleLeave(): Promise<void> {
|
||||
try {
|
||||
// 二次确认
|
||||
await showConfirmDialog({
|
||||
title: '退出会话',
|
||||
message: '确定要退出该会话吗?退出后将无法查看会话消息。',
|
||||
confirmButtonText: '确定退出',
|
||||
cancelButtonText: '再想想',
|
||||
confirmButtonColor: '#ee0a24',
|
||||
})
|
||||
|
||||
leaving.value = true
|
||||
await store.leaveAsParticipant()
|
||||
showToast('已退出会话')
|
||||
// 退出成功后收起面板
|
||||
store.participantPanelVisible = false
|
||||
} catch (err: any) {
|
||||
// 用户取消确认时 err 为 'cancel',不提示错误
|
||||
if (err !== 'cancel') {
|
||||
console.error('[ParticipantList] 退出失败:', err)
|
||||
showToast('退出失败,请重试')
|
||||
}
|
||||
} finally {
|
||||
leaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 参与者列表容器 */
|
||||
.participant-list {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 参与者项 */
|
||||
.participant-list__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 单个参与者 */
|
||||
.participant-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.participant-item:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* 当前用户高亮 */
|
||||
.participant-item--self {
|
||||
background-color: var(--accent-soft, rgba(59, 130, 246, 0.08));
|
||||
}
|
||||
|
||||
/* 头像容器(圆形,默认渐变背景 + 首字) */
|
||||
.participant-item__avatar {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.participant-item__avatar--agent {
|
||||
background: linear-gradient(135deg, #07C160, #00b347);
|
||||
}
|
||||
|
||||
/* 真实头像图片(覆盖在容器上) */
|
||||
.participant-item__avatar-img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* 首字降级(无头像时显示) */
|
||||
.participant-item__avatar-letter {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 信息区 */
|
||||
.participant-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.participant-item__name {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.participant-item__self-tag {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.participant-item__dept {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 状态徽标 */
|
||||
.participant-item__badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.participant-item__badge--owner {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.participant-item__badge--agent {
|
||||
background-color: rgba(7, 193, 96, 0.1);
|
||||
color: #07C160;
|
||||
}
|
||||
|
||||
.participant-item__badge--joined {
|
||||
background-color: rgba(7, 193, 96, 0.1);
|
||||
color: #07C160;
|
||||
}
|
||||
|
||||
.participant-item__badge--pending {
|
||||
background-color: rgba(255, 152, 0, 0.1);
|
||||
color: #FF9800;
|
||||
}
|
||||
|
||||
/* 底部退出按钮区域 */
|
||||
.participant-list__footer {
|
||||
padding: 12px 12px 4px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 退出按钮 */
|
||||
.participant-list__leave-btn {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
border: 1px solid #ee0a24;
|
||||
background: transparent;
|
||||
color: #ee0a24;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.participant-list__leave-btn:hover:not(:disabled) {
|
||||
background-color: rgba(238, 10, 36, 0.06);
|
||||
}
|
||||
|
||||
.participant-list__leave-btn:active:not(:disabled) {
|
||||
background-color: rgba(238, 10, 36, 0.12);
|
||||
}
|
||||
|
||||
.participant-list__leave-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,555 @@
|
||||
<template>
|
||||
<!--
|
||||
ScreenshotEditor H5 版 - 区域选择截图组件
|
||||
对标微信/企微截图体验,适配触摸操作
|
||||
1. 全屏遮罩,背景显示页面截图(变暗)
|
||||
2. 触摸拖拽框选区域
|
||||
3. 释放后显示选区和工具栏(确认/取消/重新选择)
|
||||
4. 确认后裁剪选中区域并 emit 回去
|
||||
-->
|
||||
<div class="screenshot-editor-overlay">
|
||||
<!-- 背景:页面截图(变暗) -->
|
||||
<canvas
|
||||
ref="canvasBgRef"
|
||||
class="screenshot-editor-bg"
|
||||
></canvas>
|
||||
|
||||
<!-- 选区绘制层(跟随触摸拖拽) -->
|
||||
<!--
|
||||
注意:触摸事件不使用 Vue 的 @touchstart/@touchmove 绑定,
|
||||
因为 Vue 在移动端默认用 passive 模式绑定,无法调用 preventDefault()。
|
||||
改为在 onMounted 中用 addEventListener 手动绑定非 passive 监听器。
|
||||
-->
|
||||
<div
|
||||
ref="selectionLayerRef"
|
||||
class="screenshot-selection-layer"
|
||||
>
|
||||
<!-- 暗色遮罩 -->
|
||||
<div class="screenshot-dark-overlay" :style="darkOverlayStyle"></div>
|
||||
|
||||
<!-- 选区边框 -->
|
||||
<div
|
||||
v-if="selecting || selectionComplete"
|
||||
class="screenshot-selection-box"
|
||||
:style="selectionBoxStyle"
|
||||
>
|
||||
<!-- 选区尺寸提示 -->
|
||||
<div class="screenshot-size-tip" v-if="selectionComplete">
|
||||
{{ selectionWidth }} × {{ selectionHeight }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏(选区完成后显示) -->
|
||||
<div v-if="selectionComplete" class="screenshot-toolbar">
|
||||
<button class="screenshot-toolbar-btn" @click="handleCancel" title="取消截图">
|
||||
取消
|
||||
</button>
|
||||
<button class="screenshot-toolbar-btn" @click="handleReselect" title="重新选择">
|
||||
重选
|
||||
</button>
|
||||
<button class="screenshot-toolbar-btn screenshot-toolbar-btn--primary" @click="handleConfirm" title="确认截图">
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 提示文字(未选区时显示) -->
|
||||
<div v-if="!selecting && !selectionComplete" class="screenshot-tip">
|
||||
按住屏幕拖拽选择截图区域,返回键 取消
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ScreenshotEditor H5 版
|
||||
* 做什么:实现触摸友好的区域截图功能
|
||||
* 为什么:对标微信/企微截图体验,适配移动端
|
||||
*
|
||||
* 交互流程:
|
||||
* 1. 传入页面截图的 canvas/image
|
||||
* 2. 用户触摸拖拽选择区域
|
||||
* 3. 确认后裁剪选中区域,通过 emit 返回 Blob
|
||||
* 4. 取消则关闭编辑器
|
||||
*
|
||||
* Props:
|
||||
* - visible: 是否显示编辑器
|
||||
* - screenshotCanvas: html2canvas 生成的完整页面截图(Canvas)
|
||||
*
|
||||
* Emits:
|
||||
* - confirm: 确认截图,参数是裁剪后图片的 Blob
|
||||
* - cancel: 取消截图
|
||||
*/
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
// ========== Props & Emits ==========
|
||||
interface Props {
|
||||
/** html2canvas 生成的完整页面截图 Canvas 对象 */
|
||||
screenshotCanvas: HTMLCanvasElement | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
screenshotCanvas: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', blob: Blob): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
// ========== Refs ==========
|
||||
const canvasBgRef = ref<HTMLCanvasElement | null>(null)
|
||||
const selectionLayerRef = ref<HTMLDivElement | null>(null) // 选区绘制层 DOM 引用
|
||||
|
||||
// 选区状态
|
||||
const selecting = ref(false) // 是否正在拖拽选区
|
||||
const selectionComplete = ref(false) // 选区是否完成
|
||||
const startX = ref(0) // 拖拽起点 X
|
||||
const startY = ref(0) // 拖拽起点 Y
|
||||
const endX = ref(0) // 拖拽终点 X
|
||||
const endY = ref(0) // 拖拽终点 Y
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
|
||||
/** 选区左坐标(取 start/end 最小值) */
|
||||
const selectionLeft = computed(() => Math.min(startX.value, endX.value))
|
||||
/** 选区上坐标 */
|
||||
const selectionTop = computed(() => Math.min(startY.value, endY.value))
|
||||
/** 选区宽度 */
|
||||
const selectionWidth = computed(() => Math.abs(endX.value - startX.value))
|
||||
/** 选区高度 */
|
||||
const selectionHeight = computed(() => Math.abs(endY.value - startY.value))
|
||||
|
||||
/** 选区盒模型样式 */
|
||||
const selectionBoxStyle = computed(() => ({
|
||||
left: `${selectionLeft.value}px`,
|
||||
top: `${selectionTop.value}px`,
|
||||
width: `${selectionWidth.value}px`,
|
||||
height: `${selectionHeight.value}px`,
|
||||
}))
|
||||
|
||||
/** 暗色遮罩样式 */
|
||||
const darkOverlayStyle = computed(() => {
|
||||
if (!selectionComplete.value && !selecting.value) {
|
||||
return {} // 未选区时全暗
|
||||
}
|
||||
// 使用 clip-path 挖空选区
|
||||
const left = selectionLeft.value
|
||||
const top = selectionTop.value
|
||||
const width = selectionWidth.value
|
||||
const height = selectionHeight.value
|
||||
return {
|
||||
clipPath: selecting.value
|
||||
? 'none'
|
||||
: `polygon(0% 0%, 0% 100%, ${left}px ${top}px, ${left}px ${top + height}px, ${left + width}px ${top + height}px, ${left + width}px ${top}px, 0% 100%, 100% 100%, 100% 0%)`,
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
onMounted(() => {
|
||||
// 将截图绘制到背景 canvas
|
||||
nextTick(() => {
|
||||
drawBackground()
|
||||
})
|
||||
|
||||
const layer = selectionLayerRef.value
|
||||
if (!layer) {
|
||||
console.error('[ScreenshotEditor] selectionLayerRef 为 null,无法绑定事件')
|
||||
return
|
||||
}
|
||||
|
||||
// ========= 触摸事件(移动端)=========
|
||||
// 手动绑定(非 passive 模式,允许 preventDefault 阻止页面滚动)
|
||||
// Vue 的 @touchstart/@touchmove 在移动端默认 passive,无法调用 preventDefault()
|
||||
layer.addEventListener('touchstart', onTouchStart, { passive: false })
|
||||
layer.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
layer.addEventListener('touchend', onTouchEnd)
|
||||
|
||||
// ========= 鼠标事件(桌面端 H5,企微桌面端占 70%)=========
|
||||
// 鼠标拖拽拖选区域(对标微信/企微桌面端截图体验)
|
||||
layer.addEventListener('mousedown', onMouseDown)
|
||||
// mousemove/mouseup 绑定在 document 上,防止拖拽出选区层后丢失事件
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
|
||||
console.log('[ScreenshotEditor] 事件绑定完成(触摸 + 鼠标)')
|
||||
|
||||
// 监听 ESC 键取消
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const layer = selectionLayerRef.value
|
||||
|
||||
// 清除触摸事件
|
||||
if (layer) {
|
||||
layer.removeEventListener('touchstart', onTouchStart)
|
||||
layer.removeEventListener('touchmove', onTouchMove)
|
||||
layer.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
|
||||
// 清除鼠标事件(绑定在 document 上的也要清)
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
console.log('[ScreenshotEditor] 事件已清除')
|
||||
})
|
||||
|
||||
// 监听 screenshotCanvas 变化,重新绘制背景
|
||||
watch(
|
||||
() => props.screenshotCanvas,
|
||||
() => {
|
||||
nextTick(() => drawBackground())
|
||||
}
|
||||
)
|
||||
|
||||
// ========== 方法 ==========
|
||||
|
||||
/** 将截图绘制到背景 canvas */
|
||||
function drawBackground(): void {
|
||||
const canvas = canvasBgRef.value
|
||||
if (!canvas || !props.screenshotCanvas) return
|
||||
|
||||
// 设置 canvas 尺寸为窗口大小
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// 将截图绘制到 canvas(覆盖整个窗口)
|
||||
ctx.drawImage(props.screenshotCanvas, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
/** 键盘事件:ESC 取消 */
|
||||
function onKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') {
|
||||
handleCancel()
|
||||
}
|
||||
}
|
||||
|
||||
/** 触摸开始:开始选区 */
|
||||
function onTouchStart(e: TouchEvent): void {
|
||||
// 阻止浏览器默认行为(防止页面滚动/缩放干扰拖拽选区)
|
||||
e.preventDefault()
|
||||
|
||||
// 如果选区已完成,判断是否点击在选区外
|
||||
if (selectionComplete.value) {
|
||||
const touch = e.touches[0]
|
||||
const rect = getSelectionRect()
|
||||
if (
|
||||
touch.clientX < rect.left ||
|
||||
touch.clientX > rect.right ||
|
||||
touch.clientY < rect.top ||
|
||||
touch.clientY > rect.bottom
|
||||
) {
|
||||
// 点击在选区外,重新开始选区
|
||||
resetSelection()
|
||||
} else {
|
||||
// 点击在选区内,不处理
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
selecting.value = true
|
||||
const touch = e.touches[0]
|
||||
startX.value = touch.clientX
|
||||
startY.value = touch.clientY
|
||||
endX.value = touch.clientX
|
||||
endY.value = touch.clientY
|
||||
}
|
||||
|
||||
/** 触摸移动:更新选区 */
|
||||
function onTouchMove(e: TouchEvent): void {
|
||||
if (selecting.value) {
|
||||
// 阻止默认行为(防止页面滚动干扰拖拽)
|
||||
e.preventDefault()
|
||||
const touch = e.touches[0]
|
||||
endX.value = touch.clientX
|
||||
endY.value = touch.clientY
|
||||
}
|
||||
}
|
||||
|
||||
/** 触摸结束:完成选区 */
|
||||
function onTouchEnd(): void {
|
||||
if (selecting.value) {
|
||||
selecting.value = false
|
||||
|
||||
// 判断选区是否有效(最小 10x10)
|
||||
if (selectionWidth.value < 10 || selectionHeight.value < 10) {
|
||||
// 选区太小,忽略
|
||||
resetSelection()
|
||||
return
|
||||
}
|
||||
|
||||
selectionComplete.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 鼠标事件处理(桌面端 H5 用)=========
|
||||
|
||||
/** 鼠标按下:开始选区 */
|
||||
function onMouseDown(e: MouseEvent): void {
|
||||
console.log('[ScreenshotEditor] mousedown', e.clientX, e.clientY)
|
||||
|
||||
// 如果选区已完成,判断是否点击在选区外
|
||||
if (selectionComplete.value) {
|
||||
const rect = getSelectionRect()
|
||||
if (
|
||||
e.clientX < rect.left ||
|
||||
e.clientX > rect.right ||
|
||||
e.clientY < rect.top ||
|
||||
e.clientY > rect.bottom
|
||||
) {
|
||||
// 点击在选区外,重新开始选区
|
||||
resetSelection()
|
||||
} else {
|
||||
// 点击在选区内,不处理(允许拖拽移动选区,后续可扩展)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
selecting.value = true
|
||||
startX.value = e.clientX
|
||||
startY.value = e.clientY
|
||||
endX.value = e.clientX
|
||||
endY.value = e.clientY
|
||||
}
|
||||
|
||||
/** 鼠标移动:更新选区 */
|
||||
function onMouseMove(e: MouseEvent): void {
|
||||
if (selecting.value) {
|
||||
endX.value = e.clientX
|
||||
endY.value = e.clientY
|
||||
}
|
||||
}
|
||||
|
||||
/** 鼠标抬起:完成选区 */
|
||||
function onMouseUp(): void {
|
||||
if (selecting.value) {
|
||||
selecting.value = false
|
||||
|
||||
// 判断选区是否有效(最小 10x10)
|
||||
if (selectionWidth.value < 10 || selectionHeight.value < 10) {
|
||||
resetSelection()
|
||||
return
|
||||
}
|
||||
|
||||
selectionComplete.value = true
|
||||
console.log('[ScreenshotEditor] 选区完成', getSelectionRect())
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置选区 */
|
||||
function resetSelection(): void {
|
||||
selecting.value = false
|
||||
selectionComplete.value = false
|
||||
startX.value = 0
|
||||
startY.value = 0
|
||||
endX.value = 0
|
||||
endY.value = 0
|
||||
}
|
||||
|
||||
/** 获取选区矩形(绝对坐标) */
|
||||
function getSelectionRect() {
|
||||
return {
|
||||
left: selectionLeft.value,
|
||||
top: selectionTop.value,
|
||||
right: selectionLeft.value + selectionWidth.value,
|
||||
bottom: selectionTop.value + selectionHeight.value,
|
||||
width: selectionWidth.value,
|
||||
height: selectionHeight.value,
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消截图 */
|
||||
function handleCancel(): void {
|
||||
resetSelection()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
/** 重新选择 */
|
||||
function handleReselect(): void {
|
||||
resetSelection()
|
||||
}
|
||||
|
||||
/** 确认截图:裁剪选中区域并 emit Blob */
|
||||
async function handleConfirm(): Promise<void> {
|
||||
if (!props.screenshotCanvas) {
|
||||
showToast('截图数据丢失,请重试')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 计算缩放因子(screenshotCanvas 可能包含 devicePixelRatio)
|
||||
const scaleX = props.screenshotCanvas.width / window.innerWidth
|
||||
const scaleY = props.screenshotCanvas.height / window.innerHeight
|
||||
|
||||
// 用 canvas 裁剪选中区域
|
||||
const cropCanvas = document.createElement('canvas')
|
||||
cropCanvas.width = selectionWidth.value
|
||||
cropCanvas.height = selectionHeight.value
|
||||
|
||||
const ctx = cropCanvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
showToast('截图裁剪失败(无法创建画布)')
|
||||
return
|
||||
}
|
||||
|
||||
// 从完整截图中裁剪选中区域(使用缩放后的坐标)
|
||||
ctx.drawImage(
|
||||
props.screenshotCanvas,
|
||||
selectionLeft.value * scaleX,
|
||||
selectionTop.value * scaleY,
|
||||
selectionWidth.value * scaleX,
|
||||
selectionHeight.value * scaleY,
|
||||
0,
|
||||
0,
|
||||
selectionWidth.value,
|
||||
selectionHeight.value
|
||||
)
|
||||
|
||||
// 转为 Blob
|
||||
const blob = await new Promise<Blob | null>((resolve) => {
|
||||
cropCanvas.toBlob((b) => resolve(b), 'image/png')
|
||||
})
|
||||
|
||||
if (!blob || blob.size === 0) {
|
||||
showToast('截图生成失败(裁剪结果为空),请重试')
|
||||
return
|
||||
}
|
||||
|
||||
emit('confirm', blob)
|
||||
resetSelection()
|
||||
} catch (err) {
|
||||
console.error('[ScreenshotEditor] 确认截图失败:', err)
|
||||
showToast('截图确认失败,请重试')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 全屏遮罩 */
|
||||
.screenshot-editor-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 9999;
|
||||
cursor: crosshair;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 背景 canvas(显示页面截图) */
|
||||
.screenshot-editor-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 选区绘制层 */
|
||||
.screenshot-selection-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
touch-action: none; /* 阻止浏览器默认触摸手势(滚动/缩放),确保触摸事件到达 js */
|
||||
-webkit-touch-callout: none; /* 禁止长按弹出菜单 */
|
||||
}
|
||||
|
||||
/* 暗色遮罩(未选区时全暗,选区后挖空)
|
||||
pointer-events: none — 让触摸事件穿透遮罩,到达选区层 */
|
||||
.screenshot-dark-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 选区边框 */
|
||||
.screenshot-selection-box {
|
||||
position: absolute;
|
||||
border: 2px solid #1989fa;
|
||||
background: rgba(25, 137, 250, 0.05);
|
||||
z-index: 20;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 选区尺寸提示 */
|
||||
.screenshot-size-tip {
|
||||
position: absolute;
|
||||
bottom: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.screenshot-toolbar {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 8px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.screenshot-toolbar-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.screenshot-toolbar-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.screenshot-toolbar-btn--primary {
|
||||
background: #1989fa;
|
||||
border-color: #1989fa;
|
||||
}
|
||||
.screenshot-toolbar-btn--primary:hover {
|
||||
background: #0570db;
|
||||
}
|
||||
|
||||
/* 提示文字 */
|
||||
.screenshot-tip {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,223 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — H5用户端「呼叫坐席」触发按钮
|
||||
=============================================================================
|
||||
说明:只负责触发弹窗,不直接发 shake 请求。
|
||||
点击 → 播放敲桌子动画 → emit('trigger')
|
||||
动画:静态呼吸浮动 → 悬浮放大 → 点击时双手交替敲桌子 0.8s
|
||||
============================================================================= -->
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="pound-btn"
|
||||
:class="{
|
||||
'pound-btn--pounding': isPounding,
|
||||
'pound-btn--idle': !isPounding && !disabled && !shaking,
|
||||
}"
|
||||
:disabled="disabled || shaking || isPounding"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- 左拳 -->
|
||||
<span class="pound-btn__fist pound-btn__fist--left"
|
||||
:class="{ 'pound-btn__fist--active': isPounding }">👊</span>
|
||||
<!-- 右拳 -->
|
||||
<span class="pound-btn__fist pound-btn__fist--right"
|
||||
:class="{ 'pound-btn__fist--active': isPounding }">👊</span>
|
||||
|
||||
<!-- 右上角红点(提示可点击) -->
|
||||
<span v-if="!disabled && !shaking" class="pound-btn__dot"></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ShakeButton — 「呼叫坐席」触发按钮
|
||||
* 点击只触发弹窗,不直接发请求
|
||||
* 动画:双手交替敲桌子效果
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'trigger'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
/** 是否禁用按钮 */
|
||||
disabled?: boolean
|
||||
/** 是否正在呼叫中(防止重复) */
|
||||
shaking?: boolean
|
||||
}>()
|
||||
|
||||
/** 是否正在播放敲击动画 */
|
||||
const isPounding = ref<boolean>(false)
|
||||
|
||||
/**
|
||||
* 处理点击:播放动画 → 触发弹窗
|
||||
*/
|
||||
function handleClick(): void {
|
||||
if (props.disabled || props.shaking || isPounding.value) return
|
||||
|
||||
isPounding.value = true
|
||||
setTimeout(() => {
|
||||
isPounding.value = false
|
||||
}, 800)
|
||||
|
||||
emit('trigger')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
按钮主体:圆形,橙色渐变,48px
|
||||
========================================================================== */
|
||||
.pound-btn {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, var(--color-warning), var(--color-warning));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
box-shadow: 0 2px 10px rgba(255, 87, 34, 0.4);
|
||||
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||
box-shadow 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 悬浮:放大 + 发光 */
|
||||
.pound-btn--idle:hover {
|
||||
transform: scale(1.12);
|
||||
box-shadow: 0 4px 18px rgba(255, 87, 34, 0.55);
|
||||
}
|
||||
|
||||
/* 敲击中:整体震动 */
|
||||
.pound-btn--pounding {
|
||||
animation: desk-shake 0.8s ease-in-out;
|
||||
box-shadow: 0 6px 24px rgba(255, 87, 34, 0.7);
|
||||
}
|
||||
|
||||
/* 禁用 */
|
||||
.pound-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: 0 2px 10px rgba(255, 87, 34, 0.4) !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
双拳
|
||||
========================================================================== */
|
||||
.pound-btn__fists {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pound-btn__fist {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
transition: transform 0.08s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* 静止时轻微呼吸浮动 */
|
||||
.pound-btn--idle .pound-btn__fist--left {
|
||||
animation: fist-idle-left 2s ease-in-out infinite;
|
||||
}
|
||||
.pound-btn--idle .pound-btn__fist--right {
|
||||
animation: fist-idle-right 2s ease-in-out 0.3s infinite;
|
||||
}
|
||||
|
||||
/* 敲击时 */
|
||||
.pound-btn__fist--left.pound-btn__fist--active {
|
||||
animation: fist-pound-left 0.8s ease-in-out;
|
||||
}
|
||||
.pound-btn__fist--right.pound-btn__fist--active {
|
||||
animation: fist-pound-right 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
右上角红点
|
||||
========================================================================== */
|
||||
.pound-btn__dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-danger);
|
||||
border: 1.5px solid var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
CSS 动画关键帧
|
||||
========================================================================== */
|
||||
|
||||
/* 按钮整体水平震动 — 模拟桌子被敲击 */
|
||||
@keyframes desk-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10% { transform: translateX(-4px); }
|
||||
20% { transform: translateX(4px); }
|
||||
30% { transform: translateX(-3px); }
|
||||
40% { transform: translateX(3px); }
|
||||
50% { transform: translateX(-2px); }
|
||||
60% { transform: translateX(2px); }
|
||||
70% { transform: translateX(-1px); }
|
||||
80% { transform: translateX(1px); }
|
||||
90% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* 左手拳头敲击 */
|
||||
@keyframes fist-pound-left {
|
||||
0% { transform: translateY(0) scale(1); }
|
||||
5% { transform: translateY(6px) scale(1.15); }
|
||||
10% { transform: translateY(-2px) scale(0.95); }
|
||||
15% { transform: translateY(0) scale(1); }
|
||||
25% { transform: translateY(0) scale(1); }
|
||||
30% { transform: translateY(6px) scale(1.15); }
|
||||
35% { transform: translateY(-2px) scale(0.95); }
|
||||
40% { transform: translateY(0) scale(1); }
|
||||
50% { transform: translateY(0) scale(1); }
|
||||
55% { transform: translateY(6px) scale(1.15); }
|
||||
60% { transform: translateY(-2px) scale(0.95); }
|
||||
65% { transform: translateY(0) scale(1); }
|
||||
100% { transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* 右手拳头敲击(与左手交替) */
|
||||
@keyframes fist-pound-right {
|
||||
0% { transform: translateY(0) scale(1); }
|
||||
12% { transform: translateY(6px) scale(1.15); }
|
||||
18% { transform: translateY(-2px) scale(0.95); }
|
||||
22% { transform: translateY(0) scale(1); }
|
||||
32% { transform: translateY(0) scale(1); }
|
||||
37% { transform: translateY(6px) scale(1.15); }
|
||||
42% { transform: translateY(-2px) scale(0.95); }
|
||||
47% { transform: translateY(0) scale(1); }
|
||||
57% { transform: translateY(0) scale(1); }
|
||||
62% { transform: translateY(6px) scale(1.15); }
|
||||
67% { transform: translateY(-2px) scale(0.95); }
|
||||
72% { transform: translateY(0) scale(1); }
|
||||
100% { transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* 静止时左手呼吸 */
|
||||
@keyframes fist-idle-left {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
/* 静止时右手呼吸(错开半周期) */
|
||||
@keyframes fist-idle-right {
|
||||
0%, 100% { transform: translateY(-2px); }
|
||||
50% { transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,594 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端排查步骤交互组件
|
||||
// =============================================================================
|
||||
// 说明:与坐席端同构的排查步骤功能,用户可交互
|
||||
// 核心功能:
|
||||
// 1. 横向路径进度条(和坐席端一致的 done/current/pending 三态)
|
||||
// 2. 步骤节点:展示当前操作说明(图文指引)
|
||||
// 3. 决策节点:展示问答卡片,用户点击选项聚焦问题范围
|
||||
// 4. 坐席和用户同步看到当前路径和节点
|
||||
// 数据流:
|
||||
// 坐席推进步骤 → WebSocket 推送 → store 更新 → 本组件响应式渲染
|
||||
// 用户选择选项 → WebSocket 推送 → 坐席端同步看到用户的选择
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<!-- 仅在有排查步骤时显示 -->
|
||||
<div v-if="steps.length > 0" class="ts-flow">
|
||||
<!-- ====== 路径进度条 ====== -->
|
||||
<div class="ts-flow__path">
|
||||
<div class="ts-flow__path-bar">
|
||||
<template v-for="(step, index) in steps" :key="index">
|
||||
<div
|
||||
class="ts-flow__step"
|
||||
:class="`ts-flow__step--${step.status}`"
|
||||
>
|
||||
<span
|
||||
class="ts-flow__dot"
|
||||
:class="`ts-flow__dot--${step.status}`"
|
||||
>
|
||||
<template v-if="step.status === 'done'">✓</template>
|
||||
<template v-else>{{ index + 1 }}</template>
|
||||
</span>
|
||||
<span class="ts-flow__label">{{ step.label }}</span>
|
||||
</div>
|
||||
<!-- 步骤间连接线 -->
|
||||
<span
|
||||
v-if="index < steps.length - 1"
|
||||
class="ts-flow__arrow"
|
||||
:class="{ 'ts-flow__arrow--active': step.status === 'done' }"
|
||||
>→</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 交互卡片区域 ====== -->
|
||||
<div class="ts-flow__card">
|
||||
|
||||
<!-- ===== 决策节点:问答交互卡片 ===== -->
|
||||
<template v-if="currentNode && currentNode.type === 'decision'">
|
||||
<div class="ts-flow__question">
|
||||
<div class="ts-flow__question-header">
|
||||
<span class="ts-flow__question-icon">❓</span>
|
||||
<span class="ts-flow__question-text">{{ currentNode.label }}</span>
|
||||
</div>
|
||||
<!-- 选项按钮 -->
|
||||
<div class="ts-flow__options">
|
||||
<button
|
||||
class="ts-flow__option ts-flow__option--yes"
|
||||
@click="handleOptionSelect('yes')"
|
||||
:disabled="optionSubmitting"
|
||||
>
|
||||
<span class="ts-flow__option-icon">✅</span>
|
||||
<span>{{ currentNode.yes_branch?.label || '是' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="ts-flow__option ts-flow__option--no"
|
||||
@click="handleOptionSelect('no')"
|
||||
:disabled="optionSubmitting"
|
||||
>
|
||||
<span class="ts-flow__option-icon">❌</span>
|
||||
<span>{{ currentNode.no_branch?.label || '否' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== 步骤节点:操作说明卡片 ===== -->
|
||||
<template v-else-if="currentNode && currentNode.type === 'step'">
|
||||
<div class="ts-flow__instruction">
|
||||
<div class="ts-flow__instruction-header">
|
||||
<span class="ts-flow__instruction-icon">📋</span>
|
||||
<span class="ts-flow__instruction-title">正在执行:{{ currentNode.label }}</span>
|
||||
</div>
|
||||
<!-- 步骤说明(如果有子节点,展示子步骤概要) -->
|
||||
<div v-if="currentNode.children && currentNode.children.length > 0" class="ts-flow__instruction-steps">
|
||||
<div
|
||||
v-for="(child, idx) in currentNode.children"
|
||||
:key="child.id"
|
||||
class="ts-flow__sub-step"
|
||||
:class="`ts-flow__sub-step--${child.status || 'pending'}`"
|
||||
>
|
||||
<span class="ts-flow__sub-dot">{{ idx + 1 }}</span>
|
||||
<span class="ts-flow__sub-label">{{ child.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 提示文字 -->
|
||||
<div class="ts-flow__instruction-hint">
|
||||
<span>💡 请按坐席指引操作,完成后坐席会推进到下一步</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== 无当前节点:等待坐席启动 ===== -->
|
||||
<template v-else>
|
||||
<div class="ts-flow__waiting">
|
||||
<span class="ts-flow__waiting-icon">⏳</span>
|
||||
<span>坐席正在选择排查流程,请稍候...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== 底部同步状态 ===== -->
|
||||
<div class="ts-flow__sync-status">
|
||||
<span class="ts-flow__sync-dot"></span>
|
||||
<span>排查进度实时同步 · {{ templateName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TroubleshootFlow 排查步骤交互组件
|
||||
*
|
||||
* 与坐席端 TroubleshootBar 同构但面向用户:
|
||||
* - 坐席端的决策树 → 用户端的问答卡片(用户点击选项)
|
||||
* - 坐席端的步骤路径 → 用户端的横向进度条
|
||||
* - 坐席端的流程图节点 → 用户端的操作说明卡片
|
||||
*
|
||||
* 交互流程:
|
||||
* 1. 坐席选择排查模板 → WebSocket 推送模板数据到 H5
|
||||
* 2. H5 展示第一个节点(通常是步骤节点)
|
||||
* 3. 遇到决策节点 → 展示问答卡片,用户点击选项
|
||||
* 4. 用户选择 → WebSocket 发送选择 → 后端更新流程状态
|
||||
* 5. 坐席和用户同步看到新的当前节点
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import type { FlowchartNode } from '@/api/troubleshooting'
|
||||
|
||||
// TODO: 阶段二实现 WebSocket 时,从共享模块导入类型
|
||||
// 目前使用坐席端已有的 FlowchartNode 类型
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 用户选择选项时防重复提交 */
|
||||
const optionSubmitting = ref(false)
|
||||
|
||||
/** 排查步骤列表(路径进度) — 从 store 获取 */
|
||||
const steps = computed(() => {
|
||||
return store.troubleshootingSteps || []
|
||||
})
|
||||
|
||||
/** 排查模板名称 — 从 store 获取 */
|
||||
const templateName = computed(() => {
|
||||
return store.troubleshootingTemplateName || '排查中'
|
||||
})
|
||||
|
||||
/** 当前活跃的流程图节点 — 从 store 获取 */
|
||||
const currentNode = computed<FlowchartNode | null>(() => {
|
||||
return store.troubleshootingCurrentNode || null
|
||||
})
|
||||
|
||||
/**
|
||||
* 用户选择决策节点的选项
|
||||
*
|
||||
* @param option - 'yes' 或 'no',对应 yes_branch / no_branch
|
||||
*
|
||||
* 流程:
|
||||
* 1. 本地更新状态(即时反馈)
|
||||
* 2. 通过 WebSocket 推送用户选择给坐席端
|
||||
* 3. 坐席端确认后,后端更新流程状态
|
||||
* 4. 双端同步新的当前节点
|
||||
*/
|
||||
function handleOptionSelect(option: 'yes' | 'no'): void {
|
||||
if (optionSubmitting.value) return
|
||||
optionSubmitting.value = true
|
||||
|
||||
try {
|
||||
// 本地即时反馈:标记当前决策节点为 done
|
||||
if (currentNode.value) {
|
||||
currentNode.value.status = 'done'
|
||||
}
|
||||
|
||||
// 确定下一个节点
|
||||
const nextNode = option === 'yes'
|
||||
? currentNode.value?.yes_branch
|
||||
: currentNode.value?.no_branch
|
||||
|
||||
if (nextNode) {
|
||||
// 更新 store 中的当前节点
|
||||
store.troubleshootingCurrentNode = nextNode
|
||||
nextNode.status = 'current'
|
||||
|
||||
// 如果下一个节点是步骤节点,更新路径进度
|
||||
if (nextNode.type === 'step') {
|
||||
updatePathSteps(nextNode)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 阶段二 — 通过 WebSocket 发送用户选择
|
||||
// websocket.send({
|
||||
// type: 'troubleshooting_choice',
|
||||
// conversation_id: store.currentConversation?.conversation_id,
|
||||
// node_id: currentNode.value?.id,
|
||||
// choice: option,
|
||||
// })
|
||||
console.log('[TroubleshootFlow] 用户选择:', option, '下一节点:', nextNode?.label)
|
||||
} finally {
|
||||
// 延迟重置,防止快速重复点击
|
||||
setTimeout(() => {
|
||||
optionSubmitting.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新路径步骤状态
|
||||
* 根据当前节点找到对应的路径步骤,更新为 current
|
||||
*/
|
||||
function updatePathSteps(activeNode: FlowchartNode): void {
|
||||
const currentSteps = store.troubleshootingSteps
|
||||
if (!currentSteps || currentSteps.length === 0) return
|
||||
|
||||
// 在路径步骤中查找匹配当前节点的标签
|
||||
const matchedIndex = currentSteps.findIndex(s => s.label === activeNode.label)
|
||||
if (matchedIndex >= 0) {
|
||||
// 更新所有步骤状态
|
||||
const updated = currentSteps.map((step, i) => ({
|
||||
...step,
|
||||
status: i < matchedIndex ? 'done' as const
|
||||
: i === matchedIndex ? 'current' as const
|
||||
: 'pending' as const,
|
||||
}))
|
||||
store.troubleshootingSteps = updated
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ===== 主容器 ===== */
|
||||
.ts-flow {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
/* ===== 路径进度条 ===== */
|
||||
.ts-flow__path {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.ts-flow__path-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ts-flow__path-bar::-webkit-scrollbar {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.ts-flow__path-bar::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* 单个步骤 */
|
||||
.ts-flow__step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 步骤圆点 */
|
||||
.ts-flow__dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 已完成步骤 */
|
||||
.ts-flow__dot--done {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ts-flow__step--done .ts-flow__label {
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* 当前步骤 — 脉冲动画 */
|
||||
.ts-flow__dot--current {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: ts-dot-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ts-flow__step--current .ts-flow__label {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 待处理步骤 */
|
||||
.ts-flow__dot--pending {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ts-flow__step--pending .ts-flow__label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 步骤标签 */
|
||||
.ts-flow__label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 步骤间箭头 */
|
||||
.ts-flow__arrow {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.ts-flow__arrow--active {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* ===== 交互卡片区域 ===== */
|
||||
.ts-flow__card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
border-radius: 0 0 12px 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* ===== 决策节点:问答交互 ===== */
|
||||
.ts-flow__question {
|
||||
/* 问答卡片 */
|
||||
}
|
||||
|
||||
.ts-flow__question-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ts-flow__question-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.ts-flow__question-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 选项按钮容器 */
|
||||
.ts-flow__options {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 选项按钮 */
|
||||
.ts-flow__option {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.ts-flow__option:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.ts-flow__option:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* "是"选项 — 绿色主题 */
|
||||
.ts-flow__option--yes {
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
background: rgba(34, 197, 94, 0.06);
|
||||
}
|
||||
|
||||
.ts-flow__option--yes:active:not(:disabled) {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
/* "否"选项 — 红色主题 */
|
||||
.ts-flow__option--no {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
.ts-flow__option--no:active:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.ts-flow__option-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ===== 步骤节点:操作说明 ===== */
|
||||
.ts-flow__instruction {
|
||||
/* 操作说明卡片 */
|
||||
}
|
||||
|
||||
.ts-flow__instruction-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ts-flow__instruction-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ts-flow__instruction-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 子步骤列表 */
|
||||
.ts-flow__instruction-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 10px 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.ts-flow__sub-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ts-flow__sub-dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.ts-flow__sub-step--done .ts-flow__sub-dot {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.ts-flow__sub-step--done .ts-flow__sub-label {
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.ts-flow__sub-step--current .ts-flow__sub-dot {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ts-flow__sub-step--current .ts-flow__sub-label {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ts-flow__sub-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 操作提示 */
|
||||
.ts-flow__instruction-hint {
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--accent-soft);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== 等待状态 ===== */
|
||||
.ts-flow__waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.ts-flow__waiting-icon {
|
||||
font-size: 16px;
|
||||
animation: ts-waiting 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== 底部同步状态 ===== */
|
||||
.ts-flow__sync-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.ts-flow__sync-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
animation: ts-sync-blink 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== 动画 ===== */
|
||||
@keyframes ts-dot-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px var(--accent-soft);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ts-sync-blink {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ts-waiting {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,257 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端排查步骤进度组件
|
||||
// =============================================================================
|
||||
// 说明:展示坐席端的排查步骤进度,通过 WebSocket 实时同步
|
||||
// 功能:
|
||||
// 1. 横向步骤进度条(完成/当前/待处理 三态)
|
||||
// 2. 当前步骤脉冲动画提示
|
||||
// 3. 坐席更新步骤时实时推送到用户端
|
||||
// 数据源:conversation store 中的 troubleshootingSteps
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div v-if="steps.length > 0" class="h5-ts-progress">
|
||||
<!-- 标题栏 -->
|
||||
<div class="h5-ts-progress__header">
|
||||
<span class="h5-ts-progress__title">🔧 排查进度 · {{ templateName }}</span>
|
||||
<span class="h5-ts-progress__time">{{ currentTime }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 横向步骤进度条 -->
|
||||
<div class="h5-ts-progress__bar">
|
||||
<template v-for="(step, index) in steps" :key="index">
|
||||
<div
|
||||
class="h5-ts-step"
|
||||
:class="`h5-ts-step--${step.status}`"
|
||||
>
|
||||
<!-- 步骤圆点 -->
|
||||
<span
|
||||
class="h5-ts-dot"
|
||||
:class="`h5-ts-dot--${step.status}`"
|
||||
>
|
||||
<template v-if="step.status === 'done'">✓</template>
|
||||
<template v-else>{{ index + 1 }}</template>
|
||||
</span>
|
||||
<!-- 步骤标签 -->
|
||||
<span class="h5-ts-label">{{ step.label }}</span>
|
||||
</div>
|
||||
<!-- 步骤间箭头 -->
|
||||
<span v-if="index < steps.length - 1" class="h5-ts-arrow">→</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 当前步骤说明 -->
|
||||
<div v-if="currentStepLabel" class="h5-ts-progress__current-hint">
|
||||
<span class="h5-ts-progress__current-icon">▶</span>
|
||||
正在进行:<strong>{{ currentStepLabel }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TroubleshootProgress 排查步骤进度组件
|
||||
*
|
||||
* 数据结构:
|
||||
* - steps: PathStep[] — 排查步骤列表
|
||||
* - label: 步骤名称
|
||||
* - status: 'done' | 'current' | 'pending'
|
||||
* - templateName: 排查模板名称(如"VPN连接故障")
|
||||
*
|
||||
* 数据来源:conversation store 中的 troubleshootingSteps
|
||||
* 坐席端更新步骤时 → WebSocket 推送 → store 更新 → 本组件自动响应
|
||||
*/
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
/** 当前时间(用于显示) */
|
||||
const currentTime = ref('')
|
||||
|
||||
/** 更新时间的定时器 */
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 排查步骤列表 — 从 store 获取 */
|
||||
const steps = computed(() => {
|
||||
return store.troubleshootingSteps || []
|
||||
})
|
||||
|
||||
/** 排查模板名称 — 从 store 获取 */
|
||||
const templateName = computed(() => {
|
||||
return store.troubleshootingTemplateName || '排查中'
|
||||
})
|
||||
|
||||
/** 当前步骤的标签 */
|
||||
const currentStepLabel = computed(() => {
|
||||
const current = steps.value.find(s => s.status === 'current')
|
||||
return current?.label || ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新当前时间显示
|
||||
*/
|
||||
function updateTime(): void {
|
||||
const now = new Date()
|
||||
const h = now.getHours().toString().padStart(2, '0')
|
||||
const m = now.getMinutes().toString().padStart(2, '0')
|
||||
currentTime.value = `${h}:${m}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
timer = setInterval(updateTime, 60000) // 每分钟更新
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 进度卡片容器 */
|
||||
.h5-ts-progress {
|
||||
margin: 8px 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.h5-ts-progress__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.h5-ts-progress__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.h5-ts-progress__time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 横向步骤进度条 */
|
||||
.h5-ts-progress__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.h5-ts-progress__bar::-webkit-scrollbar {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.h5-ts-progress__bar::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* 单个步骤 */
|
||||
.h5-ts-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 步骤圆点 */
|
||||
.h5-ts-dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 已完成步骤 */
|
||||
.h5-ts-dot--done {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.h5-ts-step--done .h5-ts-label {
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* 当前步骤 — 脉冲动画 */
|
||||
.h5-ts-dot--current {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: dot-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.h5-ts-step--current .h5-ts-label {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 待处理步骤 */
|
||||
.h5-ts-dot--pending {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.h5-ts-step--pending .h5-ts-label {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 步骤标签 */
|
||||
.h5-ts-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 步骤间箭头 */
|
||||
.h5-ts-arrow {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* 当前步骤说明 */
|
||||
.h5-ts-progress__current-hint {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.h5-ts-progress__current-icon {
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* 脉冲动画 */
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px var(--accent-soft);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user