批次4死代码大扫除: 删triage三件套(H5零挂载)+ /approval/keywords端点 + scheduler.py孤儿模块 + 3个.bak文件 + get_reply_stream(~96行)

This commit is contained in:
Simon
2026-07-18 10:22:07 +08:00
parent 6a5f01ff90
commit b997a683ce
11 changed files with 28 additions and 3238 deletions
@@ -1,5 +1,27 @@
# 早班巡检自动化 - 执行记录
## 2026-07-18 09:30 执行结果
**数据来源**:主文档第四章 v2.8 (2026-07-14) + 独立看板 `docs/10-项目管理/项目状态看板.md` v1.0 (07-17) + 上次巡检记忆 (07-17)
**说明**:指定路径 `docs/10-项目管理/05-项目状态看板/01-项目状态看板.md` 连续第10次不存在。独立看板v1.0已更新(#76/#82 07-17入完成区),但主文档第四章未同步
### 关键发现
1. **P0待办2项**#81敏感词检测+语气优化(阻塞14天,约07-21到期需启动)、#104结构化日志查看页(无阻塞已4天未启动);#117 Neo4j已完成但仍列P0区未清理(数据不一致持续2次巡检)
2. **P1待办3项**#80坐席图片预览(数据不一致持续2次——已完成区07-16 vs P1清单仍列"待排查")、#73后端文件覆盖#86流程图review
3. **等用户决策2项,均超3天阈值**:企微会议室Secret(自07-117天)、ITSM API授权(自07-11,7天)— 需PM立即关注;联软网络不通标记"暂不处理"不视为卡点
4. **进行中0项**:主文档和独立看板均为空。上次#76已于07-17完成
5. **数据质量问题持续**#81编号冲突P0敏感词 vs 已完成粘贴图片)、#80/#117双重列出、主文档"已完成"区滞后(07-17 #76/#82未入区
6. **07-17完成2项**#76 ITSM工单卡片跳转(桥接页+扫码登录)、#82 H5右侧栏布局调整 — 已入独立看板v1.0
7. **看板路径第10次缺失**:指定路径连续10次巡检不存在,建议统一看板源
### 全局状态
- P0待办:2项(#81约07-21到期、#104未启动4天
- P1待办:3项(#80可能已完成待确认
- 等决策:2项(均超3天阈值,7天)
- 进行中:0项
---
## 2026-07-17 09:30 执行结果
**数据来源**:主文档第四章 v2.7+ (含07-16更新) + 独立看板 `docs/10-项目管理/项目状态看板.md` v1.0 (07-17) + 记忆文件 (07-16)
+3 -15
View File
@@ -983,18 +983,6 @@ async def urge_approval(
return success_response(data=result)
@router.get("/approval/keywords")
async def get_approval_keywords():
"""获取所有审批关键词(用于前端关键词检测)"""
keywords = []
for template in APPROVAL_TEMPLATES.values():
for kw in template["keywords"]:
keywords.append({
"keyword": kw,
"template_id": template["id"],
"template_name": template["name"],
"type": template["type"],
})
return success_response(data=keywords)
# v4.0 批次4GET /approval/keywords 端点已删除
# (前端 getApprovalKeywords 调用已随 v3.0 ApprovalCardModal 重构移除,
# 仅剩 .bak 备份文件引用;关键词匹配现由 ApprovalMatcher 后端统一处理)
File diff suppressed because it is too large Load Diff
+3 -96
View File
@@ -231,102 +231,9 @@ class AIService:
# --------------------------------------------------------------------------
# 流式调用:SSE 流式返回(供 WebSocket 推送给前端)
# --------------------------------------------------------------------------
async def get_reply_stream(
self,
message: str,
conversation_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> AsyncGenerator[Dict[str, Any], None]:
"""调用 Dify API 获取流式 AI 回复(SSE),逐块 yield 给调用方。
Yields:
Dict: {"delta": str, "finished": bool, "conversation_id": str, "hit": bool|None}
- 流式中间块:{"delta": 增量, "finished": False, "hit": None}
- 终态块:{"delta": "", "finished": True, "hit": 命中判断}
实现:
- stream=True 走 SSE,解析 data: {...} 行,逐块 yield delta
- 流结束后用完整内容整体判断 hit_check_knowledge_hit
容错:若 Dify 不支持流式 / 超时 / 非 SSE 格式,catch 后 fallback 到
get_reply 非流式,yield 一次完整内容(前端退化为"整段到达"
功能不破,仅无逐字动画)。
"""
payload = {
"model": "Chat",
"messages": [{"role": "user", "content": message}],
"stream": True,
"temperature": 0.1,
}
if conversation_id:
payload["conversation_id"] = conversation_id
if user_id:
payload["user"] = user_id
try:
client = await self._get_client()
full_parts: list = []
dify_conv_id = conversation_id or ""
async with client.stream("POST", self.api_url, json=payload) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line:
continue
line = line.strip()
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
# OpenAI / Dify SSE 格式:choices[0].delta.content
try:
delta = chunk["choices"][0]["delta"].get("content", "")
except (KeyError, IndexError, TypeError):
delta = ""
if delta:
full_parts.append(delta)
yield {
"delta": delta,
"finished": False,
"conversation_id": dify_conv_id,
"hit": None,
}
# Dify 可能在流式块里给出 conversation_id
cid = chunk.get("conversation_id")
if cid:
dify_conv_id = cid
# 流结束:用完整内容判断命中
full_content = "".join(full_parts)
hit = self._check_knowledge_hit(full_content) if full_content else False
yield {
"delta": "",
"finished": True,
"conversation_id": dify_conv_id,
"hit": hit,
}
except Exception as e:
# 流式不可用(dify2openai 不支持 / 超时 / 非 SSE),回退非流式
logger.warning(f"Dify 流式失败,回退非流式: {e}")
try:
result = await self.get_reply(message, conversation_id, user_id)
yield {
"delta": result["content"],
"finished": True,
"conversation_id": result["conversation_id"],
"hit": result["hit"],
}
except Exception as e2:
logger.error(f"Dify 流式与非流式均失败: {e2}")
yield {
"delta": "⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。",
"finished": True,
"conversation_id": conversation_id or "",
"hit": False,
}
# v4.0 批次4get_reply_stream 已删除(~96 行)
# v2.0 起 AI 回复改为 blocking + JSON 结构化(get_structured_reply),
# 流式 SSE 路径零调用,属死代码。
# --------------------------------------------------------------------------
# 结构化调用:blocking 模式,返回解析后的 JSON {text, action, options}
-149
View File
@@ -1,149 +0,0 @@
# -*- coding: utf-8 -*-
"""
资产推荐定时任务
功能:
1. 每日运维提醒推送(L2
2. 画像缓存预热
3. 新员工欢迎推送(L3
"""
import asyncio
import logging
from datetime import datetime
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
logger = logging.getLogger(__name__)
# 定时任务调度器实例
# v4.0 P1-7 修复:AsyncIOSScheduler → AsyncIOScheduler(原拼写错误,import 即 NameError
# 注意:本模块当前无人 importmain.py 使用自己的 _scheduler),批次 4 候选删除
scheduler = AsyncIOScheduler()
def setup_scheduled_tasks():
"""配置定时任务"""
# 每日 9:00 运维提醒
scheduler.add_job(
daily_maintenance_push,
trigger=CronTrigger(hour=9, minute=0),
id='daily_maintenance_push',
name='每日运维提醒推送',
replace_existing=True
)
# 每小时画像缓存刷新
scheduler.add_job(
hourly_profile_sync,
trigger=CronTrigger(minute=0),
id='hourly_profile_sync',
name='每小时员工画像同步',
replace_existing=True
)
# 每天 8:55 检查新员工
scheduler.add_job(
check_new_employees,
trigger=CronTrigger(hour=8, minute=55),
id='check_new_employees',
name='新员工欢迎检查',
replace_existing=True
)
logger.info(f"[Scheduler] 已配置 {len(scheduler.get_jobs())} 个定时任务")
async def daily_maintenance_push():
"""每日运维提醒推送"""
logger.info("[Scheduler] 开始执行每日运维提醒推送")
from app.services.asset_recommend_service import get_asset_recommend_service
from app.services.employee_profile_service import get_employee_profile_service
asset_service = get_asset_recommend_service()
profile_service = get_employee_profile_service()
# 获取全部员工(分页)
# TODO: 实现分页获取员工列表
# page = 1
# while True:
# employees = await get_employees_paginated(page, 100)
# if not employees:
# break
#
# for emp in employees:
# try:
# profile = await profile_service.get_profile(emp.id)
# profile_dict = {
# 'huorong_version': profile.huorong_version,
# 'huorong_virusdb_date': profile.huorong_virusdb_date,
# 'huorong_offline_days': profile.huorong_offline_days,
# 'unionsoft_patches_missing': profile.unionsoft_patches_missing,
# 'unionsoft_violations': profile.unionsoft_violations,
# }
# l2_recs = asset_service.match_profile_triggers(profile_dict)
#
# if l2_recs:
# ws_msg = asset_service.build_ws_message(l2_recs)
# from app.services.ws_manager import manager as ws_manager
# await ws_manager.broadcast_to_employees([emp.id], ws_msg)
#
# except Exception as e:
# logger.error(f"[Scheduler] 推送失败: {emp.id}, {e}")
#
# page += 1
logger.info("[Scheduler] 每日运维提醒推送完成 (TODO: 实现员工列表获取)")
async def hourly_profile_sync():
"""每小时同步员工画像缓存"""
logger.info("[Scheduler] 开始同步员工画像缓存")
try:
profile_service = get_employee_profile_service()
deleted = await profile_service.clear_expired_cache()
logger.info(f"[Scheduler] 画像缓存同步完成,清理 {deleted}")
except Exception as e:
logger.error(f"[Scheduler] 画像缓存同步失败: {e}")
async def check_new_employees():
"""检查新员工并发送欢迎"""
logger.info("[Scheduler] 检查新员工")
# TODO: 实现新员工检测逻辑
# 获取过去 24 小时入职的员工
# new_employees = await get_new_employees(days=1)
#
# for emp in new_employees:
# asset_service = get_asset_recommend_service()
# role_recs = asset_service.get_by_role('new_employee')
#
# if role_recs:
# ws_msg = asset_service.build_ws_message(role_recs)
# from app.services.ws_manager import manager as ws_manager
# await ws_manager.broadcast_to_employees([emp.id], ws_msg)
logger.info("[Scheduler] 新员工检查完成 (TODO: 实现)")
def start_scheduler():
"""启动定时任务调度器"""
if not scheduler.running:
setup_scheduled_tasks()
scheduler.start()
logger.info("定时任务调度器已启动")
def stop_scheduler():
"""停止定时任务调度器"""
if scheduler.running:
scheduler.shutdown()
logger.info("定时任务调度器已停止")
-3
View File
@@ -24,11 +24,9 @@ declare module 'vue' {
EvaluationDialog: typeof import('./src/components/chat/EvaluationDialog.vue')['default']
ImageUploader: typeof import('./src/components/ImageUploader.vue')['default']
InputBar: typeof import('./src/components/chat/InputBar.vue')['default']
InputBox: typeof import('./src/components/chat/InputBox.vue')['default']
InviteParticipantSheet: typeof import('./src/components/chat/InviteParticipantSheet.vue')['default']
ITHealthDashboard: typeof import('./src/components/assistant/ITHealthDashboard.vue')['default']
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
MessageItem: typeof import('./src/components/chat/MessageItem.vue')['default']
ParticipantList: typeof import('./src/components/chat/ParticipantList.vue')['default']
ParticipantStrip: typeof import('./src/components/chat/ParticipantStrip.vue')['default']
QueueWaiting: typeof import('./src/components/assistant/QueueWaiting.vue')['default']
@@ -45,7 +43,6 @@ declare module 'vue' {
SoftwareAndApply: typeof import('./src/components/assistant/SoftwareAndApply.vue')['default']
SoftwareDownloads: typeof import('./src/components/assistant/SoftwareDownloads.vue')['default']
SoftwareInstall: typeof import('./src/components/assistant/SoftwareInstall.vue')['default']
TriageCard: typeof import('./src/components/TriageCard.vue')['default']
TroubleshootFlow: typeof import('./src/components/chat/TroubleshootFlow.vue')['default']
TroubleshootProgress: typeof import('./src/components/chat/TroubleshootProgress.vue')['default']
UndoButton: typeof import('./src/components/automation/UndoButton.vue')['default']
-108
View File
@@ -1,108 +0,0 @@
// =============================================================================
// 企微IT智能服务台 — H5 端分诊 API 封装
// =============================================================================
// 说明:封装 H5 端 5 个分诊交互 API 调用
// =============================================================================
import request from './index'
/** 分诊选项 */
export interface TriageOption {
label: string
probability?: number
}
/** 分诊步骤 */
export interface TriageStep {
question: string
options: TriageOption[]
}
/** 发起分诊请求参数 */
export interface TriageStartParams {
conversation_id: string
question: string
}
/** 发起分诊响应 */
export interface TriageStartResult {
triage_id: string
steps: TriageStep[]
total: number
confidence?: number
urgency: string
suggested_route?: string
status?: string
message?: string
}
/** 提交步骤响应 */
export interface TriageStepResult {
next_step: TriageStep | null
collected_context: string[]
}
/** 转人工响应 */
export interface TriageTransferResult {
conversation_id: string
status: string
}
/** 分诊完成响应 */
export interface TriageCompleteResult {
reply: string
confidence: number
}
/**
* 发起分诊
* POST /api/h5/triage/start
*/
export function startTriage(params: TriageStartParams): Promise<TriageStartResult> {
return request.post('/h5/triage/start', params)
}
/**
* 提交步骤选择
* POST /api/h5/triage/step
*/
export function submitTriageStep(
triage_id: string,
step_index: number,
selected_label: string,
): Promise<TriageStepResult> {
return request.post('/h5/triage/step', { triage_id, step_index, selected_label })
}
/**
* 跳过步骤
* POST /api/h5/triage/skip
*/
export function skipTriageStep(
triage_id: string,
step_index: number,
): Promise<{ next_step: TriageStep | null }> {
return request.post('/h5/triage/skip', { triage_id, step_index })
}
/**
* 转人工
* POST /api/h5/triage/transfer
*/
export function transferTriageToHuman(
triage_id: string,
context: string[],
): Promise<TriageTransferResult> {
return request.post('/h5/triage/transfer', { triage_id, context })
}
/**
* 分诊完成
* POST /api/h5/triage/complete
*/
export function completeTriage(
triage_id: string,
context: string[],
): Promise<TriageCompleteResult> {
return request.post('/h5/triage/complete', { triage_id, context })
}
-649
View File
@@ -1,649 +0,0 @@
<!-- =============================================================================
企微IT智能服务台 H5员工端 分诊置顶卡片Tier1 / P1-1 / D4
=============================================================================
说明AI 分诊式回复卡片将复杂问题拆分为分步选择题//含概率推荐
卡片置顶悬浮展示支持专家模式切换
D4 硬约束
- 一次给几步由 AI 判复杂度自适应
- 概率展示为百分比不披露原始 confidence 给员工
- 专家模式默认关分步老手可开一把梭
- 卡片置顶/悬浮
对接后端通过 useTriage composable 管理状态调用后端 API
============================================================================= -->
<template>
<div v-if="visible" class="triage-card" :class="{ 'triage-card--collapsed': collapsed }">
<!-- 标题栏步骤进度 + 折叠按钮 -->
<div class="triage-card__header" @click="toggleCollapse">
<div class="triage-card__step-badge">
<span class="triage-card__step-icon">🤖</span>
<span class="triage-card__step-text">
AI 分诊 {{ currentStepNumber }}/{{ totalSteps }}
</span>
</div>
<div class="triage-card__header-right">
<!-- 专家模式开关 -->
<van-switch
v-model="expertMode"
size="20px"
active-color="#1989fa"
@click.stop
@change="onExpertModeChange"
/>
<span class="triage-card__expert-label">专家模式</span>
<span class="triage-card__collapse-arrow">
{{ collapsed ? '展开' : '收起' }}
</span>
</div>
</div>
<!-- 超时提示 -->
<div v-if="isTimeout" class="triage-card__body">
<div class="triage-card__timeout">
<van-icon name="warning-o" size="20" color="#ee0a24" />
<span class="triage-card__timeout-text">{{ errorMessage || '分诊超时,已自动转人工' }}</span>
</div>
</div>
<!-- 题目区域 -->
<div v-else v-show="!collapsed" class="triage-card__body">
<!-- 问题文本 -->
<div class="triage-card__question">
Q: {{ currentQuestion }}
</div>
<!-- 选项列表 -->
<div class="triage-card__options">
<div
v-for="(option, idx) in currentOptions"
:key="idx"
class="triage-card__option"
:class="{
'triage-card__option--selected': selectedOptionIndex === idx,
'triage-card__option--recommended': isRecommended(option.label) && selectedOptionIndex !== idx,
'triage-card__option--excluded': isExcluded(option.label),
}"
@click="selectOption(idx)"
>
<div class="triage-card__option-radio">
<span v-if="selectedOptionIndex === idx" class="triage-card__option-dot" />
</div>
<span class="triage-card__option-label">{{ option.label }}</span>
<!-- 概率推荐标签仅百分比不披露原始confidence -->
<span
v-if="option.probability !== undefined"
class="triage-card__option-probability"
:class="{ 'triage-card__option-probability--high': getProbabilityPercent(option.probability) >= 70 }"
>
{{ getProbabilityPercent(option.probability) }}%
</span>
<!-- 推荐标记 -->
<span v-if="isRecommended(option.label)" class="triage-card__option-recommend">推荐</span>
<!-- 排除标记 -->
<span v-if="isExcluded(option.label)" class="triage-card__option-excluded">已排除</span>
</div>
</div>
<!-- 已收集上下文P0-3 转人工时附带 -->
<div v-if="collectedContext.length > 0" class="triage-card__context">
<div class="triage-card__context-title"> 已收集上下文</div>
<div class="triage-card__context-tags">
<span
v-for="(ctx, idx) in collectedContext"
:key="idx"
class="triage-card__context-tag"
>
{{ ctx }}
</span>
</div>
</div>
<!-- 专家模式下一步展示 -->
<div v-if="expertMode && nextStepsPreview.length > 0" class="triage-card__expert-steps">
<div class="triage-card__expert-steps-title">📋 后续步骤预览</div>
<div
v-for="(step, idx) in nextStepsPreview"
:key="idx"
class="triage-card__expert-step-item"
>
<span class="triage-card__expert-step-num">{{ idx + 1 + currentStepNumber }}</span>
{{ step.question }}
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div v-if="!isTimeout" v-show="!collapsed" class="triage-card__footer">
<van-button
round
type="default"
size="small"
:loading="loading"
@click="handleSkip"
>
跳过
</van-button>
<van-button
v-if="!isLastStep"
round
type="primary"
size="small"
:disabled="selectedOptionIndex === -1"
:loading="loading"
@click="handleConfirm"
>
下一步
</van-button>
<van-button
v-else
round
type="success"
size="small"
:disabled="selectedOptionIndex === -1"
:loading="loading"
@click="handleComplete"
>
完成
</van-button>
<van-button
round
type="warning"
size="small"
plain
@click="handleTransfer"
>
转人工
</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { showToast } from 'vant'
import { useTriage } from '@/composables/useTriage'
import type { TriageStep, TriageOption } from '@/api/triage'
/** 组件属性 */
const props = defineProps<{
/** 是否可见 */
visible: boolean
/** 会话ID(发起分诊时需要) */
conversationId?: string
/** 问题文本(发起分诊时需要) */
question?: string
}>()
/** 组件事件 */
const emit = defineEmits<{
/** 分诊完成(AI 回复) */
(e: 'complete', reply: string, confidence: number): void
/** 转人工 */
(e: 'transfer-human', context: string[]): void
/** 超时转人工 */
(e: 'timeout'): void
/** 分诊关闭 */
(e: 'close'): void
/** 专家模式切换 */
(e: 'expert-mode-change', enabled: boolean): void
}>()
// ===========================================================================
// useTriage composable
// ===========================================================================
const {
triageId,
currentStepIndex,
triageSteps,
totalSteps,
collectedContext,
confidence,
urgency,
status,
errorMessage,
finalReply,
isTimeout,
excludedLabels,
recommendedLabel,
currentStep,
currentStepNumber,
isLastStep,
isTriaging,
isLoading,
startTriageFlow,
submitStep,
skipStep,
complete,
transferToHuman,
setExcludedOptions,
setRecommendedOption,
reset,
} = useTriage()
// ===========================================================================
// 本地状态
// ===========================================================================
/** 是否折叠卡片 */
const collapsed = ref(false)
/** 专家模式开关(默认关) */
const expertMode = ref(false)
/** 当前选中的选项索引(-1=未选中) */
const selectedOptionIndex = ref<number>(-1)
/** 加载状态 */
const loading = ref<boolean>(false)
// ===========================================================================
// 计算属性
// ===========================================================================
/** 当前问题文本 */
const currentQuestion = computed(() => currentStep.value?.question ?? '')
/** 当前选项列表 */
const currentOptions = computed<TriageOption[]>(() => currentStep.value?.options ?? [])
/** 后续步骤预览(专家模式) */
const nextStepsPreview = computed<TriageStep[]>(() => {
if (!expertMode.value || isLastStep.value) return []
const startIdx = currentStepIndex.value + 1
return triageSteps.value.slice(startIdx)
})
// ===========================================================================
// 监听 visible + question 变化,自动发起分诊
// ===========================================================================
watch(
() => [props.visible, props.question, props.conversationId],
async ([vis, q, cid]) => {
if (vis && q && cid && !triageId.value) {
// 自动发起分诊
loading.value = true
const ok = await startTriageFlow(cid as string, q as string)
loading.value = false
if (!ok && isTimeout.value) {
emit('timeout')
}
}
},
{ immediate: true },
)
// 监听步骤变化,重置选中项
watch(currentStepIndex, () => {
selectedOptionIndex.value = -1
})
// ===========================================================================
// 方法
// ===========================================================================
/** 概率转百分比 */
function getProbabilityPercent(prob: number): number {
return Math.round(prob * 100)
}
/** 是否被坐席排除 */
function isExcluded(label: string): boolean {
return excludedLabels.value.includes(label)
}
/** 是否被坐席推荐 */
function isRecommended(label: string): boolean {
return recommendedLabel.value === label && recommendedLabel.value !== ''
}
/** 折叠/展开卡片 */
function toggleCollapse() {
collapsed.value = !collapsed.value
}
/** 选择选项 */
function selectOption(idx: number) {
const option = currentOptions.value[idx]
if (!option || isExcluded(option.label)) return
// 单选模式
selectedOptionIndex.value = selectedOptionIndex.value === idx ? -1 : idx
}
/** 确认当前步骤(下一步) */
async function handleConfirm() {
if (selectedOptionIndex.value === -1) return
const selected = currentOptions.value[selectedOptionIndex.value]
if (!selected) return
loading.value = true
const hasNext = await submitStep(selected.label)
loading.value = false
if (!hasNext && !isLastStep.value) {
// 后端没有返回下一步,但我们还有预生成的步骤
// 直接移动到下一步
}
}
/** 跳过当前步骤 */
async function handleSkip() {
loading.value = true
const hasNext = await skipStep()
loading.value = false
if (!hasNext && isLastStep.value) {
showToast('已是最后一步')
}
}
/** 完成分诊 */
async function handleComplete() {
if (selectedOptionIndex.value === -1) return
const selected = currentOptions.value[selectedOptionIndex.value]
if (!selected) return
// 先提交最后一步选择
loading.value = true
await submitStep(selected.label)
// 然后完成分诊
const reply = await complete()
loading.value = false
if (reply) {
emit('complete', reply, confidence.value ?? 0)
}
}
/** 转人工 */
async function handleTransfer() {
loading.value = true
const ok = await transferToHuman()
loading.value = false
if (ok) {
emit('transfer-human', [...collectedContext.value])
}
}
/** 专家模式切换 */
function onExpertModeChange(enabled: boolean) {
emit('expert-mode-change', enabled)
}
/** 暴露方法供父组件调用 */
defineExpose({
/** 设置坐席排除项(WS 接收后调用) */
setExcludedOptions(labels: string[]) {
setExcludedOptions(labels)
},
/** 设置坐席推荐项(WS 接收后调用) */
setRecommendedOption(label: string) {
setRecommendedOption(label)
},
/** 重置分诊状态 */
reset() {
reset()
selectedOptionIndex.value = -1
collapsed.value = false
},
})
</script>
<style scoped>
/* =========================================================================
分诊卡片 — 置顶悬浮、Vant 风格
========================================================================= */
.triage-card {
position: sticky;
top: 0;
z-index: 100;
margin: 8px 12px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
transition: all 0.3s ease;
}
.triage-card--collapsed {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
/* 标题栏 */
.triage-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4fd 100%);
cursor: pointer;
user-select: none;
}
.triage-card__step-badge {
display: flex;
align-items: center;
gap: 6px;
}
.triage-card__step-icon {
font-size: 16px;
}
.triage-card__step-text {
font-size: 13px;
font-weight: 600;
color: #1989fa;
}
.triage-card__header-right {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #969799;
}
.triage-card__expert-label {
margin-right: 8px;
}
.triage-card__collapse-arrow {
font-size: 12px;
color: #1989fa;
}
/* 内容区 */
.triage-card__body {
padding: 12px 14px;
}
/* 超时提示 */
.triage-card__timeout {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
}
.triage-card__timeout-text {
font-size: 14px;
color: #ee0a24;
}
.triage-card__question {
font-size: 15px;
font-weight: 500;
color: #323233;
margin-bottom: 12px;
line-height: 1.5;
}
/* 选项列表 */
.triage-card__options {
display: flex;
flex-direction: column;
gap: 8px;
}
.triage-card__option {
display: flex;
align-items: center;
padding: 10px 12px;
border: 1px solid #ebedf0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
gap: 8px;
}
.triage-card__option:hover {
background: #f7f8fa;
}
.triage-card__option--selected {
border-color: #1989fa;
background: #ecf5ff;
}
.triage-card__option--recommended {
border-color: #ff976a;
background: #fff7f0;
}
.triage-card__option--excluded {
border-color: #ebedf0;
background: #f5f5f5;
opacity: 0.5;
cursor: not-allowed;
text-decoration: line-through;
}
.triage-card__option-radio {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #c8c9cc;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.triage-card__option--selected .triage-card__option-radio {
border-color: #1989fa;
}
.triage-card__option-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #1989fa;
}
.triage-card__option-label {
flex: 1;
font-size: 14px;
color: #323233;
}
.triage-card__option-probability {
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
background: #f0f0f0;
color: #969799;
}
.triage-card__option-probability--high {
background: #e8f8e8;
color: #07c160;
}
.triage-card__option-recommend {
font-size: 12px;
color: #ff976a;
}
.triage-card__option-excluded {
font-size: 12px;
color: #c8c9cc;
}
/* 已收集上下文 */
.triage-card__context {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed #ebedf0;
}
.triage-card__context-title {
font-size: 12px;
color: #969799;
margin-bottom: 6px;
}
.triage-card__context-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.triage-card__context-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
background: #f0f7ff;
color: #1989fa;
}
/* 专家模式预览 */
.triage-card__expert-steps {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed #ebedf0;
}
.triage-card__expert-steps-title {
font-size: 12px;
color: #ff976a;
margin-bottom: 6px;
}
.triage-card__expert-step-item {
font-size: 12px;
color: #969799;
line-height: 1.6;
padding: 2px 0;
}
.triage-card__expert-step-num {
display: inline-block;
width: 18px;
height: 18px;
line-height: 18px;
border-radius: 50%;
background: #f0f0f0;
text-align: center;
font-size: 10px;
margin-right: 4px;
}
/* 底部操作栏 */
.triage-card__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #f5f5f5;
}
</style>
@@ -1,434 +0,0 @@
<!--
=============================================================================
// 推荐卡片组件 - 支持分层展示
=============================================================================
// 功能:
// - 渲染单个推荐卡片
// - 支持多种操作类型: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,339 +0,0 @@
<!-- =============================================================================
// 企微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>
-318
View File
@@ -1,318 +0,0 @@
// =============================================================================
// 企微IT智能服务台 — H5 端分诊状态管理 Composable
// =============================================================================
// 说明:管理分诊交互的完整状态,封装所有分诊 API 调用。
// 状态:当前步骤、已收集上下文、分诊ID、分诊步骤数据等
// 方法:startTriage, submitStep, skipStep, completeTriage, transferToHuman
// =============================================================================
import { ref, computed, readonly } from 'vue'
import {
startTriage,
submitTriageStep,
skipTriageStep,
transferTriageToHuman,
completeTriage,
type TriageStep,
type TriageStartResult,
} from '@/api/triage'
/** 分诊状态 */
export type TriageStatus = 'idle' | 'loading' | 'triaging' | 'completed' | 'transferred' | 'timeout' | 'error'
/** useTriage composable — 分诊状态管理 */
export function useTriage() {
// ==========================================================================
// 响应式状态
// ==========================================================================
/** 分诊会话ID */
const triageId = ref<string>('')
/** 当前步骤序号(0-based */
const currentStepIndex = ref<number>(0)
/** 所有分诊步骤数据 */
const triageSteps = ref<TriageStep[]>([])
/** 总步骤数 */
const totalSteps = ref<number>(0)
/** 已收集的上下文 */
const collectedContext = ref<string[]>([])
/** AI 置信度 */
const confidence = ref<number | null>(null)
/** 紧急度 */
const urgency = ref<string>('medium')
/** AI 建议路由 */
const suggestedRoute = ref<string | null>(null)
/** 分诊状态 */
const status = ref<TriageStatus>('idle')
/** 错误消息 */
const errorMessage = ref<string>('')
/** 最终 AI 回复 */
const finalReply = ref<string>('')
/** 是否超时自动转人工 */
const isTimeout = ref<boolean>(false)
/** 被坐席排除的选项标签 */
const excludedLabels = ref<string[]>([])
/** 坐席推荐的选项标签 */
const recommendedLabel = ref<string>('')
// ==========================================================================
// 计算属性
// ==========================================================================
/** 当前步骤数据 */
const currentStep = computed<TriageStep | null>(() => {
if (currentStepIndex.value < triageSteps.value.length) {
return triageSteps.value[currentStepIndex.value]
}
return null
})
/** 当前步骤序号(1-based,展示用) */
const currentStepNumber = computed(() => currentStepIndex.value + 1)
/** 是否为最后一步 */
const isLastStep = computed(() => currentStepNumber.value >= totalSteps.value)
/** 是否有下一步 */
const hasNextStep = computed(() => currentStepIndex.value < triageSteps.value.length - 1)
/** 是否正在进行分诊 */
const isTriaging = computed(() => status.value === 'triaging')
/** 是否加载中 */
const isLoading = computed(() => status.value === 'loading')
// ==========================================================================
// 方法
// ==========================================================================
/**
* 发起分诊
* @param conversationId 会话ID
* @param question 员工问题文本
* @returns 是否成功
*/
async function startTriageFlow(conversationId: string, question: string): Promise<boolean> {
status.value = 'loading'
errorMessage.value = ''
isTimeout.value = false
try {
const result: TriageStartResult = await startTriage({
conversation_id: conversationId,
question,
})
// 超时自动转人工
if (result.status === 'timeout') {
status.value = 'timeout'
isTimeout.value = true
errorMessage.value = result.message || '分诊超时,已自动转人工'
return false
}
triageId.value = result.triage_id
triageSteps.value = result.steps || []
totalSteps.value = result.total || result.steps.length
confidence.value = result.confidence ?? null
urgency.value = result.urgency || 'medium'
suggestedRoute.value = result.suggested_route || null
currentStepIndex.value = 0
collectedContext.value = []
status.value = 'triaging'
return true
} catch (e: any) {
status.value = 'error'
errorMessage.value = e?.message || '发起分诊失败'
return false
}
}
/**
* 提交步骤选择
* @param selectedLabel 选择的选项标签
* @returns 是否还有下一步
*/
async function submitStep(selectedLabel: string): Promise<boolean> {
if (!triageId.value) return false
try {
const result = await submitTriageStep(
triageId.value,
currentStepIndex.value,
selectedLabel,
)
// 记录已收集上下文
collectedContext.value = result.collected_context || collectedContext.value
// 移动到下一步
if (result.next_step) {
// 如果后端返回了下一步数据,更新步骤列表
if (currentStepIndex.value + 1 < triageSteps.value.length) {
triageSteps.value[currentStepIndex.value + 1] = result.next_step
} else {
triageSteps.value.push(result.next_step)
}
currentStepIndex.value++
return true
}
// 没有下一步,分诊完成
return false
} catch (e: any) {
errorMessage.value = e?.message || '提交步骤失败'
return false
}
}
/**
* 跳过当前步骤
* @returns 是否还有下一步
*/
async function skipStep(): Promise<boolean> {
if (!triageId.value) return false
try {
const result = await skipTriageStep(triageId.value, currentStepIndex.value)
if (result.next_step) {
if (currentStepIndex.value + 1 < triageSteps.value.length) {
triageSteps.value[currentStepIndex.value + 1] = result.next_step
} else {
triageSteps.value.push(result.next_step)
}
currentStepIndex.value++
return true
}
return false
} catch (e: any) {
errorMessage.value = e?.message || '跳过步骤失败'
return false
}
}
/**
* 分诊完成,获取 AI 最终回复
* @returns AI 回复文本
*/
async function complete(): Promise<string> {
if (!triageId.value) return ''
try {
const result = await completeTriage(triageId.value, collectedContext.value)
finalReply.value = result.reply
confidence.value = result.confidence
status.value = 'completed'
return result.reply
} catch (e: any) {
errorMessage.value = e?.message || '分诊完成失败'
status.value = 'error'
return ''
}
}
/**
* 转人工
* @returns 是否成功
*/
async function transferToHuman(): Promise<boolean> {
if (!triageId.value) return false
try {
await transferTriageToHuman(triageId.value, collectedContext.value)
status.value = 'transferred'
return true
} catch (e: any) {
errorMessage.value = e?.message || '转人工失败'
return false
}
}
/**
* 设置坐席排除的选项(通过 WS 接收)
* @param labels 要排除的选项标签列表
*/
function setExcludedOptions(labels: string[]): void {
excludedLabels.value = labels
}
/**
* 设置坐席推荐的选项(通过 WS 接收)
* @param label 推荐的选项标签
*/
function setRecommendedOption(label: string): void {
recommendedLabel.value = label
}
/**
* 重置分诊状态
*/
function reset(): void {
triageId.value = ''
currentStepIndex.value = 0
triageSteps.value = []
totalSteps.value = 0
collectedContext.value = []
confidence.value = null
urgency.value = 'medium'
suggestedRoute.value = null
status.value = 'idle'
errorMessage.value = ''
finalReply.value = ''
isTimeout.value = false
excludedLabels.value = []
recommendedLabel.value = ''
}
return {
// 状态(只读)
triageId: readonly(triageId),
currentStepIndex: readonly(currentStepIndex),
triageSteps: readonly(triageSteps),
totalSteps: readonly(totalSteps),
collectedContext: readonly(collectedContext),
confidence: readonly(confidence),
urgency: readonly(urgency),
suggestedRoute: readonly(suggestedRoute),
status: readonly(status),
errorMessage: readonly(errorMessage),
finalReply: readonly(finalReply),
isTimeout: readonly(isTimeout),
excludedLabels: readonly(excludedLabels),
recommendedLabel: readonly(recommendedLabel),
// 计算属性
currentStep,
currentStepNumber,
isLastStep,
hasNextStep,
isTriaging,
isLoading,
// 方法
startTriageFlow,
submitStep,
skipStep,
complete,
transferToHuman,
setExcludedOptions,
setRecommendedOption,
reset,
}
}