v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化
This commit is contained in:
@@ -231,7 +231,7 @@ function mapMessage(raw: any): Message {
|
||||
conversation_id: raw.conversation_id || '',
|
||||
message_type: (raw.sender_type || raw.message_type || 'text') as MessageType,
|
||||
msg_type: raw.msg_type,
|
||||
content: raw.content || '',
|
||||
content: typeof raw.content === 'string' ? raw.content : (raw.content ? JSON.stringify(raw.content) : ''),
|
||||
sender_name: raw.sender_name || '',
|
||||
created_at: raw.created_at || '',
|
||||
media_url: raw.media_url,
|
||||
@@ -267,6 +267,37 @@ export async function getUser(): Promise<UserInfo> {
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端 ConversationResponse 映射为 H5 前端 ConversationInfo 格式
|
||||
*
|
||||
* 根因修复(2026-07-13):
|
||||
* 后端 ConversationResponse 使用 `id` 字段,但 H5 前端 ConversationInfo 使用 `conversation_id`。
|
||||
* 原代码直接返回 raw response,导致 currentConversation.value.conversation_id 始终为 undefined,
|
||||
* 进而使所有 WS 事件处理器中的 conversation_id 匹配检查失败(handleAiReply / handleAiThinking /
|
||||
* sendOptionSelect 等),AI 回复被静默丢弃,用户必须刷新才能看到消息。
|
||||
*
|
||||
* 映射规则:
|
||||
* - id → conversation_id
|
||||
* - assigned_agent_id → agent_id
|
||||
* - 其余字段直接透传
|
||||
*/
|
||||
function mapConversation(raw: any): ConversationInfo | null {
|
||||
if (!raw) return null
|
||||
return {
|
||||
conversation_id: raw.id || raw.conversation_id || '',
|
||||
employee_id: raw.employee_id || '',
|
||||
employee_name: raw.employee_name || '',
|
||||
status: raw.status || 'waiting',
|
||||
agent_id: raw.assigned_agent_id || raw.agent_id || '',
|
||||
agent_name: raw.agent_name || '',
|
||||
created_at: raw.created_at || '',
|
||||
updated_at: raw.updated_at || '',
|
||||
ai_substantive_reply_count: raw.ai_substantive_reply_count ?? 0,
|
||||
can_call_agent: raw.can_call_agent ?? false,
|
||||
participants: raw.participants || [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话
|
||||
* 返回当前员工正在进行的会话,如果无活跃会话则返回 null
|
||||
@@ -274,7 +305,8 @@ export async function getUser(): Promise<UserInfo> {
|
||||
*/
|
||||
export async function getCurrentConversation(): Promise<ConversationInfo | null> {
|
||||
const response: any = await apiClient.get('/h5/conversations/current')
|
||||
return response
|
||||
// 修复:后端返回 id,前端期望 conversation_id,需要映射
|
||||
return mapConversation(response)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,38 +403,6 @@ export async function getApprovalLinks(): Promise<ApprovalLink[]> {
|
||||
return (data?.items || data || []) as ApprovalLink[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 审批流程关键词 API(新增 - 用于关键词触发卡片弹窗)
|
||||
// =============================================================================
|
||||
|
||||
/** 审批关键词响应 */
|
||||
export interface ApprovalKeyword {
|
||||
keyword: string
|
||||
template_id: string
|
||||
template_name: string
|
||||
type: 'jump' | 'api'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取审批关键词列表
|
||||
* 用于前端关键词检测,触发卡片弹窗
|
||||
* @returns 审批关键词数组
|
||||
*/
|
||||
export async function getApprovalKeywords(): Promise<ApprovalKeyword[]> {
|
||||
const response: any = await apiClient.get('/approval/keywords')
|
||||
return response || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成跳转审批链接
|
||||
* @param templateId 模板ID
|
||||
* @returns 跳转链接
|
||||
*/
|
||||
export async function createApprovalJump(templateId: string): Promise<{ url: string; template_name: string }> {
|
||||
const response: any = await apiClient.post('/approval/jump', { template_id: templateId })
|
||||
return response
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 审批意图检测 API(Dify 意图置信度 — 替代纯关键词匹配)
|
||||
// =============================================================================
|
||||
|
||||
@@ -23,16 +23,20 @@ export interface DirectoryEmployee {
|
||||
|
||||
/** 组织架构树节点 */
|
||||
export interface OrgTreeNode {
|
||||
/** 节点 ID(部门名为 ID,员工为 UserID) */
|
||||
/** 节点 ID(部门为 dept_{id} 前缀,员工为 UserID) */
|
||||
id: string
|
||||
/** 显示文本(部门名或员工姓名) */
|
||||
label: string
|
||||
/** 子节点列表(仅部门节点有) */
|
||||
/** 子节点列表(部门节点有,混合子部门+员工) */
|
||||
children?: OrgTreeNode[]
|
||||
/** 是否为叶子节点(true=员工,false/undefined=部门) */
|
||||
isLeaf?: boolean
|
||||
/** 部门名称(仅叶子/员工节点有) */
|
||||
department?: string
|
||||
/** 企微原始部门ID(仅部门节点有) */
|
||||
dept_id?: number | null
|
||||
/** 父部门ID(仅部门节点有) */
|
||||
parentid?: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -303,7 +303,8 @@ async function sendImage() {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
border: 1px solid #ebedf0;
|
||||
/* 用户要求:边框宽度减少到三分之一(从1px减少到0.5px) */
|
||||
border: 0.5px solid #ebedf0;
|
||||
}
|
||||
|
||||
.image-uploader__preview-remove {
|
||||
|
||||
@@ -1,147 +1,97 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
// 企微IT智能服务台 — H5 动态推荐卡片组件(v2.0 新增)
|
||||
// 企微IT智能服务台 — H5 动态推荐卡片组件(v3.1 简化版)
|
||||
=============================================================================
|
||||
// 说明:渲染 AI 推荐的操作卡片,显示在右边栏底部"智能推荐"标签页
|
||||
// 数据来源:store.dynamicRecommendations(由 WS dynamic_recommend 事件推送)
|
||||
// 说明:渲染 AI 推荐的操作卡片,显示在右边栏
|
||||
// - v3.1 变更:移除L1/L2/L3分层标题,仅用边框颜色区分类型
|
||||
// - 绿色边框:相关推荐
|
||||
// - 橙色边框:运维提醒
|
||||
// - 灰色边框:常用资源
|
||||
// - 数据来源:
|
||||
// - store.dynamicRecommendations(由 WS dynamic_recommend 事件推送,旧格式)
|
||||
// - store.assetRecommendations(由 WS asset_recommend 事件推送,新格式)
|
||||
// 卡片类型:
|
||||
// - approval: 审批流程入口(点击打开企微审批表单)
|
||||
// - action: 操作建议(点击执行对应操作)
|
||||
// - info: 信息展示(纯文字说明,不可点击)
|
||||
// 交互:
|
||||
// - 最多 3 张卡片,超过时自动移除最旧的
|
||||
// - 每张卡片可关闭(× 按钮),关闭后从 store 移除
|
||||
// - 用户查看时清除未读计数(Badge 红点消失)
|
||||
// - info: 信息展示(纯文字说明)
|
||||
// - download: 下载操作
|
||||
// - doc/guide: 文档/指南
|
||||
// =============================================================================
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="dynamic-recommend">
|
||||
<!-- 无推荐时显示空状态 -->
|
||||
<div v-if="recommendations.length === 0" class="dynamic-recommend__empty">
|
||||
<div v-if="allRecommendations.length === 0" class="dynamic-recommend__empty">
|
||||
<div class="dynamic-recommend__empty-icon">💡</div>
|
||||
<p class="dynamic-recommend__empty-text">暂无智能推荐</p>
|
||||
<p class="dynamic-recommend__empty-hint">向 Duckula 提问后将显示相关推荐</p>
|
||||
</div>
|
||||
|
||||
<!-- 推荐卡片列表 -->
|
||||
<div v-else class="dynamic-recommend__list">
|
||||
<div
|
||||
v-for="rec in recommendations"
|
||||
:key="rec.recommend_id"
|
||||
class="recommend-card"
|
||||
:class="`recommend-card--${rec.card_type}`"
|
||||
>
|
||||
<!-- 卡片头部:类型图标 + 标题 + 关闭按钮 -->
|
||||
<div class="recommend-card__header">
|
||||
<span class="recommend-card__icon">{{ cardIcon(rec.card_type) }}</span>
|
||||
<span class="recommend-card__title">{{ rec.title }}</span>
|
||||
<button
|
||||
class="recommend-card__close"
|
||||
title="关闭推荐"
|
||||
@click="handleClose(rec.recommend_id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 卡片描述 -->
|
||||
<p v-if="rec.description" class="recommend-card__desc">{{ rec.description }}</p>
|
||||
|
||||
<!-- 卡片操作按钮 -->
|
||||
<button
|
||||
v-if="rec.card_type === 'approval' && rec.approval_type"
|
||||
class="recommend-card__btn"
|
||||
@click="handleApprovalClick(rec)"
|
||||
>
|
||||
打开审批表单
|
||||
</button>
|
||||
<button
|
||||
v-else-if="rec.card_type === 'action'"
|
||||
class="recommend-card__btn"
|
||||
@click="handleActionClick(rec)"
|
||||
>
|
||||
立即执行
|
||||
</button>
|
||||
|
||||
<!-- 置信度指示器(可选,仅 debug 模式显示) -->
|
||||
<div v-if="showConfidence" class="recommend-card__confidence">
|
||||
置信度: {{ Math.round(rec.confidence * 100) }}%
|
||||
</div>
|
||||
</div>
|
||||
<!-- 直接展示所有卡片(v3.1:移除分层标题,仅用边框颜色区分) -->
|
||||
<div v-else class="dynamic-recommend__cards">
|
||||
<RecommendCard
|
||||
v-for="rec in allRecommendations"
|
||||
:key="rec.id || rec.recommend_id"
|
||||
:card="normalizeCard(rec)"
|
||||
:class="getCardClass(rec)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DynamicRecommend 动态推荐卡片组件
|
||||
* DynamicRecommend 动态推荐卡片组件(简化版 v3.1)
|
||||
*
|
||||
* 渲染 AI 推荐的操作卡片,从 conversation store 的 dynamicRecommendations 读取数据。
|
||||
* 当组件挂载时清除未读计数(用户已看到推荐)。
|
||||
* 功能:
|
||||
* - 支持新旧两种数据格式
|
||||
* - 移除L1/L2/L3分层标题
|
||||
* - 仅用边框颜色区分类型
|
||||
*/
|
||||
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import RecommendCard from './RecommendCard.vue'
|
||||
|
||||
const store = useConversationStore()
|
||||
|
||||
// 从 store 获取推荐列表(响应式)
|
||||
const recommendations = computed(() => store.dynamicRecommendations)
|
||||
|
||||
// 是否显示置信度(可通过 URL 参数 ?debug=true 开启)
|
||||
const showConfidence = computed(() => {
|
||||
return new URLSearchParams(window.location.search).has('debug')
|
||||
// 从 store 获取推荐列表
|
||||
const allRecommendations = computed(() => {
|
||||
// 合并新旧格式的推荐数据
|
||||
const old = store.dynamicRecommendations || []
|
||||
const newList = store.assetRecommendations || []
|
||||
return [...old, ...newList]
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取卡片类型对应的图标
|
||||
* 根据推荐类型返回 CSS 类名(v3.1:用于边框颜色区分)
|
||||
*/
|
||||
function cardIcon(cardType: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
approval: '📋',
|
||||
action: '⚡',
|
||||
info: 'ℹ️',
|
||||
function getCardClass(rec: any): string {
|
||||
const layer = rec.layer || 'L1'
|
||||
if (layer === 'L1') return 'card-l1'
|
||||
if (layer === 'L2') return 'card-l2'
|
||||
return 'card-l3'
|
||||
}
|
||||
|
||||
// 标准化卡片数据(兼容新旧格式)
|
||||
function normalizeCard(rec: any) {
|
||||
return {
|
||||
id: rec.id || rec.recommend_id || `rec_${Date.now()}`,
|
||||
layer: rec.layer || 'L1',
|
||||
layer_label: rec.layer_label || '',
|
||||
source: rec.source || rec.card_type || 'unknown',
|
||||
title: rec.title || '',
|
||||
description: rec.description || '',
|
||||
icon: rec.icon,
|
||||
items: rec.items,
|
||||
action_url: rec.action_url,
|
||||
action_label: rec.action_label,
|
||||
confidence: rec.confidence,
|
||||
relevance: rec.relevance || 'high',
|
||||
card_type: rec.card_type,
|
||||
approval_type: rec.approval_type // Dify action.approval_type
|
||||
}
|
||||
return icons[cardType] || '💡'
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭推荐卡片
|
||||
*/
|
||||
function handleClose(recommendId: string): void {
|
||||
store.removeRecommend(recommendId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击审批类型推荐 — 打开企微审批表单
|
||||
* 做什么:调用 useWecomApproval composable 打开原生审批表单
|
||||
* 为什么:审批入口从聊天流移到侧边栏,点击即跳转企微原生审批页面
|
||||
*/
|
||||
function handleApprovalClick(rec: {
|
||||
recommend_id: string
|
||||
approval_type?: string
|
||||
title: string
|
||||
}): void {
|
||||
if (!rec.approval_type) return
|
||||
// 触发审批表单打开(通过 store 事件或直接调用 composable)
|
||||
// 当前实现:emit 事件让父组件处理
|
||||
console.log('[DynamicRecommend] 打开审批表单:', rec.approval_type)
|
||||
// TODO: 接入 useWecomApproval composable 打开原生审批表单
|
||||
// 该接入在 Phase 5 坐席端适配时统一处理
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击操作类型推荐 — 执行对应操作
|
||||
*/
|
||||
function handleActionClick(rec: {
|
||||
recommend_id: string
|
||||
title: string
|
||||
card_type: string
|
||||
}): void {
|
||||
console.log('[DynamicRecommend] 执行操作:', rec.title)
|
||||
// TODO: 根据 rec 中的 action 字段路由到对应操作
|
||||
// 可能的操作:打开软件安装页、打开诊断工具等
|
||||
}
|
||||
|
||||
// 组件挂载时清除未读计数
|
||||
@@ -186,123 +136,23 @@ onMounted(() => {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ====== 推荐卡片列表 ====== */
|
||||
.dynamic-recommend__list {
|
||||
/* ====== 卡片列表(v3.1:扁平化) ====== */
|
||||
.dynamic-recommend__cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ====== 单张推荐卡片 ====== */
|
||||
.recommend-card {
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
/* ====== 卡片边框颜色(v3.1新增) ====== */
|
||||
.card-l1 {
|
||||
border-left: 3px solid #07C160 !important;
|
||||
}
|
||||
|
||||
.recommend-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
border-color: var(--accent, #07C160);
|
||||
.card-l2 {
|
||||
border-left: 3px solid #FF9500 !important;
|
||||
}
|
||||
|
||||
/* 审批类型卡片左侧绿色条 */
|
||||
.recommend-card--approval {
|
||||
border-left: 3px solid var(--accent, #07C160);
|
||||
}
|
||||
|
||||
/* 操作类型卡片左侧蓝色条 */
|
||||
.recommend-card--action {
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
/* 信息类型卡片左侧灰色条 */
|
||||
.recommend-card--info {
|
||||
border-left: 3px solid var(--text-placeholder, #9ca3af);
|
||||
}
|
||||
|
||||
/* ── 卡片头部 ── */
|
||||
.recommend-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.recommend-card__icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recommend-card__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1f2937);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recommend-card__close {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
color: var(--text-placeholder, #9ca3af);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recommend-card__close:hover {
|
||||
background: var(--bg-tertiary, #f3f4f6);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* ── 卡片描述 ── */
|
||||
.recommend-card__desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
/* ── 卡片操作按钮 ── */
|
||||
.recommend-card__btn {
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
background: var(--accent, #07C160);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.recommend-card__btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.recommend-card__btn:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── 置信度(debug) ── */
|
||||
.recommend-card__confidence {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
color: var(--text-placeholder, #9ca3af);
|
||||
text-align: right;
|
||||
.card-l3 {
|
||||
border-left: 3px solid #8E8E93 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,135 +1,37 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5 排队等待组件
|
||||
// 企微IT智能服务台 — H5 排队等待组件 v3.0
|
||||
// =============================================================================
|
||||
// 说明:排队等待期间实时展示排队位置、平台统计、答题进度、积分等级
|
||||
// 1. 顶部:排队位置动画(位置前移时有动效)
|
||||
// 2. 中部:平台实时统计(活跃/排队/服务中/AI处理)
|
||||
// 3. 下部:答题区(诊断题/知识题,答对可插队+积分)
|
||||
// 说明:v3.0 变更(2026-07-17):
|
||||
// 1. 移除平台统计四宫格
|
||||
// 2. 排队卡片高度压缩50%
|
||||
// 3. 答题开关在标题栏右侧,点击展开答题区域(默认折叠)
|
||||
// 4. 保留:排队位置、前面人数、预计等待时间、积分等级
|
||||
// 数据来源:GET /api/h5/queue/status(初始化+15s轮询)+ WS queue_position_update
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="queue-waiting">
|
||||
<!-- ====== 排队位置动画区 ====== -->
|
||||
<!-- ====== 排队位置卡片(压缩后)====== -->
|
||||
<div class="queue-waiting__position-card" :class="positionCardClass">
|
||||
<!-- 段位标签 -->
|
||||
<div class="queue-waiting__segment-badge">
|
||||
<span class="segment-badge__icon">{{ segmentIcon }}</span>
|
||||
<span class="segment-badge__text">{{ queueData?.queue?.segment_label || '等待中' }}</span>
|
||||
<!-- 位置信息 -->
|
||||
<div class="position-info">
|
||||
<span class="position-icon">⏳</span>
|
||||
<span class="position-label">排队位置</span>
|
||||
<span class="position-value">{{ currentPosition > 0 ? currentPosition : '—' }}</span>
|
||||
<span v-if="currentPosition > 0" class="position-ahead">
|
||||
前面{{ queueData?.queue?.ahead_count || 0 }}人 · 等待{{ estimatedWaitText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 位置数字 -->
|
||||
<div class="queue-waiting__position-number">
|
||||
<transition name="position-flip" mode="out-in">
|
||||
<span :key="currentPosition" class="position-number__value">
|
||||
{{ currentPosition > 0 ? currentPosition : '—' }}
|
||||
</span>
|
||||
</transition>
|
||||
<span class="position-number__label">您的排队位置</span>
|
||||
</div>
|
||||
|
||||
<!-- 前面人数 -->
|
||||
<div v-if="currentPosition > 1" class="queue-waiting__ahead">
|
||||
前方还有 <strong>{{ queueData?.queue?.ahead_count || 0 }}</strong> 位同事等待
|
||||
</div>
|
||||
<div v-else-if="currentPosition === 1" class="queue-waiting__ahead queue-waiting__ahead--first">
|
||||
即将轮到您,请准备好!
|
||||
</div>
|
||||
<div v-else class="queue-waiting__ahead">
|
||||
正在为您分配坐席…
|
||||
</div>
|
||||
|
||||
<!-- 预计等待时间 -->
|
||||
<div v-if="estimatedWaitText && currentPosition > 0" class="queue-waiting__eta">
|
||||
<span class="eta-icon">⏱️</span>
|
||||
<span>预计等待 {{ estimatedWaitText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 插队进度条 -->
|
||||
<div v-if="queueData?.quiz" class="queue-waiting__jump-progress">
|
||||
<div class="jump-progress__header">
|
||||
<span class="jump-progress__title">答题插队</span>
|
||||
<span class="jump-progress__priority">
|
||||
已前移 {{ queueData.quiz.queue_priority }}/{{ queueData.quiz.max_priority }} 位
|
||||
</span>
|
||||
</div>
|
||||
<div class="jump-progress__bar">
|
||||
<div
|
||||
v-for="n in queueData.quiz.max_priority"
|
||||
:key="n"
|
||||
class="jump-progress__dot"
|
||||
:class="{ 'jump-progress__dot--filled': n <= queueData.quiz.queue_priority }"
|
||||
>
|
||||
<span v-if="n <= queueData.quiz.queue_priority" class="jump-progress__check">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="queueData.quiz.can_jump_more" class="jump-progress__hint">
|
||||
再答 {{ queueData.quiz.remaining_for_next_jump }} 题可前移 1 位
|
||||
</div>
|
||||
<div v-else class="jump-progress__hint jump-progress__hint--max">
|
||||
已达最大插队次数
|
||||
</div>
|
||||
<!-- 积分等级 -->
|
||||
<div v-if="pointsInfo" class="position-points">
|
||||
<span class="points-badge">LV.{{ pointsInfo.level_index }}</span>
|
||||
<span class="points-value">{{ pointsInfo.points }}分</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 平台实时统计 ====== -->
|
||||
<div class="queue-waiting__stats">
|
||||
<div class="stats__header">
|
||||
<span class="stats__title">平台实时状态</span>
|
||||
<span class="stats__live-indicator">
|
||||
<span class="live-dot"></span>
|
||||
<span>实时</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="stats__grid">
|
||||
<div class="stats__item">
|
||||
<div class="stats__value">{{ platformStats.total_active }}</div>
|
||||
<div class="stats__label">总活跃</div>
|
||||
</div>
|
||||
<div class="stats__item stats__item--queued">
|
||||
<div class="stats__value">{{ platformStats.queued }}</div>
|
||||
<div class="stats__label">排队中</div>
|
||||
</div>
|
||||
<div class="stats__item stats__item--serving">
|
||||
<div class="stats__value">{{ platformStats.serving }}</div>
|
||||
<div class="stats__label">服务中</div>
|
||||
</div>
|
||||
<div class="stats__item stats__item--ai">
|
||||
<div class="stats__value">{{ platformStats.ai_handling }}</div>
|
||||
<div class="stats__label">AI处理</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 积分等级区 ====== -->
|
||||
<div v-if="pointsInfo" class="queue-waiting__points">
|
||||
<div class="points__level-badge" :class="`points__level-badge--${pointsInfo.level_index}`">
|
||||
<span class="level-badge__icon">{{ levelIcon }}</span>
|
||||
<span class="level-badge__name">{{ pointsInfo.level_name }}</span>
|
||||
</div>
|
||||
<div class="points__info">
|
||||
<span class="points__value">{{ pointsInfo.points }} 积分</span>
|
||||
<span v-if="pointsInfo.next_level_name" class="points__next">
|
||||
距 {{ pointsInfo.next_level_name }} 还差 {{ pointsInfo.to_next_level }} 分
|
||||
</span>
|
||||
<span v-else class="points__next points__next--max">
|
||||
已达最高等级 🏆
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 答题区 ====== -->
|
||||
<div class="queue-waiting__quiz">
|
||||
<div class="quiz__header">
|
||||
<span class="quiz__title">{{ quizTitle }}</span>
|
||||
<span v-if="currentQuestion?.type === 'diagnostic'" class="quiz__type-badge quiz__type-badge--diag">
|
||||
诊断题
|
||||
</span>
|
||||
<span v-else class="quiz__type-badge quiz__type-badge--knowledge">
|
||||
知识题
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ====== 答题区域(点击答题挑战按钮展开,默认折叠)====== -->
|
||||
<div v-show="props.showQuiz" class="queue-waiting__quiz-section">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="quizLoading" class="quiz__loading">
|
||||
<span class="quiz__loading-spinner"></span>
|
||||
@@ -196,26 +98,31 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* QueueWaiting 排队等待组件
|
||||
* QueueWaiting 排队等待组件 v3.0
|
||||
*
|
||||
* 功能:
|
||||
* 1. 实时显示排队位置(含前移动画)
|
||||
* 2. 平台统计四宫格(总活跃/排队中/服务中/AI处理)
|
||||
* 3. 积分等级展示
|
||||
* 4. 答题系统(双模式:诊断题/知识题)
|
||||
* 5. 插队进度可视化
|
||||
* v3.0 变更(2026-07-17):
|
||||
* 1. 移除平台统计四宫格
|
||||
* 2. 排队卡片高度压缩50%
|
||||
* 3. 答题区域默认折叠,由父组件控制展开
|
||||
* 4. 保留:排队位置、前面人数、预计等待时间、积分等级
|
||||
*
|
||||
* 数据更新:
|
||||
* - 初始化时调用 GET /api/h5/queue/status
|
||||
* - 每 15 秒轮询刷新
|
||||
* - WS 事件 queue_position_update 触发即时更新
|
||||
*/
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, defineProps } from 'vue'
|
||||
import { getQueueStatus, type QueueStatusResponse } from '@/api/queue'
|
||||
import { getQuizQuestion, submitQuizAnswer, type QuizQuestion, type AnswerResult } from '@/api/quiz'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useEmployeeStore } from '@/stores/employee'
|
||||
|
||||
// ── Props ─-
|
||||
/** 是否显示答题区(默认 false,点击答题挑战按钮后展开) */
|
||||
const props = defineProps<{
|
||||
showQuiz?: boolean
|
||||
}>()
|
||||
|
||||
const store = useConversationStore()
|
||||
const employeeStore = useEmployeeStore()
|
||||
|
||||
@@ -253,21 +160,9 @@ const currentPosition = computed(() => queueData.value?.queue?.position || 0)
|
||||
/** 预计等待时间文本 */
|
||||
const estimatedWaitText = computed(() => queueData.value?.queue?.estimated_wait_text || '')
|
||||
|
||||
/** 平台统计 */
|
||||
const platformStats = computed(() => queueData.value?.platform || { total_active: 0, queued: 0, serving: 0, ai_handling: 0 })
|
||||
|
||||
/** 积分信息 */
|
||||
const pointsInfo = computed(() => queueData.value?.points || null)
|
||||
|
||||
/** 段位图标 */
|
||||
const segmentIcon = computed(() => {
|
||||
const seg = queueData.value?.queue?.segment
|
||||
if (seg === 'vip') return '⭐'
|
||||
if (seg === 'completed') return '✅'
|
||||
if (seg === 'incomplete') return '⏳'
|
||||
return '📋'
|
||||
})
|
||||
|
||||
/** 位置卡片样式类 */
|
||||
const positionCardClass = computed(() => {
|
||||
const seg = queueData.value?.queue?.segment
|
||||
@@ -278,21 +173,6 @@ const positionCardClass = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 等级图标 */
|
||||
const levelIcon = computed(() => {
|
||||
const idx = pointsInfo.value?.level_index || 1
|
||||
const icons = ['🐣', '🌱', '⚡', '🔥', '👑']
|
||||
return icons[Math.min(idx - 1, 4)] || '⭐'
|
||||
})
|
||||
|
||||
/** 答题区标题 */
|
||||
const quizTitle = computed(() => {
|
||||
if (currentQuestion.value?.type === 'diagnostic') {
|
||||
return '回答问题帮助坐席更快定位'
|
||||
}
|
||||
return '答题赢积分+插队'
|
||||
})
|
||||
|
||||
// ── 方法 ──
|
||||
|
||||
/** 获取员工ID */
|
||||
@@ -450,351 +330,83 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ====== 容器 ====== */
|
||||
/* ====== 容器(v3.0:简化布局) ====== */
|
||||
.queue-waiting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ====== 排队位置卡片 ====== */
|
||||
/* ====== 排队位置卡片(v3.0:压缩高度50%)====== */
|
||||
.queue-waiting__position-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 20px 16px 16px;
|
||||
text-align: center;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
/* VIP段位 */
|
||||
.position-card--vip {
|
||||
border-color: #f59e0b;
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.05), var(--bg-secondary));
|
||||
}
|
||||
|
||||
/* 已完成段位 */
|
||||
.position-card--completed {
|
||||
border-color: var(--color-success);
|
||||
background: linear-gradient(135deg, rgba(34, 197, 94, 0.05), var(--bg-secondary));
|
||||
}
|
||||
|
||||
/* 未完成段位 */
|
||||
.position-card--incomplete {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* 段位标签 */
|
||||
.queue-waiting__segment-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.segment-badge__icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.segment-badge__text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 位置数字 */
|
||||
.queue-waiting__position-number {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.position-number__value {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.position-number__label {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 前面人数 */
|
||||
.queue-waiting__ahead {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.queue-waiting__ahead strong {
|
||||
color: var(--accent);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.queue-waiting__ahead--first {
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
animation: pulse-text 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-text {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* 预计等待 */
|
||||
.queue-waiting__eta {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 插队进度 */
|
||||
.queue-waiting__jump-progress {
|
||||
background: var(--bg-accent-soft);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.jump-progress__header {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jump-progress__title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.jump-progress__priority {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.jump-progress__bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.jump-progress__dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-color);
|
||||
/* 位置信息 */
|
||||
.position-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.jump-progress__dot--filled {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.jump-progress__check {
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.jump-progress__hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.jump-progress__hint--max {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* ====== 平台统计 ====== */
|
||||
.queue-waiting__stats {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.stats__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stats__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stats__live-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger);
|
||||
animation: live-blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes live-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.stats__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stats__item {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
.position-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.stats__value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
.position-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stats__label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
.position-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.stats__item--queued .stats__value { color: var(--color-warning); }
|
||||
.stats__item--serving .stats__value { color: var(--color-success); }
|
||||
.stats__item--ai .stats__value { color: var(--color-info); }
|
||||
|
||||
/* ====== 积分等级区 ====== */
|
||||
.queue-waiting__points {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.points__level-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--border-radius-md);
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.points__level-badge--1 { background: rgba(34, 197, 94, 0.1); }
|
||||
.points__level-badge--2 { background: rgba(96, 165, 250, 0.1); }
|
||||
.points__level-badge--3 { background: rgba(245, 158, 11, 0.1); }
|
||||
.points__level-badge--4 { background: rgba(239, 68, 68, 0.1); }
|
||||
.points__level-badge--5 { background: rgba(168, 85, 247, 0.1); }
|
||||
|
||||
.level-badge__icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.level-badge__name {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
.position-ahead {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.points__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.points__value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.points__next {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.points__next--max {
|
||||
color: var(--color-warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ====== 答题区 ====== */
|
||||
.queue-waiting__quiz {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.quiz__header {
|
||||
/* 积分等级 */
|
||||
.position-points {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.quiz__title {
|
||||
font-size: 13px;
|
||||
.points-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quiz__type-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quiz__type-badge--diag {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.quiz__type-badge--knowledge {
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
color: var(--color-info);
|
||||
/* ====== 答题区域(v3.0:默认折叠,点击展开)====== */
|
||||
.queue-waiting__quiz-section {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
@@ -982,24 +594,6 @@ defineExpose({
|
||||
}
|
||||
|
||||
/* ====== 过渡动画 ====== */
|
||||
|
||||
/* 位置数字翻转动画 */
|
||||
.position-flip-enter-active,
|
||||
.position-flip-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.position-flip-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.position-flip-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
/* 淡入动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
// 推荐卡片组件 - 支持分层展示
|
||||
=============================================================================
|
||||
// 功能:
|
||||
// - 渲染单个推荐卡片
|
||||
// - 支持多种操作类型:download, approval, info, doc, guide
|
||||
// - 支持多操作项列表(items)
|
||||
// - 根据层级显示不同边框颜色
|
||||
// =============================================================================
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="recommend-card"
|
||||
:class="[cardLayerClass, cardSourceClass]"
|
||||
>
|
||||
<!-- 卡片头部 -->
|
||||
<div class="recommend-card__header">
|
||||
<span class="recommend-card__icon">{{ displayIcon }}</span>
|
||||
<div class="recommend-card__title-area">
|
||||
<div class="recommend-card__title">{{ card.title }}</div>
|
||||
<div v-if="card.description" class="recommend-card__desc">
|
||||
{{ card.description }}
|
||||
</div>
|
||||
</div>
|
||||
<van-tag v-if="card.confidence" size="small" type="primary">
|
||||
{{ Math.round(card.confidence * 100) }}%
|
||||
</van-tag>
|
||||
</div>
|
||||
|
||||
<!-- 卡片操作项列表 -->
|
||||
<div v-if="card.items && card.items.length > 0" class="recommend-card__items">
|
||||
<div
|
||||
v-for="(item, index) in card.items"
|
||||
:key="index"
|
||||
class="recommend-card__item"
|
||||
:class="{ 'item-clickable': isItemClickable(item) }"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<van-icon :name="getItemIcon(item.type)" class="item-icon" />
|
||||
<span class="item-label">{{ item.label }}</span>
|
||||
<van-icon v-if="isItemClickable(item)" name="arrow" class="item-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单一操作按钮(原生button确保可点击) -->
|
||||
<div class="recommend-card__action">
|
||||
<button
|
||||
type="button"
|
||||
style="width:100%;padding:8px 16px;background:#07C160;color:#fff;border:none;border-radius:4px;font-size:14px;cursor:pointer"
|
||||
@click="handleActionClick"
|
||||
>
|
||||
{{ card.action_label || '打开审批表单' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 来源标签 -->
|
||||
<div class="recommend-card__source">
|
||||
<span class="source-tag">{{ sourceLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
// v3.0: 删除 APPROVAL_URL_MAP,URL 全部由后端推送
|
||||
|
||||
// 推荐卡片类型定义
|
||||
interface RecommendItem {
|
||||
label: string
|
||||
type: 'download' | 'approval' | 'info' | 'doc' | 'guide' | 'link' | 'contact'
|
||||
url?: string
|
||||
value?: string
|
||||
copyable?: boolean
|
||||
approval_type?: string
|
||||
}
|
||||
|
||||
interface RecommendCard {
|
||||
id: string
|
||||
recommend_id?: string // 兼容旧字段
|
||||
layer?: string
|
||||
layer_label?: string
|
||||
source: string
|
||||
title: string
|
||||
description?: string
|
||||
icon?: string
|
||||
items?: RecommendItem[]
|
||||
action_url?: string
|
||||
action_label?: string
|
||||
confidence?: number
|
||||
relevance?: string
|
||||
card_type?: string // 兼容旧字段
|
||||
approval_type?: string // 审批类型(Dify action.approval_type)
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
card: RecommendCard
|
||||
}>()
|
||||
|
||||
// 计算属性:卡片层级样式
|
||||
const cardLayerClass = computed(() => {
|
||||
const layer = props.card.layer || 'L1'
|
||||
return `card-layer-${layer.toLowerCase()}`
|
||||
})
|
||||
|
||||
// 计算属性:卡片来源样式
|
||||
const cardSourceClass = computed(() => {
|
||||
return `card-source-${props.card.source || 'unknown'}`
|
||||
})
|
||||
|
||||
// 计算属性:显示图标
|
||||
const displayIcon = computed(() => {
|
||||
if (props.card.icon) return props.card.icon
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
dify_action: '💬',
|
||||
dify_intent: '💬',
|
||||
keyword_assets: '📦',
|
||||
profile_trigger: '⚠️',
|
||||
role_assets: '👤',
|
||||
approval: '📋',
|
||||
action: '⚡',
|
||||
info: 'ℹ️',
|
||||
}
|
||||
return icons[props.card.source] || '💡'
|
||||
})
|
||||
|
||||
// 计算属性:来源标签
|
||||
const sourceLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
dify_action: 'AI 智能推荐',
|
||||
dify_intent: 'AI 智能推荐',
|
||||
keyword_assets: '知识库匹配',
|
||||
profile_trigger: '系统检测',
|
||||
role_assets: '常用资源',
|
||||
approval: '审批入口',
|
||||
action: '操作建议',
|
||||
}
|
||||
return labels[props.card.source] || props.card.source
|
||||
})
|
||||
|
||||
// 判断操作项是否可点击
|
||||
function isItemClickable(item: RecommendItem): boolean {
|
||||
return !!(item.url || item.action || item.type === 'info' || item.copyable)
|
||||
}
|
||||
|
||||
// 获取操作项图标
|
||||
function getItemIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
download: 'down',
|
||||
approval: 'description',
|
||||
info: 'info-o',
|
||||
doc: 'certificate',
|
||||
guide: 'question-o',
|
||||
link: 'link',
|
||||
contact: 'phone-o'
|
||||
}
|
||||
return icons[type] || 'arrow'
|
||||
}
|
||||
|
||||
// 处理操作项点击
|
||||
function handleItemClick(item: RecommendItem) {
|
||||
if (!isItemClickable(item)) return
|
||||
|
||||
switch (item.type) {
|
||||
case 'download':
|
||||
// 下载操作
|
||||
if (item.url) {
|
||||
window.open(item.url, '_blank')
|
||||
}
|
||||
break
|
||||
|
||||
case 'approval':
|
||||
// 打开审批表单
|
||||
invokeApproval(item.approval_type || '')
|
||||
break
|
||||
|
||||
case 'info':
|
||||
// 复制信息
|
||||
if (item.copyable && item.value) {
|
||||
navigator.clipboard.writeText(item.value).then(() => {
|
||||
showToast('已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
showToast('复制失败')
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'doc':
|
||||
case 'guide':
|
||||
case 'link':
|
||||
// 打开文档/指南
|
||||
if (item.url) {
|
||||
window.open(item.url, '_blank')
|
||||
}
|
||||
break
|
||||
|
||||
case 'contact':
|
||||
// 联系方式
|
||||
if (item.value) {
|
||||
navigator.clipboard.writeText(item.value).then(() => {
|
||||
showToast('已复制联系方式')
|
||||
}).catch(() => {
|
||||
showToast('复制失败')
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// v3.0: 处理单一操作按钮点击 - 仅使用后端推送的 action_url
|
||||
function handleActionClick() {
|
||||
if (props.card.action_url) {
|
||||
window.location.href = props.card.action_url
|
||||
return
|
||||
}
|
||||
showToast('请选择审批类型')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.recommend-card {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
// 层级边框颜色
|
||||
&.card-layer-l1 {
|
||||
border-left: 3px solid #07C160;
|
||||
}
|
||||
&.card-layer-l2 {
|
||||
border-left: 3px solid #FF9500;
|
||||
}
|
||||
&.card-layer-l3 {
|
||||
border-left: 3px solid #8E8E93;
|
||||
}
|
||||
|
||||
// 来源样式
|
||||
&.card-source-dify_action .recommend-card__icon { color: #1989fa; }
|
||||
&.card-source-dify_intent .recommend-card__icon { color: #1989fa; }
|
||||
&.card-source-keyword_assets .recommend-card__icon { color: #07C160; }
|
||||
&.card-source-profile_trigger .recommend-card__icon { color: #FF9500; }
|
||||
&.card-source-role_assets .recommend-card__icon { color: #8E8E93; }
|
||||
}
|
||||
|
||||
.recommend-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.recommend-card__icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.recommend-card__title-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recommend-card__title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #323233;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.recommend-card__desc {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.recommend-card__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #ebedf0;
|
||||
}
|
||||
|
||||
.recommend-card__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&.item-clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 14px;
|
||||
color: #646566;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #323233;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recommend-card__action {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.recommend-card__source {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #ebedf0;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
font-size: 11px;
|
||||
color: #c8c9cc;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,434 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
// 推荐卡片组件 - 支持分层展示
|
||||
=============================================================================
|
||||
// 功能:
|
||||
// - 渲染单个推荐卡片
|
||||
// - 支持多种操作类型:download, approval, info, doc, guide
|
||||
// - 支持多操作项列表(items)
|
||||
// - 根据层级显示不同边框颜色
|
||||
// =============================================================================
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="recommend-card"
|
||||
:class="[cardLayerClass, cardSourceClass]"
|
||||
>
|
||||
<!-- 卡片头部 -->
|
||||
<div class="recommend-card__header">
|
||||
<span class="recommend-card__icon">{{ displayIcon }}</span>
|
||||
<div class="recommend-card__title-area">
|
||||
<div class="recommend-card__title">{{ card.title }}</div>
|
||||
<div v-if="card.description" class="recommend-card__desc">
|
||||
{{ card.description }}
|
||||
</div>
|
||||
</div>
|
||||
<van-tag v-if="card.confidence" size="small" type="primary">
|
||||
{{ Math.round(card.confidence * 100) }}%
|
||||
</van-tag>
|
||||
</div>
|
||||
|
||||
<!-- 卡片操作项列表 -->
|
||||
<div v-if="card.items && card.items.length > 0" class="recommend-card__items">
|
||||
<div
|
||||
v-for="(item, index) in card.items"
|
||||
:key="index"
|
||||
class="recommend-card__item"
|
||||
:class="{ 'item-clickable': isItemClickable(item) }"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<van-icon :name="getItemIcon(item.type)" class="item-icon" />
|
||||
<span class="item-label">{{ item.label }}</span>
|
||||
<van-icon v-if="isItemClickable(item)" name="arrow" class="item-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单一操作按钮(原生button确保可点击) -->
|
||||
<div class="recommend-card__action">
|
||||
<button
|
||||
type="button"
|
||||
style="width:100%;padding:8px 16px;background:#07C160;color:#fff;border:none;border-radius:4px;font-size:14px;cursor:pointer"
|
||||
@click="handleActionClick"
|
||||
>
|
||||
{{ card.action_label || '打开审批表单' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 来源标签 -->
|
||||
<div class="recommend-card__source">
|
||||
<span class="source-tag">{{ sourceLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { useWecomApproval } from '@/composables/useWecomApproval'
|
||||
|
||||
// 企微审批 URL 映射表(按 approval_type 查找)
|
||||
// 与 ApprovalCardModal.vue 中的 APPROVAL_OPTIONS 保持同步
|
||||
// 支持中文名称和英文ID两种key
|
||||
const APPROVAL_URL_MAP: Record<string, string> = {
|
||||
// 设备申请(分类)
|
||||
'设备申请': '_SHOW_OPTIONS_',
|
||||
// 英文ID(后端 approval_type)
|
||||
'asset_receive': 'https://itsm.servyou.com.cn/itsm-miniapp-mobile/',
|
||||
'asset_borrow': 'https://itsm.servyou.com.cn/itsm-miniapp-mobile/',
|
||||
// asset_upgrade 改用ITSM工单系统(企微审批模板 Bs7ucTGs... 已失效)
|
||||
'asset_upgrade': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE',
|
||||
'it_device_repair': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE',
|
||||
'zero_trust_vpn': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7',
|
||||
'network_access': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7',
|
||||
'event_support': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81',
|
||||
'it_support_repair': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE',
|
||||
'public_email': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7',
|
||||
// 中文名称(兼容)
|
||||
'IT资产领用': 'https://itsm.servyou.com.cn/itsm-miniapp-mobile/',
|
||||
'IT资产借用': 'https://itsm.servyou.com.cn/itsm-miniapp-mobile/',
|
||||
'IT资产升级': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE',
|
||||
// 账号权限申请(分类)
|
||||
'账号权限申请': '_SHOW_OPTIONS_',
|
||||
'VPN账号申请': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7',
|
||||
'企微外联权限': 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WrCbZd214XrDMZJiHDho7ZQHWX7gsabb7x2fF72&sp_id=&from=template_list',
|
||||
'公共邮箱账号': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7',
|
||||
// 软件服务申请(分类)
|
||||
'软件服务申请': '_SHOW_OPTIONS_',
|
||||
// 商业软件申请 改用ITSM工单系统(企微审批模板 3TmACf8D... 可能已失效)
|
||||
'商业软件申请': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%95%86%E4%B8%9A%E8%BD%AF%E4%BB%B6%E6%9C%8D%E5%8A%A1%E7%94%B3%E8%AF%B7',
|
||||
// 资产处置申请(分类)
|
||||
'资产处置申请': '_SHOW_OPTIONS_',
|
||||
'IT资产外修': 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=Bs7ucTLPo42dtj8Y1LzBoujijsa6geRWaRxZJjk4X&sp_id=&from=template_list',
|
||||
'IT资产报废': 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WroCDfWuHKyujQjatjm3AjNv67imXk5C6WNooFkb&sp_id=&from=template_list',
|
||||
'资产退还': 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4c8qt33AZ52a7n9BBWDh6PmsDnpM5B6w8geqqqoHz&sp_id=&from=template_list',
|
||||
// 办公用品申请(分类)
|
||||
'办公用品申请': '_SHOW_OPTIONS_',
|
||||
'办公用品超额领用': 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WN6zRucbjycdnR94gBvkSVuXRamX7pKW4PrmNFh&sp_id=&from=template_list',
|
||||
// 新增审批类型
|
||||
'会议室故障报修': 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4ZXJMtjQJiPXo6N5vNMK26uPRT3KTi9VvkH2NScg&sp_id=&from=template_list',
|
||||
'企业应用管理': 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WLJnRg2Se1fwizQtNvFtcYMgci1mhRJZhMw2FFKb&sp_id=&from=template_list',
|
||||
'资产变更确认': 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4cA2owjRcXRRPQ46otZvUHoWNEKL5t25tHHfeePip&sp_id=&from=template_list',
|
||||
'终端设备网络准入': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7',
|
||||
'活动与会议技术支持': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81',
|
||||
'员工IT支持与故障报修': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE',
|
||||
'公共邮箱账号申请': 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7',
|
||||
}
|
||||
|
||||
// 推荐卡片类型定义
|
||||
interface RecommendItem {
|
||||
label: string
|
||||
type: 'download' | 'approval' | 'info' | 'doc' | 'guide' | 'link' | 'contact'
|
||||
url?: string
|
||||
value?: string
|
||||
copyable?: boolean
|
||||
approval_type?: string
|
||||
}
|
||||
|
||||
interface RecommendCard {
|
||||
id: string
|
||||
recommend_id?: string // 兼容旧字段
|
||||
layer?: string
|
||||
layer_label?: string
|
||||
source: string
|
||||
title: string
|
||||
description?: string
|
||||
icon?: string
|
||||
items?: RecommendItem[]
|
||||
action_url?: string
|
||||
action_label?: string
|
||||
confidence?: number
|
||||
relevance?: string
|
||||
card_type?: string // 兼容旧字段
|
||||
approval_type?: string // 审批类型(Dify action.approval_type)
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
card: RecommendCard
|
||||
}>()
|
||||
|
||||
// 计算属性:卡片层级样式
|
||||
const cardLayerClass = computed(() => {
|
||||
const layer = props.card.layer || 'L1'
|
||||
return `card-layer-${layer.toLowerCase()}`
|
||||
})
|
||||
|
||||
// 计算属性:卡片来源样式
|
||||
const cardSourceClass = computed(() => {
|
||||
return `card-source-${props.card.source || 'unknown'}`
|
||||
})
|
||||
|
||||
// 计算属性:显示图标
|
||||
const displayIcon = computed(() => {
|
||||
if (props.card.icon) return props.card.icon
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
dify_action: '💬',
|
||||
dify_intent: '💬',
|
||||
keyword_assets: '📦',
|
||||
profile_trigger: '⚠️',
|
||||
role_assets: '👤',
|
||||
approval: '📋',
|
||||
action: '⚡',
|
||||
info: 'ℹ️',
|
||||
}
|
||||
return icons[props.card.source] || '💡'
|
||||
})
|
||||
|
||||
// 计算属性:来源标签
|
||||
const sourceLabel = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
dify_action: 'AI 智能推荐',
|
||||
dify_intent: 'AI 智能推荐',
|
||||
keyword_assets: '知识库匹配',
|
||||
profile_trigger: '系统检测',
|
||||
role_assets: '常用资源',
|
||||
approval: '审批入口',
|
||||
action: '操作建议',
|
||||
}
|
||||
return labels[props.card.source] || props.card.source
|
||||
})
|
||||
|
||||
// 判断操作项是否可点击
|
||||
function isItemClickable(item: RecommendItem): boolean {
|
||||
return !!(item.url || item.action || item.type === 'info' || item.copyable)
|
||||
}
|
||||
|
||||
// 获取操作项图标
|
||||
function getItemIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
download: 'down',
|
||||
approval: 'description',
|
||||
info: 'info-o',
|
||||
doc: 'certificate',
|
||||
guide: 'question-o',
|
||||
link: 'link',
|
||||
contact: 'phone-o'
|
||||
}
|
||||
return icons[type] || 'arrow'
|
||||
}
|
||||
|
||||
// 处理操作项点击
|
||||
function handleItemClick(item: RecommendItem) {
|
||||
if (!isItemClickable(item)) return
|
||||
|
||||
switch (item.type) {
|
||||
case 'download':
|
||||
// 下载操作
|
||||
if (item.url) {
|
||||
window.open(item.url, '_blank')
|
||||
}
|
||||
break
|
||||
|
||||
case 'approval':
|
||||
// 打开审批表单
|
||||
invokeApproval(item.approval_type || '')
|
||||
break
|
||||
|
||||
case 'info':
|
||||
// 复制信息
|
||||
if (item.copyable && item.value) {
|
||||
navigator.clipboard.writeText(item.value).then(() => {
|
||||
showToast('已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
showToast('复制失败')
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'doc':
|
||||
case 'guide':
|
||||
case 'link':
|
||||
// 打开文档/指南
|
||||
if (item.url) {
|
||||
window.open(item.url, '_blank')
|
||||
}
|
||||
break
|
||||
|
||||
case 'contact':
|
||||
// 联系方式
|
||||
if (item.value) {
|
||||
navigator.clipboard.writeText(item.value).then(() => {
|
||||
showToast('已复制联系方式')
|
||||
}).catch(() => {
|
||||
showToast('复制失败')
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 调用企微审批表单
|
||||
async function invokeApproval(approvalType: string) {
|
||||
if (!approvalType) {
|
||||
showToast('审批类型未知')
|
||||
return
|
||||
}
|
||||
|
||||
// 优先从映射表查找 URL
|
||||
const url = APPROVAL_URL_MAP[approvalType]
|
||||
if (url && url !== '_SHOW_OPTIONS_') {
|
||||
// 使用企微 SDK 智能路由打开(企微审批用原生打开,ITSM 用同窗口导航)
|
||||
const { openUrl } = useWecomApproval()
|
||||
await openUrl(url)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是分类名或未找到匹配,显示提示让用户知道可以在"更多审批"中查看
|
||||
// 这里我们提示用户在右侧栏查看
|
||||
console.warn('[RecommendCard] 审批类型需要选择:', approvalType)
|
||||
showToast('可在右侧"智能推荐"查看更多审批选项')
|
||||
}
|
||||
|
||||
// 处理单一操作按钮点击 - 强制跳转
|
||||
function handleActionClick() {
|
||||
// 优先使用后端传递的 action_url
|
||||
if (props.card.action_url) {
|
||||
window.location.href = props.card.action_url
|
||||
return
|
||||
}
|
||||
|
||||
// 备用:从映射表查找 URL
|
||||
if (props.card.approval_type) {
|
||||
const url = APPROVAL_URL_MAP[props.card.approval_type]
|
||||
if (url && url !== '_SHOW_OPTIONS_') {
|
||||
// 直接使用 window.location.href 强制跳转(确保可点击)
|
||||
window.location.href = url
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback:跳转到右侧栏的"更多审批"选项(避免硬编码失效URL)
|
||||
showToast('请点击右侧"智能推荐"选择审批类型')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.recommend-card {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
// 层级边框颜色
|
||||
&.card-layer-l1 {
|
||||
border-left: 3px solid #07C160;
|
||||
}
|
||||
&.card-layer-l2 {
|
||||
border-left: 3px solid #FF9500;
|
||||
}
|
||||
&.card-layer-l3 {
|
||||
border-left: 3px solid #8E8E93;
|
||||
}
|
||||
|
||||
// 来源样式
|
||||
&.card-source-dify_action .recommend-card__icon { color: #1989fa; }
|
||||
&.card-source-dify_intent .recommend-card__icon { color: #1989fa; }
|
||||
&.card-source-keyword_assets .recommend-card__icon { color: #07C160; }
|
||||
&.card-source-profile_trigger .recommend-card__icon { color: #FF9500; }
|
||||
&.card-source-role_assets .recommend-card__icon { color: #8E8E93; }
|
||||
}
|
||||
|
||||
.recommend-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.recommend-card__icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.recommend-card__title-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recommend-card__title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #323233;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.recommend-card__desc {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.recommend-card__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #ebedf0;
|
||||
}
|
||||
|
||||
.recommend-card__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&.item-clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #eee;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 14px;
|
||||
color: #646566;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #323233;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recommend-card__action {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.recommend-card__source {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #ebedf0;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
font-size: 11px;
|
||||
color: #c8c9cc;
|
||||
}
|
||||
</style>
|
||||
@@ -1,29 +1,34 @@
|
||||
<!--
|
||||
=============================================================================
|
||||
// 企微IT智能服务台 — H5用户端右侧面板 v2.0
|
||||
// 企微IT智能服务台 — H5用户端右侧面板 v3.0
|
||||
=============================================================================
|
||||
// 说明:桌面端右侧面板,v2.0 手风琴 + 底部标签页布局:
|
||||
// 说明:桌面端右侧面板,v3.0 手风琴 + 智能推荐 + 排队布局:
|
||||
//
|
||||
// ┌─────────────────────────────────────┐
|
||||
// │ ▸ 设备信息(默认折叠) │ ← 手风琴区域(互斥)
|
||||
// │ ▸ 自助诊断(默认折叠) │
|
||||
// ├─────────────────────────────────────┤
|
||||
// │ 智能推荐(AI 动态推荐,始终可见) │ ← 推荐区域(flex:1)
|
||||
// │ │
|
||||
// │ (DynamicRecommend 组件) │
|
||||
// │ │
|
||||
// │ ⚡ 智能推荐(始终可见,固定高度) │ ← 统一标题样式
|
||||
// │ ┌─────────────────────────────┐ │
|
||||
// │ │ 绿色边框:相关推荐 │ │
|
||||
// │ └─────────────────────────────┘ │
|
||||
// │ ┌─────────────────────────────┐ │
|
||||
// │ │ 橙色边框:运维提醒 │ │
|
||||
// │ └─────────────────────────────┘ │
|
||||
// │ ┌─────────────────────────────┐ │
|
||||
// │ │ 灰色边框:常用资源 │ │
|
||||
// │ └─────────────────────────────┘ │
|
||||
// ├─────────────────────────────────────┤
|
||||
// │ 排队等待(折叠/展开) │ ← 底部固定
|
||||
// │ ⏳ 排队位置 #5 [LV.3][答题插队] │ ← 压缩50%
|
||||
// │ ───────────────────────────────── │
|
||||
// │ [答题区域 - 默认折叠] │ ← 点击展开
|
||||
// └─────────────────────────────────────┘
|
||||
//
|
||||
// v2.0 变更(2026-07-12):
|
||||
// - 上层从"3个子模块平铺"改为"手风琴互斥折叠"
|
||||
// - 设备信息↔自助诊断互斥,展开一个自动收起另一个
|
||||
// - 新增"智能推荐"区域(DynamicRecommend 组件,Badge 红点提示)
|
||||
// - 底部推荐区域始终可见,不随手风琴折叠
|
||||
// v2.1 变更(2026-07-13):
|
||||
// - 删除"软件安装"和"资源权限"标签页,全面 AI 化
|
||||
// - 推荐区域从标签页改为直接展示,移除标签栏
|
||||
// v3.0 变更(2026-07-17):
|
||||
// - 智能推荐移到自助诊断下方,统一标题样式(图标+文字+箭头)
|
||||
// - 智能推荐:移除分类标签(L1/L2/L3),仅用边框颜色区分
|
||||
// - 排队卡片:高度压缩50%,移除平台统计四宫格
|
||||
// - 答题开关:位于排队标题栏右侧,点击展开答题区域(默认折叠)
|
||||
//
|
||||
// 注意:此面板仅在桌面端(≥500px)显示,手机端隐藏
|
||||
// =============================================================================
|
||||
@@ -63,64 +68,62 @@
|
||||
<!-- ====== 分隔线 ====== -->
|
||||
<div class="right-panel__divider"></div>
|
||||
|
||||
<!-- ====== 智能推荐区域(始终可见,flex:1)====== -->
|
||||
<div class="right-panel__recommend-section">
|
||||
<DynamicRecommend />
|
||||
<!-- ====== 智能推荐区域(统一标题样式,始终可见)====== -->
|
||||
<div class="right-panel__recommend-section" :class="{ 'accordion-item--active': isRecommendExpanded }">
|
||||
<!-- 标题栏(与设备信息/自助诊断统一样式) -->
|
||||
<div class="accordion-item__header" @click="toggleRecommend">
|
||||
<span class="accordion-item__icon">⚡</span>
|
||||
<span class="accordion-item__title">智能推荐</span>
|
||||
<span class="accordion-item__toggle">{{ isRecommendExpanded ? '▾' : '▸' }}</span>
|
||||
</div>
|
||||
<!-- 卡片内容(固定高度显示3张卡片) -->
|
||||
<div v-show="isRecommendExpanded" class="recommend-content">
|
||||
<DynamicRecommend />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== 分隔线 ====== -->
|
||||
<div class="right-panel__divider"></div>
|
||||
|
||||
<!-- ====== 排队等待(折叠/展开,与 v1 一致)====== -->
|
||||
<div class="right-panel__queue-section" :class="{ 'right-panel__queue-section--collapsed': !isQueueExpanded }">
|
||||
<!-- 折叠态:紧凑提示条 -->
|
||||
<div v-if="!isQueueExpanded" class="queue-collapsed-bar" @click="toggleQueue">
|
||||
<div class="queue-collapsed-bar__left">
|
||||
<span class="queue-collapsed-bar__icon">{{ isQueued ? '⏳' : '✅' }}</span>
|
||||
<span v-if="!isQueued" class="queue-collapsed-bar__text">当前无排队</span>
|
||||
<span v-else class="queue-collapsed-bar__text">
|
||||
排队中
|
||||
<span v-if="queuePosition > 0" class="queue-collapsed-bar__position">#{{ queuePosition }}</span>
|
||||
</span>
|
||||
<!-- ====== 排队等待(v3.0:压缩高度 + 答题开关)====== -->
|
||||
<div class="right-panel__queue-section">
|
||||
<!-- 标题栏 + 答题开关 -->
|
||||
<div class="queue-header-v3">
|
||||
<div class="queue-header-v3__left" @click="toggleQueue">
|
||||
<span class="queue-header-v3__icon">⏳</span>
|
||||
<span class="queue-header-v3__title">排队等待</span>
|
||||
<span v-if="isQueued && queuePosition > 0" class="queue-header-v3__position">#{{ queuePosition }}</span>
|
||||
</div>
|
||||
<!-- 答题开关(绿色胶囊按钮) -->
|
||||
<div class="queue-header-v3__actions">
|
||||
<button class="quiz-toggle-btn" @click="toggleQuiz">
|
||||
{{ isQuizExpanded ? '收起答题' : '答题挑战' }}
|
||||
</button>
|
||||
</div>
|
||||
<span class="queue-collapsed-bar__toggle">{{ isQueued ? '展开查看' : '展开' }} ▾</span>
|
||||
</div>
|
||||
|
||||
<!-- 展开态:标题栏 + 完整 QueueWaiting -->
|
||||
<template v-else>
|
||||
<div class="queue-header" @click="toggleQueue">
|
||||
<span class="queue-header__icon">⏳</span>
|
||||
<span class="queue-header__title">排队等待</span>
|
||||
<span v-if="isQueued" class="queue-header__badge"></span>
|
||||
<span class="queue-header__toggle">收起 ▴</span>
|
||||
</div>
|
||||
<!-- QueueWaiting 始终挂载(用 v-show 控制显隐),保证轮询和 WS 事件不中断 -->
|
||||
<div v-show="isQueueExpanded" class="right-panel__queue-content">
|
||||
<QueueWaiting ref="queueWaitingRef" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- QueueWaiting 始终挂载,通过 showQuiz prop 控制答题区显隐 -->
|
||||
<div v-show="isQueueExpanded" class="right-panel__queue-content">
|
||||
<QueueWaiting ref="queueWaitingRef" :show-quiz="isQuizExpanded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* RightPanel 右侧面板组件 v2.1
|
||||
* RightPanel 右侧面板组件 v3.0
|
||||
*
|
||||
* v2.1 布局(2026-07-13 改造):
|
||||
* v3.0 布局(2026-07-17 改造):
|
||||
* - 手风琴区域:设备信息 ↔ 自助诊断(互斥折叠,均默认折叠)
|
||||
* - 智能推荐区域:DynamicRecommend 组件(始终可见,无标签切换)
|
||||
* - 排队等待:折叠/展开(与 v1 一致)
|
||||
* - 智能推荐区域:统一标题样式(图标+文字+箭头),点击展开/折叠
|
||||
* - 移除L1/L2/L3分类标签,仅用边框颜色区分
|
||||
* - 排队等待:压缩高度50%,移除平台统计,新增答题开关
|
||||
*
|
||||
* 手风琴互斥逻辑:
|
||||
* - activeAccordion 为 'device' | 'diagnosis' | null
|
||||
* - 点击已展开的项 → 折叠(设为 null)
|
||||
* - 点击未展开的项 → 展开该项(同时折叠另一项)
|
||||
*
|
||||
* 智能推荐逻辑:
|
||||
* - 推荐区域始终可见(无标签栏切换)
|
||||
* - 有新推荐时自动清除未读计数(内容已直接展示)
|
||||
*
|
||||
* 仅在桌面端(≥500px)显示
|
||||
*/
|
||||
|
||||
@@ -155,6 +158,20 @@ function toggleAccordion(item: 'device' | 'diagnosis'): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 智能推荐展开/折叠状态
|
||||
// ============================================================================
|
||||
|
||||
/** 智能推荐是否展开 */
|
||||
const isRecommendExpanded = ref(true) // 默认展开
|
||||
|
||||
/**
|
||||
* 切换智能推荐展开/折叠
|
||||
*/
|
||||
function toggleRecommend(): void {
|
||||
isRecommendExpanded.value = !isRecommendExpanded.value
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 智能推荐未读计数
|
||||
// ============================================================================
|
||||
@@ -171,12 +188,15 @@ watch(() => store.unreadRecommendCount, (newVal) => {
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 排队折叠/展开(与 v1 一致,保持不变)
|
||||
// 排队折叠/展开(v3.0:答题开关独立控制)
|
||||
// ============================================================================
|
||||
|
||||
/** QueueWaiting 组件引用(用于 WS 事件转发 + 获取排队位置) */
|
||||
const queueWaitingRef = ref<InstanceType<typeof QueueWaiting> | null>(null)
|
||||
|
||||
/** Quiz QueueWaiting 组件引用(答题区域) */
|
||||
const quizWaitingRef = ref<InstanceType<typeof QueueWaiting> | null>(null)
|
||||
|
||||
/** 是否正在排队(后端 queued/ai_handling 都映射为前端 waiting + 无坐席分配) */
|
||||
const isQueued = computed(() => {
|
||||
const status = store.currentConversation?.status
|
||||
@@ -189,7 +209,10 @@ const queuePosition = computed(() => {
|
||||
})
|
||||
|
||||
/** 排队区域是否展开 */
|
||||
const isQueueExpanded = ref(false)
|
||||
const isQueueExpanded = ref(true)
|
||||
|
||||
/** 答题区域是否展开(v3.0:默认折叠,点击答题开关展开) */
|
||||
const isQuizExpanded = ref(false)
|
||||
|
||||
/**
|
||||
* 监听排队状态变化:
|
||||
@@ -209,6 +232,13 @@ function toggleQueue(): void {
|
||||
isQueueExpanded.value = !isQueueExpanded.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换答题区域展开/折叠(v3.0新增)
|
||||
*/
|
||||
function toggleQuiz(): void {
|
||||
isQuizExpanded.value = !isQuizExpanded.value
|
||||
}
|
||||
|
||||
// 暴露 QueueWaiting ref 供父组件转发 WS 事件
|
||||
defineExpose({
|
||||
queueWaitingRef,
|
||||
@@ -241,8 +271,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
.accordion-item--active {
|
||||
/* 展开态最大高度限制(防止挤占底部标签页空间) */
|
||||
max-height: 50%;
|
||||
/* 展开态最大高度限制 — 80% 确保设备信息完整展示无需滚动条 */
|
||||
max-height: 80%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -303,112 +333,89 @@ defineExpose({
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 智能推荐区域(v2.1:原标签页区域简化为单一直接展示)
|
||||
// 智能推荐区域(v3.0:统一标题样式 + 固定高度)
|
||||
// ============================================================================ */
|
||||
.right-panel__recommend-section {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 排队等待区域(与 v1 一致)
|
||||
// ============================================================================ */
|
||||
.right-panel__queue-section {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
max-height: 60%;
|
||||
background: var(--bg-primary, #fff);
|
||||
max-height: 300px; /* 固定高度,显示约3张卡片 */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.right-panel__queue-section--collapsed {
|
||||
max-height: 48px;
|
||||
.right-panel__recommend-section.accordion-item--active {
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── 折叠态提示条 ── */
|
||||
.queue-collapsed-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
.recommend-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 排队等待区域(v3.0:压缩高度 + 答题开关)
|
||||
// ============================================================================ */
|
||||
.right-panel__queue-section {
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-primary, #fff);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.queue-collapsed-bar:hover { background: var(--bg-tertiary, #f9fafb); }
|
||||
.queue-collapsed-bar:active { background: var(--border-color, #e5e7eb); }
|
||||
|
||||
.queue-collapsed-bar__left {
|
||||
/* ── v3.0 标题栏 ── */
|
||||
.queue-header-v3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.queue-collapsed-bar__icon { font-size: 14px; }
|
||||
|
||||
.queue-collapsed-bar__text {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.queue-collapsed-bar__position {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-warning, #f59e0b);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.queue-collapsed-bar__toggle {
|
||||
font-size: 12px;
|
||||
color: var(--accent, #07C160);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 展开态标题栏 ── */
|
||||
.queue-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
background: var(--bg-primary, #fff);
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.queue-header:hover { background: var(--bg-tertiary, #f9fafb); }
|
||||
.queue-header-v3:hover { background: var(--bg-tertiary, #f9fafb); }
|
||||
|
||||
.queue-header__icon { font-size: 15px; }
|
||||
.queue-header__title { font-size: 14px; font-weight: 600; color: var(--text-primary, #1f2937); }
|
||||
.queue-header-v3__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.queue-header__toggle {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
.queue-header-v3__icon { font-size: 15px; }
|
||||
.queue-header-v3__title { font-size: 14px; font-weight: 600; color: var(--text-primary, #1f2937); }
|
||||
|
||||
.queue-header-v3__position {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--accent, #07C160);
|
||||
}
|
||||
|
||||
.queue-header-v3__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── 答题开关按钮(绿色胶囊) ── */
|
||||
.quiz-toggle-btn {
|
||||
padding: 4px 14px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
background: var(--accent, #07C160);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.queue-header__badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 26px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger, #ef4444);
|
||||
animation: badge-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes badge-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.3); }
|
||||
.quiz-toggle-btn:hover {
|
||||
background: var(--accent-hover, #06a156);
|
||||
}
|
||||
|
||||
/* ── 排队内容区域 ── */
|
||||
@@ -417,4 +424,10 @@ defineExpose({
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── 答题区域(v3.0:默认折叠,点击展开) ── */
|
||||
.right-panel__quiz-content {
|
||||
border-top: 1px dashed var(--border-color, #e5e7eb);
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,227 +1,119 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 审批卡片内联组件(消息流内嵌)
|
||||
// 企微IT智能服务台 — 审批卡片内联组件 v3.0(纯渲染)
|
||||
// =============================================================================
|
||||
// 说明:作为消息流中的一条特殊消息渲染(不是浮层弹窗)。
|
||||
// - 接收 approvalType prop,根据审批类型展示对应选项
|
||||
// - approval_type 为空时展示全部选项(快捷按钮手动触发场景)
|
||||
// - 点击选项后跳转企微审批或提示开发中
|
||||
// - 样式类似 AI 回复气泡(左侧、绿色边框)
|
||||
// v3.0 重构:删除 APPROVAL_OPTIONS 和所有匹配逻辑,改为纯 props 渲染
|
||||
// 后端通过 ApprovalMatcher 已完成全部匹配,前端只负责展示和跳转
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="approval-card-inline">
|
||||
<!-- 卡片头部 -->
|
||||
<div class="approval-card-inline__header">
|
||||
<div class="approval-card-inline__title-row">
|
||||
<van-icon name="orders-o" size="16" color="#07c160" />
|
||||
<span class="approval-card-inline__title">审批快捷入口</span>
|
||||
<span v-if="approvalType" class="approval-card-inline__tag">{{ approvalType }}</span>
|
||||
</div>
|
||||
<div class="approval-card-inline__subtitle">
|
||||
检测到您可能需要提交审批,请选择对应类型
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选项列表 -->
|
||||
<div class="approval-card-inline__options">
|
||||
<!-- 单选项模式:直接展示卡片 -->
|
||||
<template v-if="cardData.card_type === 'single'">
|
||||
<div
|
||||
v-for="option in currentOptions"
|
||||
:key="option.name"
|
||||
class="approval-card-inline__option"
|
||||
@click="handleSelect(option)"
|
||||
class="approval-card-inline__option approval-card-inline__option--single"
|
||||
@click="handleSelect(cardData.options[0])"
|
||||
>
|
||||
<div class="approval-card-inline__option-icon">
|
||||
<van-icon :name="option.icon" size="20" />
|
||||
<van-icon :name="cardData.options[0].icon" size="24" />
|
||||
</div>
|
||||
<div class="approval-card-inline__option-content">
|
||||
<div class="approval-card-inline__option-name">{{ option.name }}</div>
|
||||
<div class="approval-card-inline__option-desc">{{ option.desc }}</div>
|
||||
<div class="approval-card-inline__option-name">{{ cardData.title }}</div>
|
||||
<div class="approval-card-inline__option-desc">{{ cardData.description }}</div>
|
||||
</div>
|
||||
<van-icon name="arrow" class="approval-card-inline__option-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 多选项模式:显示标题 + 列表 -->
|
||||
<template v-else>
|
||||
<div class="approval-card-inline__header">
|
||||
<div class="approval-card-inline__title-row">
|
||||
<van-icon name="orders-o" size="16" color="#07c160" />
|
||||
<span class="approval-card-inline__title">{{ cardData.title }}</span>
|
||||
</div>
|
||||
<div class="approval-card-inline__subtitle">{{ cardData.description }}</div>
|
||||
</div>
|
||||
|
||||
<div class="approval-card-inline__options">
|
||||
<div
|
||||
v-for="option in cardData.options"
|
||||
:key="option.name"
|
||||
class="approval-card-inline__option"
|
||||
@click="handleSelect(option)"
|
||||
>
|
||||
<div class="approval-card-inline__option-icon">
|
||||
<van-icon :name="option.icon" size="20" />
|
||||
</div>
|
||||
<div class="approval-card-inline__option-content">
|
||||
<div class="approval-card-inline__option-name">{{ option.name }}</div>
|
||||
<div class="approval-card-inline__option-desc">{{ option.desc }}</div>
|
||||
</div>
|
||||
<van-icon name="arrow" class="approval-card-inline__option-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ApprovalCardModal 审批卡片内联组件
|
||||
* ApprovalCardModal v3.0 — 纯渲染审批卡片组件
|
||||
*
|
||||
* 改造说明:原为 van-popup 底部弹窗,现改为消息流内联卡片。
|
||||
* - 不再使用 v-model 控制显示/隐藏
|
||||
* - 接收 approvalType prop 决定展示哪些审批选项
|
||||
* - 点击选项后尝试跳转企微审批(有匹配模板时)或提示
|
||||
* 后端 ApprovalMatcher 已完成全部匹配,前端只需:
|
||||
* 1. 接收 cardData prop
|
||||
* 2. 根据 card_type 决定渲染模式
|
||||
* 3. 点击时调用 openUrl 跳转
|
||||
*/
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { getApprovalKeywords, createApprovalJump, type ApprovalKeyword } from '@/api/conversation'
|
||||
import { useWecomApproval } from '@/composables/useWecomApproval'
|
||||
|
||||
// ==========================================================================
|
||||
// Props 定义
|
||||
// Types
|
||||
// ==========================================================================
|
||||
|
||||
interface Props {
|
||||
/** 审批类型(从消息的 extra_data.approval_type 传入,为空时展示全部选项) */
|
||||
approvalType?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
approvalType: '',
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 企微审批原生打开 composable
|
||||
// ==========================================================================
|
||||
// 做什么:封装 wx.invoke('thirdPartyOpenPage') 原生打开审批的逻辑
|
||||
// 内部自动判断:企微审批→原生打开 / ITSM工单→同窗口导航 / 非企微→降级
|
||||
const { openUrl } = useWecomApproval()
|
||||
|
||||
// ==========================================================================
|
||||
// 审批选项配置(按 approval_type 分组)
|
||||
// ==========================================================================
|
||||
|
||||
interface ApprovalOption {
|
||||
interface ApprovalCardOption {
|
||||
name: string
|
||||
icon: string
|
||||
desc: string
|
||||
url?: string // 直接跳转URL(存在时直接 window.open,不存在时走后端模板匹配)
|
||||
url: string
|
||||
}
|
||||
|
||||
const APPROVAL_OPTIONS: Record<string, ApprovalOption[]> = {
|
||||
'设备申请': [
|
||||
{ name: 'IT资产领用', icon: 'orders-o', desc: '申请新设备', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4c8qt31AbSHwN9MuaFhYXt4Qwsx6ZLCftAFh6X1w&sp_id=&from=template_list' },
|
||||
{ name: 'IT资产借用', icon: 'orders-o', desc: '临时借用设备', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3TmACnFs8oqgYcasxVh4BfSMGNX7p9sb6ydBX77mK&sp_id=&from=template_list' },
|
||||
{ name: 'IT资产升级', icon: 'orders-o', desc: '设备升级换新', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS&sp_id=&from=template_list' },
|
||||
],
|
||||
'账号权限申请': [
|
||||
{ name: 'VPN账号申请', icon: 'lock', desc: '零信任VPN', url: 'https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7' },
|
||||
{ name: '企微外联权限', icon: 'lock', desc: '外部联系人权限', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WrCbZd214XrDMZJiHDho7ZQHWX7gsabb7x2fF72&sp_id=&from=template_list' },
|
||||
{ name: '公共邮箱账号', icon: 'lock', desc: '共享邮箱', url: 'https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7' },
|
||||
],
|
||||
'软件服务申请': [
|
||||
{ name: '商业软件申请', icon: 'apps-o', desc: '正版软件授权', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3TmACf8DsJy5yr7aymanLskywC4EDhFLuz1KuBBQK&sp_id=&from=template_list' },
|
||||
],
|
||||
'资产处置申请': [
|
||||
{ name: 'IT资产外修', icon: 'warn-o', desc: '设备送修', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=Bs7ucTLPo42dtj8Y1LzBoujijsa6geRWaRxZJjk4X&sp_id=&from=template_list' },
|
||||
{ name: 'IT资产报废', icon: 'delete-o', desc: '设备报废', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WroCDfWuHKyujQjatjm3AjNv67imXk5C6WNooFkb&sp_id=&from=template_list' },
|
||||
{ name: '资产退还', icon: 'back-top', desc: '退还设备', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4c8qt33AZ52a7n9BBWDh6PmsDnpM5B6w8geqqqoHz&sp_id=&from=template_list' },
|
||||
],
|
||||
'办公用品申请': [
|
||||
{ name: '办公用品超额领用', icon: 'gift-o', desc: '超配额申领', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WN6zRucbjycdnR94gBvkSVuXRamX7pKW4PrmNFh&sp_id=&from=template_list' },
|
||||
],
|
||||
// --- 新增7种审批类型 ---
|
||||
'会议室故障报修': [
|
||||
{ name: '会议室故障报修', icon: 'warn-o', desc: '会议室设备故障', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4ZXJMtjQJiPXo6N5vNMK26uPRT3KTi9VvkH2NScg&sp_id=&from=template_list' },
|
||||
],
|
||||
'企业应用管理': [
|
||||
{ name: '企业应用管理', icon: 'apps-o', desc: '企业应用开通管理', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WLJnRg2Se1fwizQtNvFtcYMgci1mhRJZhMw2FFKb&sp_id=&from=template_list' },
|
||||
],
|
||||
'资产变更确认': [
|
||||
{ name: '资产变更确认', icon: 'exchange', desc: '资产信息变更', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4cA2owjRcXRRPQ46otZvUHoWNEKL5t25tHHfeePip&sp_id=&from=template_list' },
|
||||
],
|
||||
'终端设备网络准入': [
|
||||
{ name: '终端设备网络准入申请', icon: 'lock', desc: '终端网络准入', url: 'https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7' },
|
||||
],
|
||||
'活动与会议技术支持': [
|
||||
{ name: '活动与会议技术支持', icon: 'calendar-o', desc: '活动会议技术保障', url: 'https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81' },
|
||||
],
|
||||
'员工IT支持与故障报修': [
|
||||
{ name: '员工IT支持与故障报修', icon: 'warn-o', desc: 'IT支持与故障报修', url: 'https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE' },
|
||||
],
|
||||
'公共邮箱账号申请': [
|
||||
{ name: '公共邮箱账号申请', icon: 'lock', desc: '公共邮箱账号', url: 'https://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7' },
|
||||
],
|
||||
interface CardData {
|
||||
card_type: 'single' | 'multiple'
|
||||
title: string
|
||||
description?: string
|
||||
options: ApprovalCardOption[]
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 计算属性
|
||||
// Props
|
||||
// ==========================================================================
|
||||
|
||||
/** 当前展示的审批选项(根据 approvalType 过滤,为空时展示全部) */
|
||||
const currentOptions = computed<ApprovalOption[]>(() => {
|
||||
if (props.approvalType && APPROVAL_OPTIONS[props.approvalType]) {
|
||||
return APPROVAL_OPTIONS[props.approvalType]
|
||||
}
|
||||
// approval_type 为空(手动触发),展示全部选项
|
||||
return Object.values(APPROVAL_OPTIONS).flat()
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 审批模板(从后端加载,用于点击跳转)
|
||||
// ==========================================================================
|
||||
|
||||
/** 后端审批关键词列表(包含 template_id 和 type) */
|
||||
const approvalKeywords = ref<ApprovalKeyword[]>([])
|
||||
|
||||
/** 加载审批关键词(用于点击选项时查找匹配的模板) */
|
||||
async function loadKeywords(): Promise<void> {
|
||||
if (approvalKeywords.value.length > 0) return
|
||||
try {
|
||||
approvalKeywords.value = await getApprovalKeywords()
|
||||
} catch (error) {
|
||||
console.error('[ApprovalCard] 加载审批关键词失败:', error)
|
||||
}
|
||||
interface Props {
|
||||
cardData: CardData
|
||||
}
|
||||
|
||||
// 组件挂载时加载审批关键词
|
||||
onMounted(() => {
|
||||
loadKeywords()
|
||||
})
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// ==========================================================================
|
||||
// 事件处理
|
||||
// Composable
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 选择审批选项
|
||||
* 企微审批URL → useWecomApproval.openUrl() 原生打开(企微内不跳转网页)
|
||||
* ITSM工单URL → openUrl() 内部自动同窗口导航
|
||||
* 无URL时走后端模板匹配逻辑(fallback)
|
||||
*
|
||||
* @param option 选中的审批选项
|
||||
*/
|
||||
async function handleSelect(option: ApprovalOption): Promise<void> {
|
||||
// 优先使用 option.url,通过 openUrl 智能路由:
|
||||
// 企微审批URL → wx.invoke('thirdPartyOpenPage') 原生打开
|
||||
// ITSM工单URL → window.location.href 同窗口导航
|
||||
// 非企微环境 → window.location.href 降级
|
||||
const { openUrl } = useWecomApproval()
|
||||
|
||||
// ==========================================================================
|
||||
// Event Handlers
|
||||
// ==========================================================================
|
||||
|
||||
async function handleSelect(option: ApprovalCardOption): Promise<void> {
|
||||
if (option.url) {
|
||||
await openUrl(option.url)
|
||||
return
|
||||
}
|
||||
|
||||
// fallback:没有 url 时走后端模板匹配逻辑
|
||||
try {
|
||||
// 在已加载的审批模板中查找名称匹配的模板
|
||||
const matchedTemplate = approvalKeywords.value.find(
|
||||
(kw) => kw.template_name === props.approvalType || kw.keyword === option.name
|
||||
)
|
||||
|
||||
if (matchedTemplate) {
|
||||
if (matchedTemplate.type === 'jump') {
|
||||
// 跳转审批:后端返回URL后通过 openUrl 路由
|
||||
const result = await createApprovalJump(matchedTemplate.template_id)
|
||||
await openUrl(result.url)
|
||||
} else {
|
||||
// API提交 — 后续实现
|
||||
showToast('该功能正在开发中')
|
||||
}
|
||||
} else {
|
||||
// 未找到匹配模板,提示用户
|
||||
showToast(`${option.name} — 审批模板配置中,请稍后`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ApprovalCard] 打开审批失败:', error)
|
||||
showToast('打开审批失败,请重试')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// 审批卡片内联容器(类似 AI 回复气泡,左侧绿色边框)
|
||||
// 容器
|
||||
// ============================================================================ */
|
||||
.approval-card-inline {
|
||||
display: flex;
|
||||
@@ -237,7 +129,7 @@ async function handleSelect(option: ApprovalOption): Promise<void> {
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 卡片头部
|
||||
// 头部
|
||||
// ============================================================================ */
|
||||
.approval-card-inline__header {
|
||||
padding-bottom: 10px;
|
||||
@@ -257,17 +149,6 @@ async function handleSelect(option: ApprovalOption): Promise<void> {
|
||||
color: var(--text-primary, #323233);
|
||||
}
|
||||
|
||||
.approval-card-inline__tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
color: #07c160;
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.approval-card-inline__subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #969799);
|
||||
@@ -298,6 +179,20 @@ async function handleSelect(option: ApprovalOption): Promise<void> {
|
||||
background: rgba(7, 193, 96, 0.08);
|
||||
}
|
||||
|
||||
/* 单精确匹配模式:更大的图标和间距 */
|
||||
.approval-card-inline__option--single {
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-secondary, #ffffff);
|
||||
border: 1px solid rgba(7, 193, 96, 0.3);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.approval-card-inline__option--single .approval-card-inline__option-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.approval-card-inline__option-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 审批卡片内联组件(消息流内嵌)
|
||||
// =============================================================================
|
||||
// 说明:作为消息流中的一条特殊消息渲染(不是浮层弹窗)。
|
||||
// - 接收 approvalType prop,根据审批类型展示对应选项
|
||||
// - approval_type 为空时展示全部选项(快捷按钮手动触发场景)
|
||||
// - 点击选项后跳转企微审批或提示开发中
|
||||
// - 样式类似 AI 回复气泡(左侧、绿色边框)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="approval-card-inline">
|
||||
<!-- 卡片头部 -->
|
||||
<div class="approval-card-inline__header">
|
||||
<div class="approval-card-inline__title-row">
|
||||
<van-icon name="orders-o" size="16" color="#07c160" />
|
||||
<span class="approval-card-inline__title">审批快捷入口</span>
|
||||
<span v-if="approvalType" class="approval-card-inline__tag">{{ approvalType }}</span>
|
||||
</div>
|
||||
<div class="approval-card-inline__subtitle">
|
||||
检测到您可能需要提交审批,请选择对应类型
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选项列表 -->
|
||||
<div class="approval-card-inline__options">
|
||||
<div
|
||||
v-for="option in currentOptions"
|
||||
:key="option.name"
|
||||
class="approval-card-inline__option"
|
||||
@click="handleSelect(option)"
|
||||
>
|
||||
<div class="approval-card-inline__option-icon">
|
||||
<van-icon :name="option.icon" size="20" />
|
||||
</div>
|
||||
<div class="approval-card-inline__option-content">
|
||||
<div class="approval-card-inline__option-name">{{ option.name }}</div>
|
||||
<div class="approval-card-inline__option-desc">{{ option.desc }}</div>
|
||||
</div>
|
||||
<van-icon name="arrow" class="approval-card-inline__option-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ApprovalCardModal 审批卡片内联组件
|
||||
*
|
||||
* 改造说明:原为 van-popup 底部弹窗,现改为消息流内联卡片。
|
||||
* - 不再使用 v-model 控制显示/隐藏
|
||||
* - 接收 approvalType prop 决定展示哪些审批选项
|
||||
* - 点击选项后尝试跳转企微审批(有匹配模板时)或提示
|
||||
*/
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { getApprovalKeywords, createApprovalJump, type ApprovalKeyword } from '@/api/conversation'
|
||||
import { useWecomApproval } from '@/composables/useWecomApproval'
|
||||
|
||||
// ==========================================================================
|
||||
// Props 定义
|
||||
// ==========================================================================
|
||||
|
||||
interface Props {
|
||||
/** 审批类型(从消息的 extra_data.approval_type 传入,为空时展示全部选项) */
|
||||
approvalType?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
approvalType: '',
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 企微审批原生打开 composable
|
||||
// ==========================================================================
|
||||
// 做什么:封装 wx.invoke('thirdPartyOpenPage') 原生打开审批的逻辑
|
||||
// 内部自动判断:企微审批→原生打开 / ITSM工单→同窗口导航 / 非企微→降级
|
||||
const { openUrl } = useWecomApproval()
|
||||
|
||||
// ==========================================================================
|
||||
// 审批选项配置(按 approval_type 分组)
|
||||
// ==========================================================================
|
||||
|
||||
interface ApprovalOption {
|
||||
name: string
|
||||
icon: string
|
||||
desc: string
|
||||
url?: string // 直接跳转URL(存在时直接 window.open,不存在时走后端模板匹配)
|
||||
}
|
||||
|
||||
const APPROVAL_OPTIONS: Record<string, ApprovalOption[]> = {
|
||||
'设备申请': [
|
||||
// IT资产领用 改用ITSM工单系统(企微审批模板 C4c8qt31... 已失效)
|
||||
{ name: 'IT资产领用', icon: 'orders-o', desc: '申请新设备', url: 'https://itsm.servyou.com.cn/itsm-miniapp-mobile/' },
|
||||
// IT资产借用 改用ITSM工单系统(企微审批模板 3TmACnFs... 已失效)
|
||||
{ name: 'IT资产借用', icon: 'orders-o', desc: '临时借用设备', url: 'https://itsm.servyou.com.cn/itsm-miniapp-mobile/' },
|
||||
// IT资产升级 改用ITSM工单系统(企微审批模板 Bs7ucTGs... 已失效)
|
||||
{ name: 'IT资产升级', icon: 'orders-o', desc: '设备升级换新', url: 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=IT%E8%AE%BE%E5%A4%87%E5%8D%87%E7%BA%A7%E4%B8%8E%E7%A1%AC%E4%BB%B6%E7%BB%B4%E4%BF%AE' },
|
||||
],
|
||||
'账号权限申请': [
|
||||
{ name: 'VPN账号申请', icon: 'lock', desc: '零信任VPN', url: 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5%E9%9B%B6%E4%BF%A1%E4%BB%BB%EF%BC%88%E5%8E%9FVPN%EF%BC%89%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7' },
|
||||
{ name: '企微外联权限', icon: 'lock', desc: '外部联系人权限', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WrCbZd214XrDMZJiHDho7ZQHWX7gsabb7x2fF72&sp_id=&from=template_list' },
|
||||
{ name: '公共邮箱账号', icon: 'lock', desc: '共享邮箱', url: 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7' },
|
||||
],
|
||||
'软件服务申请': [
|
||||
// 商业软件申请 改用ITSM工单系统(企微审批模板 3TmACf8D... 已失效)
|
||||
{ name: '商业软件申请', icon: 'apps-o', desc: '正版软件授权', url: 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%95%86%E4%B8%9A%E8%BD%AF%E4%BB%B6%E6%9C%8D%E5%8A%A1%E7%94%B3%E8%AF%B7' },
|
||||
],
|
||||
'资产处置申请': [
|
||||
{ name: 'IT资产外修', icon: 'warn-o', desc: '设备送修', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=Bs7ucTLPo42dtj8Y1LzBoujijsa6geRWaRxZJjk4X&sp_id=&from=template_list' },
|
||||
{ name: 'IT资产报废', icon: 'delete-o', desc: '设备报废', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4WroCDfWuHKyujQjatjm3AjNv67imXk5C6WNooFkb&sp_id=&from=template_list' },
|
||||
{ name: '资产退还', icon: 'back-top', desc: '退还设备', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4c8qt33AZ52a7n9BBWDh6PmsDnpM5B6w8geqqqoHz&sp_id=&from=template_list' },
|
||||
],
|
||||
'办公用品申请': [
|
||||
{ name: '办公用品超额领用', icon: 'gift-o', desc: '超配额申领', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WN6zRucbjycdnR94gBvkSVuXRamX7pKW4PrmNFh&sp_id=&from=template_list' },
|
||||
],
|
||||
// --- 新增7种审批类型 ---
|
||||
'会议室故障报修': [
|
||||
{ name: '会议室故障报修', icon: 'warn-o', desc: '会议室设备故障', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4ZXJMtjQJiPXo6N5vNMK26uPRT3KTi9VvkH2NScg&sp_id=&from=template_list' },
|
||||
],
|
||||
'企业应用管理': [
|
||||
{ name: '企业应用管理', icon: 'apps-o', desc: '企业应用开通管理', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=3WLJnRg2Se1fwizQtNvFtcYMgci1mhRJZhMw2FFKb&sp_id=&from=template_list' },
|
||||
],
|
||||
'资产变更确认': [
|
||||
{ name: '资产变更确认', icon: 'exchange', desc: '资产信息变更', url: 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=C4cA2owjRcXRRPQ46otZvUHoWNEKL5t25tHHfeePip&sp_id=&from=template_list' },
|
||||
],
|
||||
'终端设备网络准入': [
|
||||
{ name: '终端设备网络准入申请', icon: 'lock', desc: '终端网络准入', url: 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E7%BB%88%E7%AB%AF%E8%AE%BE%E5%A4%87%E7%BD%91%E7%BB%9C%E5%87%86%E5%85%A5%E7%94%B3%E8%AF%B7' },
|
||||
],
|
||||
'活动与会议技术支持': [
|
||||
{ name: '活动与会议技术支持', icon: 'calendar-o', desc: '活动会议技术保障', url: 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E6%B4%BB%E5%8A%A8%E4%B8%8E%E4%BC%9A%E8%AE%AE%E6%8A%80%E6%9C%AF%E6%94%AF%E6%8C%81' },
|
||||
],
|
||||
'员工IT支持与故障报修': [
|
||||
{ name: '员工IT支持与故障报修', icon: 'warn-o', desc: 'IT支持与故障报修', url: 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%91%98%E5%B7%A5IT%E6%94%AF%E6%8C%81%E4%B8%8E%E6%95%85%E9%9A%9C%E6%8A%A5%E4%BF%AE' },
|
||||
],
|
||||
'公共邮箱账号申请': [
|
||||
{ name: '公共邮箱账号申请', icon: 'lock', desc: '公共邮箱账号', url: 'http://devops.dc.servyou-it.com/ITSM/workflow/service/createTicket?name=%E5%85%AC%E5%85%B1%E9%82%AE%E7%AE%B1%E8%B4%A6%E5%8F%B7%E7%94%B3%E8%AF%B7' },
|
||||
],
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 计算属性
|
||||
// ==========================================================================
|
||||
|
||||
/** 当前展示的审批选项(根据 approvalType 过滤,为空时展示全部) */
|
||||
const currentOptions = computed<ApprovalOption[]>(() => {
|
||||
if (props.approvalType && APPROVAL_OPTIONS[props.approvalType]) {
|
||||
return APPROVAL_OPTIONS[props.approvalType]
|
||||
}
|
||||
// approval_type 为空(手动触发),展示全部选项
|
||||
return Object.values(APPROVAL_OPTIONS).flat()
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 审批模板(从后端加载,用于点击跳转)
|
||||
// ==========================================================================
|
||||
|
||||
/** 后端审批关键词列表(包含 template_id 和 type) */
|
||||
const approvalKeywords = ref<ApprovalKeyword[]>([])
|
||||
|
||||
/** 加载审批关键词(用于点击选项时查找匹配的模板) */
|
||||
async function loadKeywords(): Promise<void> {
|
||||
if (approvalKeywords.value.length > 0) return
|
||||
try {
|
||||
approvalKeywords.value = await getApprovalKeywords()
|
||||
} catch (error) {
|
||||
console.error('[ApprovalCard] 加载审批关键词失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载审批关键词
|
||||
onMounted(() => {
|
||||
loadKeywords()
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 事件处理
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 选择审批选项
|
||||
* 企微审批URL → useWecomApproval.openUrl() 原生打开(企微内不跳转网页)
|
||||
* ITSM工单URL → openUrl() 内部自动同窗口导航
|
||||
* 无URL时走后端模板匹配逻辑(fallback)
|
||||
*
|
||||
* @param option 选中的审批选项
|
||||
*/
|
||||
async function handleSelect(option: ApprovalOption): Promise<void> {
|
||||
// 优先使用 option.url,通过 openUrl 智能路由:
|
||||
// 企微审批URL → wx.invoke('thirdPartyOpenPage') 原生打开
|
||||
// ITSM工单URL → window.location.href 同窗口导航
|
||||
// 非企微环境 → window.location.href 降级
|
||||
if (option.url) {
|
||||
await openUrl(option.url)
|
||||
return
|
||||
}
|
||||
|
||||
// fallback:没有 url 时走后端模板匹配逻辑
|
||||
try {
|
||||
// 在已加载的审批模板中查找名称匹配的模板
|
||||
const matchedTemplate = approvalKeywords.value.find(
|
||||
(kw) => kw.template_name === props.approvalType || kw.keyword === option.name
|
||||
)
|
||||
|
||||
if (matchedTemplate) {
|
||||
if (matchedTemplate.type === 'jump') {
|
||||
// 跳转审批:后端返回URL后通过 openUrl 路由
|
||||
const result = await createApprovalJump(matchedTemplate.template_id)
|
||||
await openUrl(result.url)
|
||||
} else {
|
||||
// API提交 — 后续实现
|
||||
showToast('该功能正在开发中')
|
||||
}
|
||||
} else {
|
||||
// 未找到匹配模板,提示用户
|
||||
showToast(`${option.name} — 审批模板配置中,请稍后`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ApprovalCard] 打开审批失败:', error)
|
||||
showToast('打开审批失败,请重试')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================================
|
||||
// 审批卡片内联容器(类似 AI 回复气泡,左侧绿色边框)
|
||||
// ============================================================================ */
|
||||
.approval-card-inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
background: var(--bg-secondary, #ffffff);
|
||||
border: 1px solid #07c160;
|
||||
border-left: 3px solid #07c160;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 卡片头部
|
||||
// ============================================================================ */
|
||||
.approval-card-inline__header {
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color, #ebedf0);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.approval-card-inline__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.approval-card-inline__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #323233);
|
||||
}
|
||||
|
||||
.approval-card-inline__tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
color: #07c160;
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
margin-left: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.approval-card-inline__subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #969799);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 选项列表
|
||||
// ============================================================================ */
|
||||
.approval-card-inline__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.approval-card-inline__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-tertiary, #f7f8fa);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.approval-card-inline__option:active {
|
||||
background: rgba(7, 193, 96, 0.08);
|
||||
}
|
||||
|
||||
.approval-card-inline__option-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #07c160;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.approval-card-inline__option-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.approval-card-inline__option-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #323233);
|
||||
}
|
||||
|
||||
.approval-card-inline__option-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #969799);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.approval-card-inline__option-arrow {
|
||||
color: var(--text-tertiary, #969799);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -184,8 +184,9 @@ async function handleSubmit(): Promise<void> {
|
||||
emit('submitted')
|
||||
} catch (error: any) {
|
||||
console.error('[EvaluationDialog] 提交评价失败:', error)
|
||||
const message = error?.response?.data?.message || '提交失败,请稍后重试'
|
||||
showToast(message)
|
||||
// 修复:H5 拦截器已将错误对象格式化为 {code, message},
|
||||
// 不再有 error.response.data.message 结构。
|
||||
// 拦截器已在 showToast 中显示具体错误消息,此处不再重复。
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@
|
||||
|
||||
<!-- 输入区域:工具栏 + 输入框 + 发送按钮 -->
|
||||
<div class="input-bar__row">
|
||||
<!-- 工具栏:表情/文件 + 截图快捷键提示 -->
|
||||
<!-- 工具栏左侧放图标,右侧放截图提示,PC显示/移动端隐藏 -->
|
||||
<!-- 工具栏:表情/文件 -->
|
||||
<div class="input-bar__toolbar">
|
||||
<button class="input-bar__tool-btn" title="表情" @click="handleEmoji">
|
||||
<span>😊</span>
|
||||
@@ -32,8 +31,6 @@
|
||||
<button class="input-bar__tool-btn" title="文件" @click="handleFile">
|
||||
<span>📎</span>
|
||||
</button>
|
||||
<!-- 截图快捷键引导:PC 端显示,移动端隐藏(CSS 媒体查询) -->
|
||||
<span class="input-bar__shortcut-hint">截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V</span>
|
||||
</div>
|
||||
|
||||
<!-- 表情选择面板(简易版:常用 Emoji 网格) -->
|
||||
@@ -73,28 +70,11 @@
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
|
||||
<!-- 输入栏右侧控件区:垂直堆叠 (语音按钮 → 人工坐席 → 发送) -->
|
||||
<!-- 输入栏右侧控件区:两层布局 -->
|
||||
<!-- 第一层:人工坐席按钮(独占一行,始终可见) -->
|
||||
<!-- 第二层:语音按钮 + 发送按钮(水平并排,高度均36px) -->
|
||||
<div class="input-bar__controls">
|
||||
<!-- 语音按钮 -->
|
||||
<!-- 做什么:点击开始录音/聆听,再次点击停止并转文字 -->
|
||||
<!-- 识别中禁用按钮,防止重复点击;显示⏳表示识别进行中 -->
|
||||
<!-- 为什么用 v-if 而不是 v-show:不支持语音时完全移除,不占位 -->
|
||||
<button
|
||||
v-if="showVoiceButton"
|
||||
class="input-bar__voice-btn"
|
||||
:class="{ 'input-bar__voice-btn--recording': isVoiceActive }"
|
||||
:title="isTranscribing ? '正在识别...' : (isVoiceActive ? '点击停止' : '语音输入 (Ctrl+D)')"
|
||||
:disabled="isTranscribing"
|
||||
@mousedown.prevent="handleVoiceToggle"
|
||||
@click.prevent="handleVoiceToggle"
|
||||
@touchstart.prevent="handleVoiceToggle"
|
||||
>
|
||||
{{ isTranscribing ? '⏳' : (isVoiceActive ? '⏹' : '🎤') }}
|
||||
</button>
|
||||
|
||||
<!-- "人工坐席"呼叫按钮 — 位于语音按钮下方(发送键上方) -->
|
||||
<!-- 三态:disabled(AI回复<3次) / active(可呼叫) / urgent(紧急关键词直通) -->
|
||||
<!-- 2026-07-12:文案统一为"人工坐席",三态都用同一文案 -->
|
||||
<!-- 第一层:人工坐席按钮 -->
|
||||
<button
|
||||
v-if="showCallAgentBtn"
|
||||
class="call-agent-btn"
|
||||
@@ -107,17 +87,37 @@
|
||||
<span class="call-agent-btn__text">{{ callAgentBtnText }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<van-button
|
||||
class="input-bar__send-btn"
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!canSend"
|
||||
:loading="store.loading"
|
||||
@click="handleSend"
|
||||
>
|
||||
发送
|
||||
</van-button>
|
||||
<!-- 第二层:语音按钮 + 发送按钮(水平并排) -->
|
||||
<div class="input-bar__controls-row">
|
||||
<!-- 语音按钮 -->
|
||||
<!-- 做什么:点击开始录音/聆听,再次点击停止并转文字 -->
|
||||
<!-- 识别中禁用按钮,防止重复点击;显示⏳表示识别进行中 -->
|
||||
<!-- 为什么用 v-if 而不是 v-show:不支持语音时完全移除,不占位 -->
|
||||
<button
|
||||
v-if="showVoiceButton"
|
||||
class="input-bar__voice-btn"
|
||||
:class="{ 'input-bar__voice-btn--recording': isVoiceActive }"
|
||||
:title="isTranscribing ? '正在识别...' : (isVoiceActive ? '点击停止' : '语音输入 (Ctrl+D)')"
|
||||
:disabled="isTranscribing"
|
||||
@mousedown.prevent="handleVoiceToggle"
|
||||
@click.prevent="handleVoiceToggle"
|
||||
@touchstart.prevent="handleVoiceToggle"
|
||||
>
|
||||
{{ isTranscribing ? '⏳' : (isVoiceActive ? '⏹' : '🎤') }}
|
||||
</button>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<van-button
|
||||
class="input-bar__send-btn"
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!canSend"
|
||||
:loading="store.loading"
|
||||
@click="handleSend"
|
||||
>
|
||||
发送
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -918,7 +918,7 @@ function handleInputResizeStart(event: MouseEvent): void {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 工具栏:表情/文件 + 截图快捷键提示 */
|
||||
/* 工具栏:表情/文件 */
|
||||
.input-bar__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -926,20 +926,6 @@ function handleInputResizeStart(event: MouseEvent): void {
|
||||
padding: 0 0 4px 0;
|
||||
}
|
||||
|
||||
/* 截图快捷键引导文字 — 右对齐,小字灰色 */
|
||||
.input-bar__shortcut-hint {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #999);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 2026-07-12 新增:移动端隐藏截图快捷键说明(要求:移动端不显示) */
|
||||
@media (max-width: 768px) {
|
||||
.input-bar__shortcut-hint { display: none; }
|
||||
}
|
||||
|
||||
/* ── 表情选择面板 ── */
|
||||
.emoji-panel {
|
||||
position: relative;
|
||||
@@ -1029,8 +1015,9 @@ function handleInputResizeStart(event: MouseEvent): void {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 2026-07-12 新增:右侧控件垂直堆叠容器
|
||||
布局:语音按钮(顶) → 人工坐席按钮(中) → 发送按钮(底) */
|
||||
/* 右侧控件区:两层布局
|
||||
第一层:人工坐席按钮(独占一行)
|
||||
第二层:语音按钮 + 发送按钮(水平并排,高度均36px) */
|
||||
.input-bar__controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1039,6 +1026,13 @@ function handleInputResizeStart(event: MouseEvent): void {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 第二层:语音按钮 + 发送按钮水平排列容器 */
|
||||
.input-bar__controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
文本输入框
|
||||
========================================================================== */
|
||||
@@ -1136,10 +1130,9 @@ function handleInputResizeStart(event: MouseEvent): void {
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
"人工坐席"呼叫按钮(位于语音按钮下方,发送键上方)
|
||||
"人工坐席"呼叫按钮(控件区第一层,独占一行)
|
||||
三态:disabled / active / urgent — 文案统一为"人工坐席"
|
||||
2026-07-12 重构:原本单独占一行的 .input-bar__call-agent-row 已废弃,
|
||||
按钮改为放在 .input-bar__controls 垂直堆叠容器内
|
||||
2026-07-13 重构:从垂直堆叠改为独占第一行,语音+发送在第二层水平排列
|
||||
========================================================================== */
|
||||
/* 旧的 call-agent-row 保留兜底(不渲染但样式兼容) */
|
||||
.input-bar__call-agent-row {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// =============================================================================
|
||||
// 说明:底部弹出层,双 Tab(搜索 + 组织架构),支持多选员工后邀请加入会话
|
||||
// - 搜索 Tab:van-search 输入框 + 结果列表(300ms debounce)
|
||||
// - 组织架构 Tab:van-collapse 按部门折叠 + 员工多选
|
||||
// - 组织架构 Tab:多层级部门树(扁平化渲染,按层级缩进)+ 员工多选
|
||||
// - 已在会话中的参与者禁用勾选
|
||||
// - 底部:显示已选人数 + "确认邀请" 按钮
|
||||
// - 确认后调用 inviteParticipants API,成功后 showToast + 关闭弹层 + emit 事件
|
||||
@@ -93,44 +93,49 @@
|
||||
description="暂无组织架构数据"
|
||||
image="search"
|
||||
/>
|
||||
<!-- 折叠列表 -->
|
||||
<van-collapse v-else v-model="activeCollapse">
|
||||
<van-collapse-item
|
||||
v-for="dept in orgTree"
|
||||
:key="dept.id"
|
||||
:name="dept.id"
|
||||
:title="dept.label"
|
||||
<!-- 多层级组织架构树(扁平化渲染,按层级缩进) -->
|
||||
<div v-else class="org-tree-flat">
|
||||
<div
|
||||
v-for="node in flatTree"
|
||||
:key="node.id"
|
||||
class="org-tree-node"
|
||||
:class="{
|
||||
'org-tree-node--dept': !node.isLeaf,
|
||||
'org-tree-node--emp': node.isLeaf,
|
||||
'invite-cell--disabled': node.isLeaf && isDisabled(node.id),
|
||||
}"
|
||||
:style="{ paddingLeft: `${node.depth * 16 + 12}px` }"
|
||||
@click="node.isLeaf ? toggleSelectFromTree(node) : toggleExpand(node.id)"
|
||||
>
|
||||
<template #right-icon>
|
||||
<span class="invite-collapse__count">
|
||||
{{ (dept.children || []).length }}人
|
||||
</span>
|
||||
<!-- 部门节点 -->
|
||||
<template v-if="!node.isLeaf">
|
||||
<van-icon
|
||||
:name="isExpanded(node.id) ? 'arrow-down' : 'arrow'"
|
||||
class="org-tree-node__arrow"
|
||||
/>
|
||||
<span class="org-tree-node__label">{{ node.label }}</span>
|
||||
<span class="org-tree-node__count">{{ node.childCount }}人</span>
|
||||
</template>
|
||||
<div
|
||||
v-for="emp in (dept.children || [])"
|
||||
:key="emp.id"
|
||||
class="invite-cell"
|
||||
:class="{ 'invite-cell--disabled': isDisabled(emp.id) }"
|
||||
@click="toggleSelectFromTree(emp)"
|
||||
>
|
||||
<!-- 员工节点 -->
|
||||
<template v-else>
|
||||
<van-checkbox
|
||||
:model-value="isSelected(emp.id)"
|
||||
:disabled="isDisabled(emp.id)"
|
||||
:model-value="isSelected(node.id)"
|
||||
:disabled="isDisabled(node.id)"
|
||||
shape="square"
|
||||
/>
|
||||
<div class="invite-cell__avatar">
|
||||
{{ avatarLetter(emp.label) }}
|
||||
{{ avatarLetter(node.label) }}
|
||||
</div>
|
||||
<div class="invite-cell__info">
|
||||
<span class="invite-cell__name">{{ emp.label }}</span>
|
||||
<span v-if="emp.department" class="invite-cell__dept">
|
||||
{{ emp.department }}
|
||||
<span class="invite-cell__name">{{ node.label }}</span>
|
||||
<span v-if="node.department" class="invite-cell__dept">
|
||||
{{ node.department }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="isDisabled(emp.id)" class="invite-cell__badge">已在会话中</span>
|
||||
</div>
|
||||
</van-collapse-item>
|
||||
</van-collapse>
|
||||
<span v-if="isDisabled(node.id)" class="invite-cell__badge">已在会话中</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
@@ -208,6 +213,22 @@ interface SelectableEmployee {
|
||||
department: string
|
||||
}
|
||||
|
||||
/** 扁平化树节点(用于渲染多层级组织架构树) */
|
||||
interface FlatNode {
|
||||
/** 节点 ID */
|
||||
id: string
|
||||
/** 显示文本(部门名或员工姓名) */
|
||||
label: string
|
||||
/** 是否为叶子节点(true=员工) */
|
||||
isLeaf: boolean
|
||||
/** 部门名称(仅员工节点有) */
|
||||
department?: string
|
||||
/** 层级深度(0=顶层,用于缩进) */
|
||||
depth: number
|
||||
/** 部门下的员工总数(仅部门节点有意义) */
|
||||
childCount: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 响应式状态
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -233,8 +254,8 @@ const treeLoading = ref<boolean>(false)
|
||||
/** 组织架构树是否已加载(避免重复请求) */
|
||||
const orgLoaded = ref<boolean>(false)
|
||||
|
||||
/** 展开的折叠面板 name 列表 */
|
||||
const activeCollapse = ref<string[]>([])
|
||||
/** 展开的部门 ID 列表(默认展开第一层部门) */
|
||||
const expandedDeptIds = ref<string[]>([])
|
||||
|
||||
/** 邀请提交中 */
|
||||
const inviting = ref<boolean>(false)
|
||||
@@ -249,6 +270,48 @@ const selectedEmployees = ref<Map<string, SelectableEmployee>>(new Map())
|
||||
/** 已选人数 */
|
||||
const selectedCount = computed(() => selectedEmployees.value.size)
|
||||
|
||||
/**
|
||||
* 扁平化组织架构树(根据展开状态计算可见节点列表)
|
||||
* 将多层级树递归展开为一维列表,仅包含展开的部门下的子节点
|
||||
*/
|
||||
const flatTree = computed<FlatNode[]>(() => {
|
||||
const result: FlatNode[] = []
|
||||
|
||||
function walk(nodes: OrgTreeNode[], depth: number): void {
|
||||
for (const node of nodes) {
|
||||
if (node.isLeaf) {
|
||||
// 员工叶子节点
|
||||
result.push({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
isLeaf: true,
|
||||
department: node.department,
|
||||
depth,
|
||||
childCount: 0,
|
||||
})
|
||||
} else {
|
||||
// 部门节点
|
||||
const count = countEmployees(node)
|
||||
result.push({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
isLeaf: false,
|
||||
department: node.department,
|
||||
depth,
|
||||
childCount: count,
|
||||
})
|
||||
// 仅展开的部门才递归子节点
|
||||
if (expandedDeptIds.value.includes(node.id) && node.children) {
|
||||
walk(node.children, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(orgTree.value, 0)
|
||||
return result
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 方法
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -299,16 +362,47 @@ function toggleSelectFromSearch(emp: DirectoryEmployee): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从树节点切换选中(归一化 OrgTreeNode → SelectableEmployee)
|
||||
* 从树节点切换选中(归一化 FlatNode → SelectableEmployee)
|
||||
*/
|
||||
function toggleSelectFromTree(emp: OrgTreeNode): void {
|
||||
function toggleSelectFromTree(node: FlatNode): void {
|
||||
toggleSelect({
|
||||
id: emp.id,
|
||||
name: emp.label,
|
||||
department: emp.department || '',
|
||||
id: node.id,
|
||||
name: node.label,
|
||||
department: node.department || '',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归计算部门节点下的员工总数
|
||||
*/
|
||||
function countEmployees(node: OrgTreeNode): number {
|
||||
if (node.isLeaf) return 1
|
||||
let count = 0
|
||||
for (const child of node.children || []) {
|
||||
count += countEmployees(child)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断部门是否展开
|
||||
*/
|
||||
function isExpanded(id: string): boolean {
|
||||
return expandedDeptIds.value.includes(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换部门展开/折叠
|
||||
*/
|
||||
function toggleExpand(id: string): void {
|
||||
const idx = expandedDeptIds.value.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
expandedDeptIds.value.splice(idx, 1)
|
||||
} else {
|
||||
expandedDeptIds.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索输入防抖(300ms)
|
||||
*/
|
||||
@@ -353,10 +447,10 @@ async function loadOrgTree(): Promise<void> {
|
||||
try {
|
||||
orgTree.value = await getOrgTree()
|
||||
orgLoaded.value = true
|
||||
// 默认展开第一个部门
|
||||
if (orgTree.value.length > 0) {
|
||||
activeCollapse.value = [orgTree.value[0].id]
|
||||
}
|
||||
// 默认展开第一层部门(顶层部门节点)
|
||||
expandedDeptIds.value = orgTree.value
|
||||
.filter(n => !n.isLeaf)
|
||||
.map(n => n.id)
|
||||
} catch (e) {
|
||||
console.error('[InviteSheet] 获取组织架构树失败:', e)
|
||||
} finally {
|
||||
@@ -580,11 +674,68 @@ watch(activeTab, (newVal) => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 折叠面板人数标签 */
|
||||
.invite-collapse__count {
|
||||
/* 多层级组织架构树容器 */
|
||||
.org-tree-flat {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 树节点基础样式(部门+员工共用) */
|
||||
.org-tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
padding-right: 16px;
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.org-tree-node:active {
|
||||
background-color: var(--bg-tertiary, #f7f8fa);
|
||||
}
|
||||
|
||||
/* 部门节点 */
|
||||
.org-tree-node--dept {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #323233);
|
||||
}
|
||||
|
||||
/* 部门节点箭头图标 */
|
||||
.org-tree-node__arrow {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #c8c9cc);
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 部门名称 */
|
||||
.org-tree-node__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 部门人数标签 */
|
||||
.org-tree-node__count {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #c8c9cc);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 员工节点 */
|
||||
.org-tree-node--emp {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* checkbox 禁用指针事件,所有点击由节点统一处理 */
|
||||
.org-tree-node--emp :deep(.van-checkbox) {
|
||||
pointer-events: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 底部操作栏 */
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<template>
|
||||
<!-- 审批卡片消息:内联卡片(msg_type === 'approval_card') -->
|
||||
<div v-if="msg.msg_type === 'approval_card'" class="message-bubble message-bubble--approval-card">
|
||||
<ApprovalCardModal :approval-type="msg.extra_data?.approval_type || ''" />
|
||||
<ApprovalCardModal v-if="msg.extra_data?.action?.card_data" :card-data="msg.extra_data.action.card_data" />
|
||||
</div>
|
||||
|
||||
<!-- BYOD补贴卡片消息:内联卡片(msg_type === 'byod_card') -->
|
||||
@@ -74,12 +74,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本消息 -->
|
||||
<!-- 文本消息(包含直接审批链接卡片,v2.7修复) -->
|
||||
<template v-if="!msg.msg_type || msg.msg_type === 'text'">
|
||||
<p class="message-bubble__text" style="white-space: pre-wrap;">{{ msg.content }}</p>
|
||||
<!-- v3.0: 审批卡片统一通过 card_data 渲染 -->
|
||||
<div v-if="msg.extra_data?.action?.card_data" class="ai-action">
|
||||
<ApprovalCardModal :card-data="msg.extra_data.action.card_data" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- v2.0: AI 结构化消息 — 文字内容 + 交互式选项按钮 -->
|
||||
<!-- v2.0: AI 结构化消息 — 文字内容 + 交互式选项按钮 + 审批入口 -->
|
||||
<template v-else-if="msg.msg_type === 'ai_structured'">
|
||||
<!-- 文字部分(与普通文本消息一致) -->
|
||||
<p class="message-bubble__text" style="white-space: pre-wrap;">{{ msg.content }}</p>
|
||||
@@ -94,17 +98,22 @@
|
||||
{{ option.label || option.value }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- v3.0: 审批卡片统一通过 card_data 渲染 -->
|
||||
<div v-if="msg.extra_data?.action?.card_data" class="ai-action">
|
||||
<ApprovalCardModal :card-data="msg.extra_data.action.card_data" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 图片消息:显示缩略图(可点击查看大图) -->
|
||||
<template v-else-if="msg.msg_type === 'image'">
|
||||
<div class="image-message" @click="previewImage">
|
||||
<div class="image-message message-image" @click="previewImage" style="max-width: 100px !important;">
|
||||
<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"
|
||||
style="max-width: 100px !important;"
|
||||
/>
|
||||
<!-- 无 URL 时显示占位卡片 -->
|
||||
<div v-else class="media-card">
|
||||
@@ -619,12 +628,18 @@ function handleOptionSelect(option: { value: string; label: string }): void {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-width: 220px;
|
||||
max-width: 100px !important;
|
||||
}
|
||||
|
||||
/* 图片消息气泡宽度调整 */
|
||||
.message-image {
|
||||
width: auto;
|
||||
max-width: 100px !important;
|
||||
}
|
||||
|
||||
.image-message__thumbnail {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
max-width: 100px !important;
|
||||
max-height: 180px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
@@ -829,4 +844,48 @@ function handleOptionSelect(option: { value: string; label: string }): void {
|
||||
background: #ee0a24;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
// 直接审批卡片样式(v2.7 新增)
|
||||
// ============================================================================ */
|
||||
.approval-direct-card {
|
||||
margin-top: 8px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #f0f9eb 0%, #e8f5e9 100%);
|
||||
border: 1px solid #ccefd0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.approval-direct-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.approval-direct-card__desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.approval-direct-card__btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: #07C160;
|
||||
color: white;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.approval-direct-card__btn:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,13 +39,14 @@
|
||||
|
||||
<!-- 图片消息:显示缩略图(可点击查看大图) -->
|
||||
<template v-else-if="msg.msg_type === 'image'">
|
||||
<div class="image-message" @click="previewImage">
|
||||
<div class="image-message" @click="previewImage" style="max-width: 100px !important;">
|
||||
<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"
|
||||
style="max-width: 100px !important;"
|
||||
/>
|
||||
<div v-else class="media-card">
|
||||
<div class="media-card__icon">🖼️</div>
|
||||
@@ -526,12 +527,12 @@ function previewImage(): void {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-width: 220px;
|
||||
max-width: 100px !important;
|
||||
}
|
||||
|
||||
.image-message__thumbnail {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
max-width: 100px !important;
|
||||
max-height: 180px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
:style="{
|
||||
borderColor: ROLE_BORDER_COLORS[p.role],
|
||||
}"
|
||||
@mouseenter="hoveredParticipant = p"
|
||||
@mouseleave="hoveredParticipant = null"
|
||||
>
|
||||
<!-- 有头像URL:渲染<img>,加载失败降级显示首字 -->
|
||||
<img
|
||||
@@ -51,11 +53,47 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 邀请按钮(发起人 + 已在会话中的被邀请人均可见) -->
|
||||
<button
|
||||
v-if="canInvite"
|
||||
class="participant-strip__invite-btn"
|
||||
title="邀请同事加入会话"
|
||||
@click.stop="showInviteSheet = true"
|
||||
>
|
||||
+ 邀请
|
||||
</button>
|
||||
|
||||
<!-- 展开箭头 -->
|
||||
<span class="participant-strip__arrow">▼</span>
|
||||
|
||||
<!-- 待加入角标(P1:有待加入参与者时显示红点) -->
|
||||
<span v-if="hasPending" class="participant-strip__badge-dot"></span>
|
||||
|
||||
<!-- 悬停 Tooltip(仅 PC 端 hover 触发,移动端不显示) -->
|
||||
<div
|
||||
v-if="hoveredParticipant"
|
||||
class="participant-strip__tooltip"
|
||||
:style="{ borderColor: ROLE_BORDER_COLORS[hoveredParticipant.role] }"
|
||||
>
|
||||
<div class="participant-strip__tooltip-name">
|
||||
{{ hoveredParticipant.name }}
|
||||
<span
|
||||
class="participant-strip__tooltip-role"
|
||||
:class="`participant-strip__tooltip-role--${hoveredParticipant.role}`"
|
||||
>{{ roleDisplayText(hoveredParticipant) }}</span>
|
||||
</div>
|
||||
<div v-if="hoveredParticipant.department" class="participant-strip__tooltip-dept">
|
||||
{{ hoveredParticipant.department }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请参与者弹层(点击 +邀请 按钮触发) -->
|
||||
<InviteParticipantSheet
|
||||
v-model:show="showInviteSheet"
|
||||
:conversation-id="store.currentConversation?.conversation_id || ''"
|
||||
:existing-participant-ids="existingParticipantIds"
|
||||
@invited="handleInvited"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -63,15 +101,23 @@
|
||||
/**
|
||||
* ParticipantStrip 缩略头像条
|
||||
* 默认模式:一行紧凑头像条,点击展开
|
||||
*
|
||||
* 2026-07-13 增强:
|
||||
* 1. 头像 hover(PC 端)显示 tooltip:姓名 + 角色 + 部门
|
||||
* 2. 新增 "+邀请" 按钮(发起人 + 已在会话中的被邀请人可见),点击打开 InviteParticipantSheet
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useEmployeeStore } from '@/stores/employee'
|
||||
import {
|
||||
useParticipantDisplay,
|
||||
ROLE_BORDER_COLORS,
|
||||
type NormalizedParticipant,
|
||||
} from '@/composables/useParticipantDisplay'
|
||||
import InviteParticipantSheet from './InviteParticipantSheet.vue'
|
||||
|
||||
const store = useConversationStore()
|
||||
const employeeStore = useEmployeeStore()
|
||||
|
||||
/** 超员阈值 N=4 */
|
||||
const MAX_AVATARS = 4
|
||||
@@ -79,6 +125,12 @@ const MAX_AVATARS = 4
|
||||
/** 头像加载失败标记:按 id 记录 */
|
||||
const failedIds = ref<Record<string, boolean>>({})
|
||||
|
||||
/** 悬停中的参与者(用于 tooltip 显示,仅 PC 端 hover 触发) */
|
||||
const hoveredParticipant = ref<NormalizedParticipant | null>(null)
|
||||
|
||||
/** 邀请弹层是否显示 */
|
||||
const showInviteSheet = ref(false)
|
||||
|
||||
// composable 获取规范化数据
|
||||
const { normalizedParticipants, totalCount, hasPending } = useParticipantDisplay()
|
||||
|
||||
@@ -92,6 +144,44 @@ const overflowCount = computed(() =>
|
||||
Math.max(0, normalizedParticipants.value.length - MAX_AVATARS)
|
||||
)
|
||||
|
||||
/**
|
||||
* 当前用户是否可以邀请参与者(会话发起人 或 已在会话中的参与者)
|
||||
* 复用 ParticipantList 的 canInvite 逻辑,保证两端一致
|
||||
*/
|
||||
const canInvite = computed(() => {
|
||||
const conv = store.currentConversation
|
||||
if (!conv) return false
|
||||
// 会话发起人可以邀请
|
||||
if (conv.employee_id === employeeStore.employeeId) return true
|
||||
// 已在会话中的参与者(被邀请人)也可以继续邀请其他人
|
||||
return normalizedParticipants.value.some(p => p.id === employeeStore.employeeId)
|
||||
})
|
||||
|
||||
/**
|
||||
* tooltip 角色显示文案
|
||||
* 区分主责/协作/发起人/被邀请人(被邀请人显示加入状态而非角色)
|
||||
*/
|
||||
function roleDisplayText(p: NormalizedParticipant): string {
|
||||
if (p.role === 'primary_agent') return '主责坐席'
|
||||
if (p.role === 'collaborator') return '协作坐席'
|
||||
if (p.role === 'owner') return '发起人'
|
||||
return p.joined ? '已加入' : '待加入'
|
||||
}
|
||||
|
||||
/** 已在会话中的参与者 ID 列表(用于邀请弹层禁用勾选) */
|
||||
const existingParticipantIds = computed(() => {
|
||||
return normalizedParticipants.value.map((p) => p.id).filter(Boolean)
|
||||
})
|
||||
|
||||
/**
|
||||
* 邀请成功回调
|
||||
* 做什么:关闭邀请弹层,刷新会话获取最新参与者列表
|
||||
*/
|
||||
async function handleInvited(): Promise<void> {
|
||||
showInviteSheet.value = false
|
||||
await store.fetchCurrentConversation()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取姓名首字作为降级头像
|
||||
* H5 端使用首字 charAt(0)
|
||||
@@ -228,4 +318,101 @@ function handleExpand(): void {
|
||||
background-color: #ee0a24;
|
||||
border: 1px solid var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* 邀请按钮(发起人 + 已在会话中的被邀请人均可见) */
|
||||
.participant-strip__invite-btn {
|
||||
flex-shrink: 0;
|
||||
height: 22px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--accent, #07C160);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--accent, #07C160);
|
||||
border-radius: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.participant-strip__invite-btn:hover {
|
||||
background: var(--accent, #07C160);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.participant-strip__invite-btn:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* 悬停 Tooltip(PC 端 hover 头像触发) */
|
||||
.participant-strip__tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 16px;
|
||||
z-index: 100;
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
padding: 8px 12px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--accent, #07C160);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.participant-strip__tooltip-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.participant-strip__tooltip-role {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 角色标签配色(与 ROLE_BORDER_COLORS 呼应) */
|
||||
.participant-strip__tooltip-role--primary_agent {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.participant-strip__tooltip-role--collaborator {
|
||||
background: rgba(7, 193, 96, 0.25);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.participant-strip__tooltip-role--owner {
|
||||
background: rgba(255, 152, 0, 0.25);
|
||||
color: #ffd699;
|
||||
}
|
||||
|
||||
.participant-strip__tooltip-role--invitee {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.participant-strip__tooltip-dept {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 移动端隐藏 tooltip(触摸端无 hover,避免误触显示) */
|
||||
@media (max-width: 768px) {
|
||||
.participant-strip__tooltip {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -434,6 +434,17 @@ export function useH5WebSocket() {
|
||||
}
|
||||
break
|
||||
|
||||
// ==================================================================
|
||||
// v3.0 新增:资产推荐(分层展示 L1/L2/L3)
|
||||
// ==================================================================
|
||||
case 'asset_recommend':
|
||||
// 后端推送的资产推荐(包含 L1/L2/L3 分层信息)
|
||||
// 做什么:将资产推荐数据存入 store,DynamicRecommend 组件分层渲染
|
||||
if (msg.data) {
|
||||
store.handleAssetRecommend(msg.data)
|
||||
}
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
// 心跳响应,不需要处理
|
||||
break
|
||||
|
||||
@@ -83,12 +83,6 @@ const routes = [
|
||||
component: () => import('@/views/AutomationProgress.vue'),
|
||||
meta: { title: '自动化处置', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/intro',
|
||||
name: 'VideoIntro',
|
||||
component: () => import('@/views/VideoIntro.vue'),
|
||||
meta: { title: '智能IT服务', requiresAuth: false },
|
||||
},
|
||||
// 404 兜底:未匹配的路径重定向到首页
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
@@ -116,8 +110,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
to.name === 'WeworkOnly' ||
|
||||
to.name === 'Bind' ||
|
||||
to.name === 'EmergencyDispatcher' ||
|
||||
to.name === 'H5Preview' ||
|
||||
to.name === 'VideoIntro'
|
||||
to.name === 'H5Preview'
|
||||
) {
|
||||
next()
|
||||
return
|
||||
@@ -188,19 +181,6 @@ router.beforeEach(async (to, _from, next) => {
|
||||
return
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 视频引导页:首次访问且未看过视频 → 重定向到 /intro
|
||||
// 注意:必须放在企微环境检测之后,确保只有企微用户才看视频
|
||||
// 条件:不是 VideoIntro 页面 + localStorage 无已观看标记 + 不是 OAuth2 回调(带 code 参数)
|
||||
// ========================================================================
|
||||
if (to.name !== 'VideoIntro' && !localStorage.getItem('h5_video_intro_watched_v2')) {
|
||||
const hasCode = to.query.code || new URLSearchParams(window.location.search).get('code')
|
||||
if (!hasCode) {
|
||||
next({ name: 'VideoIntro' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 URL 中的 code 参数(企微 OAuth2 回调)
|
||||
// 优先使用 Vue Router 的 route.query(更可靠,不受 nginx 重写影响)
|
||||
// 降级使用 window.location.search(兜底,防止某些场景下 route.query 未解析)
|
||||
@@ -220,6 +200,26 @@ router.beforeEach(async (to, _from, next) => {
|
||||
? `${window.location.pathname}?${remainingCodeSearch}`
|
||||
: window.location.pathname
|
||||
window.history.replaceState({}, '', cleanCodeUrl)
|
||||
|
||||
// 🔧 Bug 修复:恢复邀请链接参数
|
||||
// OAuth2 回调后 invite/eid 参数已丢失,从 sessionStorage 恢复
|
||||
// 使用 Vue Router 的 next() 重定向(而非 window.history.replaceState),
|
||||
// 确保 route.query 在 ChatView 组件中可被正确读取
|
||||
// (replaceState 只改浏览器 URL,不会更新 Vue Router 的 to 路由对象)
|
||||
const savedInviteId = sessionStorage.getItem('h5_invite_conv_id')
|
||||
if (savedInviteId) {
|
||||
const savedEid = sessionStorage.getItem('h5_invite_eid') || ''
|
||||
// 清理 sessionStorage
|
||||
sessionStorage.removeItem('h5_invite_conv_id')
|
||||
sessionStorage.removeItem('h5_invite_eid')
|
||||
// 重定向到相同路径,携带恢复的邀请参数
|
||||
// 守卫会再次执行,但此时已认证 + 有 invite 参数,会快速通过
|
||||
const restoreQuery: Record<string, string> = { invite: savedInviteId }
|
||||
if (savedEid) restoreQuery.eid = savedEid
|
||||
next({ path: to.path, query: restoreQuery })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (error) {
|
||||
console.error('[Router] OAuth2 授权失败:', error)
|
||||
|
||||
@@ -211,6 +211,63 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
/** 动态推荐未查看数量(用于 Badge 红点提示) */
|
||||
const unreadRecommendCount = ref<number>(0)
|
||||
|
||||
// ==========================================================================
|
||||
// 资产推荐卡片(v3.0 新增,由 WS asset_recommend 事件推送)
|
||||
// ==========================================================================
|
||||
/**
|
||||
* 资产推荐卡片列表(由后端 asset_recommend_service 推送)
|
||||
* 做什么:存储 L1/L2/L3 分层推荐卡片,供 DynamicRecommend 组件渲染
|
||||
* 数据结构:{ id, layer, layer_label, source, title, description, items, ... }
|
||||
*/
|
||||
const assetRecommendations = ref<Array<{
|
||||
id: string
|
||||
layer: string
|
||||
layer_label: string
|
||||
source: string
|
||||
title: string
|
||||
description?: string
|
||||
icon?: string
|
||||
items?: Array<{
|
||||
label: string
|
||||
type: string
|
||||
url?: string
|
||||
value?: string
|
||||
copyable?: boolean
|
||||
approval_type?: string
|
||||
}>
|
||||
action_url?: string
|
||||
confidence?: number
|
||||
relevance?: string
|
||||
}>>([])
|
||||
|
||||
/**
|
||||
* 处理资产推荐卡片(v3.0 新增)
|
||||
* 做什么:将后端推送的资产推荐数据存入 assetRecommendations
|
||||
*/
|
||||
function handleAssetRecommend(data: {
|
||||
recommends: Array<any>
|
||||
layered?: boolean
|
||||
push_type?: string
|
||||
}): void {
|
||||
if (!data.recommends || data.recommends.length === 0) return
|
||||
|
||||
// 添加到推荐列表(最多 5 张,超过时移除最旧的)
|
||||
assetRecommendations.value.push(...data.recommends)
|
||||
if (assetRecommendations.value.length > 5) {
|
||||
assetRecommendations.value.splice(0, assetRecommendations.value.length - 5)
|
||||
}
|
||||
// 增加未读计数
|
||||
unreadRecommendCount.value += data.recommends.length
|
||||
console.log('[Store] 资产推荐已接收:', data.recommends.length, '条, 未读:', unreadRecommendCount.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有资产推荐
|
||||
*/
|
||||
function clearAssetRecommend(): void {
|
||||
assetRecommendations.value = []
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 流式 AI 回复(打字机)临时状态 — 改造方案 A
|
||||
// ==========================================================================
|
||||
@@ -361,7 +418,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
conversation_id: data.conversation_id,
|
||||
message_type: (data.sender_type || 'system') as MessageType,
|
||||
msg_type: (data.msg_type || 'text') as MsgContentType,
|
||||
content: data.content,
|
||||
// ★ 防御性类型保护:确保 content 始终是 String
|
||||
content: typeof data.content === 'string' ? data.content : (data.content ? JSON.stringify(data.content) : ''),
|
||||
sender_name: data.sender_name || '',
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
@@ -574,6 +632,22 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
currentConversation.value.ai_substantive_reply_count = resp.ai_reply_count ?? 0
|
||||
}
|
||||
|
||||
// 关键修复:如果发送消息前没有活跃会话(currentConversation 为 null),
|
||||
// 说明这是第一条消息创建了新会话。此时必须重新获取会话信息,
|
||||
// 否则 currentConversation.value?.conversation_id 为 undefined,
|
||||
// 所有后续 WS 事件(ai_reply / ai_thinking / dynamic_recommend 等)
|
||||
// 都会因 conversation_id 不匹配(undefined !== actual_id)被静默丢弃。
|
||||
if (!currentConversation.value && resp.user_message?.conversation_id) {
|
||||
console.log('[Store] 发送消息后 currentConversation 为 null,重新获取会话信息...')
|
||||
await fetchCurrentConversation()
|
||||
if (currentConversation.value) {
|
||||
// 获取到新会话后,同步状态
|
||||
currentConversation.value.can_call_agent = resp.can_call_agent ?? false
|
||||
currentConversation.value.ai_substantive_reply_count = resp.ai_reply_count ?? 0
|
||||
console.log('[Store] 新会话已获取:', currentConversation.value.conversation_id)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[Store] 消息发送成功, AI回复计数:',
|
||||
resp.ai_reply_count,
|
||||
@@ -1074,7 +1148,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
conversation_id: data.conversation_id,
|
||||
message_type: (data.sender_type || 'ai') as MessageType,
|
||||
msg_type: (data.msg_type || 'text') as MsgContentType,
|
||||
content: data.content,
|
||||
// ★ 防御性类型保护:确保 content 始终是 String
|
||||
// 后端可能因 Dify 返回异常导致 content 为对象,此时转为 JSON 字符串
|
||||
content: typeof data.content === 'string' ? data.content : (data.content ? JSON.stringify(data.content) : ''),
|
||||
sender_name: data.sender_name || 'Duckula(达寇拉)',
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'sent',
|
||||
@@ -1671,6 +1747,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
dynamicRecommendations,
|
||||
unreadRecommendCount,
|
||||
|
||||
// v3.0 新增:资产推荐(分层展示)
|
||||
handleAssetRecommend,
|
||||
clearAssetRecommend,
|
||||
assetRecommendations,
|
||||
|
||||
// 排队/关闭机制 WS 事件处理
|
||||
queuePositionData,
|
||||
pendingCloseRequest,
|
||||
|
||||
@@ -298,6 +298,17 @@ export const useEmployeeStore = defineStore('employee', () => {
|
||||
* 失败则本地构造
|
||||
*/
|
||||
async function redirectToOAuth(): Promise<void> {
|
||||
// 🔧 Bug 修复:保存邀请链接参数到 sessionStorage,OAuth2 回调后恢复
|
||||
// 当未登录用户打开邀请链接时,OAuth2 跳转会丢失 invite/eid 参数,
|
||||
// 此处在跳转前保存,回调后由路由守卫恢复
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const inviteId = urlParams.get('invite')
|
||||
const eid = urlParams.get('eid')
|
||||
if (inviteId) {
|
||||
sessionStorage.setItem('h5_invite_conv_id', inviteId)
|
||||
if (eid) sessionStorage.setItem('h5_invite_eid', eid)
|
||||
}
|
||||
|
||||
// 当前页面的完整回调地址
|
||||
const currentRedirectUri = window.location.origin + '/h5/'
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
<!--
|
||||
// =============================================================================
|
||||
// 智能IT服务 — H5 登录前视频引导页
|
||||
// =============================================================================
|
||||
// 说明:首次访问时播放品牌宣传视频,播放结束或用户跳过后进入正常流程
|
||||
// - 全屏视频自动播放(muted,移动端企微 WebView 兼容)
|
||||
// - 右上角"跳过"按钮,3 秒后显示
|
||||
// - localStorage 记录已观看,后续访问跳过
|
||||
// - 视频加载失败 → 直接跳过
|
||||
// =============================================================================
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="video-intro">
|
||||
<!-- 视频元素 -->
|
||||
<video
|
||||
ref="videoRef"
|
||||
class="intro-video"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
:poster="''"
|
||||
@ended="finishIntro"
|
||||
@error="finishIntro"
|
||||
@canplay="onCanPlay"
|
||||
>
|
||||
<source :src="videoSrc" type="video/mp4" />
|
||||
</video>
|
||||
|
||||
<!-- 加载中动画 -->
|
||||
<div v-if="loading" class="intro-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">正在加载...</p>
|
||||
</div>
|
||||
|
||||
<!-- 跳过按钮(3 秒后显示) -->
|
||||
<button
|
||||
v-if="showSkip"
|
||||
class="skip-btn"
|
||||
@click="finishIntro"
|
||||
>
|
||||
跳过 >
|
||||
</button>
|
||||
|
||||
<!-- 底部品牌文字 -->
|
||||
<div class="intro-brand">
|
||||
<span class="brand-name">智能IT服务</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
const loading = ref(true)
|
||||
const showSkip = ref(false)
|
||||
|
||||
// 视频源路径(H5 base 路径为 /h5/,public 目录下文件直接相对于 base)
|
||||
const videoSrc = '/h5/video/intro.mp4'
|
||||
|
||||
// 跳过按钮定时器
|
||||
let skipTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 视频可以播放时隐藏 loading */
|
||||
function onCanPlay() {
|
||||
loading.value = false
|
||||
// 3 秒后显示跳过按钮
|
||||
skipTimer = setTimeout(() => {
|
||||
showSkip.value = true
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/** 完成视频引导,记录 localStorage 并跳转首页 */
|
||||
function finishIntro() {
|
||||
// 记录已观看标记
|
||||
localStorage.setItem('h5_video_intro_watched_v2', 'true')
|
||||
|
||||
// 清理定时器
|
||||
if (skipTimer) {
|
||||
clearTimeout(skipTimer)
|
||||
skipTimer = null
|
||||
}
|
||||
|
||||
// 跳转到首页(ChatView),replace 避免返回键回到视频页
|
||||
router.replace({ name: 'ChatView' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 尝试播放视频(某些移动端浏览器需要显式 play() 调用)
|
||||
if (videoRef.value) {
|
||||
videoRef.value.play().catch(() => {
|
||||
// 自动播放被拒绝或失败 → 直接跳过视频
|
||||
finishIntro()
|
||||
})
|
||||
}
|
||||
|
||||
// 安全兜底:如果 15 秒后视频还没结束(可能是卡住),自动跳过
|
||||
skipTimer = setTimeout(() => {
|
||||
finishIntro()
|
||||
}, 15000) as ReturnType<typeof setTimeout>
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (skipTimer) {
|
||||
clearTimeout(skipTimer)
|
||||
skipTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-intro {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.intro-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 加载中动画 */
|
||||
.intro-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: #07C160;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 跳过按钮 */
|
||||
.skip-btn {
|
||||
position: absolute;
|
||||
top: env(safe-area-inset-top, 20px);
|
||||
top: max(20px, env(safe-area-inset-top));
|
||||
right: 20px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.skip-btn:active {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* 底部品牌文字 */
|
||||
.intro-brand {
|
||||
position: absolute;
|
||||
bottom: env(safe-area-inset-bottom, 40px);
|
||||
bottom: max(40px, env(safe-area-inset-bottom));
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user