v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化

This commit is contained in:
Simon
2026-07-17 23:08:59 +08:00
parent 5a77a89ab1
commit 3ed86d5fb3
181 changed files with 19738 additions and 2655 deletions
+34 -34
View File
@@ -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 意图置信度 — 替代纯关键词匹配)
// =============================================================================
+6 -2
View File
@@ -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
}
/**
+2 -1
View File
@@ -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_MAPURL 全部由后端推送
// 推荐卡片类型定义
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
}
+49 -56
View File
@@ -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(搜索 + 组织架构),支持多选员工后邀请加入会话
// - 搜索 Tabvan-search 输入框 + 结果列表(300ms debounce
// - 组织架构 Tabvan-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. 头像 hoverPC 端)显示 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);
}
/* 悬停 TooltipPC 端 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 分层信息)
// 做什么:将资产推荐数据存入 storeDynamicRecommend 组件分层渲染
if (msg.data) {
store.handleAssetRecommend(msg.data)
}
break
case 'pong':
// 心跳响应,不需要处理
break
+21 -21
View File
@@ -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)
+83 -2
View File
@@ -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,
+11
View File
@@ -298,6 +298,17 @@ export const useEmployeeStore = defineStore('employee', () => {
* 失败则本地构造
*/
async function redirectToOAuth(): Promise<void> {
// 🔧 Bug 修复:保存邀请链接参数到 sessionStorageOAuth2 回调后恢复
// 当未登录用户打开邀请链接时,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/'
-204
View File
@@ -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>