批次4死代码大扫除: 删triage三件套(H5零挂载)+ /approval/keywords端点 + scheduler.py孤儿模块 + 3个.bak文件 + get_reply_stream(~96行)
This commit is contained in:
@@ -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-11,7天)、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)
|
||||
|
||||
@@ -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 批次4:GET /approval/keywords 端点已删除
|
||||
# (前端 getApprovalKeywords 调用已随 v3.0 ApprovalCardModal 重构移除,
|
||||
# 仅剩 .bak 备份文件引用;关键词匹配现由 ApprovalMatcher 后端统一处理)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 批次4:get_reply_stream 已删除(~96 行)
|
||||
# v2.0 起 AI 回复改为 blocking + JSON 结构化(get_structured_reply),
|
||||
# 流式 SSE 路径零调用,属死代码。
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 结构化调用:blocking 模式,返回解析后的 JSON {text, action, options}
|
||||
|
||||
@@ -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)
|
||||
# 注意:本模块当前无人 import(main.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("定时任务调度器已停止")
|
||||
Vendored
-3
@@ -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']
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user