feat(h5): confidence gate + triage + image uploader + ws composable
新增 ConfidenceGateBanner/ImageUploader/TriageCard 组件与 useConfidenceGate; useH5WebSocket 增强; 对话/API 适配。
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
@@ -16,7 +16,7 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# 安装依赖
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
|
||||
# 复制源码(后续通过 volume mount 更新)
|
||||
COPY . .
|
||||
|
||||
Vendored
+9
@@ -14,7 +14,9 @@ declare module 'vue' {
|
||||
CallAgentModal: typeof import('./src/components/chat/CallAgentModal.vue')['default']
|
||||
ChatPanel: typeof import('./src/components/chat/ChatPanel.vue')['default']
|
||||
ComingSoon: typeof import('./src/components/assistant/ComingSoon.vue')['default']
|
||||
ConfidenceGateBanner: typeof import('./src/components/ConfidenceGateBanner.vue')['default']
|
||||
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']
|
||||
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
|
||||
@@ -28,16 +30,23 @@ declare module 'vue' {
|
||||
ScreenshotEditor: typeof import('./src/components/chat/ScreenshotEditor.vue')['default']
|
||||
ShakeButton: typeof import('./src/components/chat/ShakeButton.vue')['default']
|
||||
SoftwareDownloads: typeof import('./src/components/assistant/SoftwareDownloads.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']
|
||||
VanButton: typeof import('vant/es')['Button']
|
||||
VanCell: typeof import('vant/es')['Cell']
|
||||
VanCellGroup: typeof import('vant/es')['CellGroup']
|
||||
VanConfigProvider: typeof import('vant/es')['ConfigProvider']
|
||||
VanDialog: typeof import('vant/es')['Dialog']
|
||||
VanEmpty: typeof import('vant/es')['Empty']
|
||||
VanField: typeof import('vant/es')['Field']
|
||||
VanIcon: typeof import('vant/es')['Icon']
|
||||
VanLoading: typeof import('vant/es')['Loading']
|
||||
VanNavBar: typeof import('vant/es')['NavBar']
|
||||
VanPopup: typeof import('vant/es')['Popup']
|
||||
VanRate: typeof import('vant/es')['Rate']
|
||||
VanStep: typeof import('vant/es')['Step']
|
||||
VanSteps: typeof import('vant/es')['Steps']
|
||||
VanTag: typeof import('vant/es')['Tag']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- 移动端视口设置(适配企微 WebView) -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<!-- CSP 安全策略 -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://127.0.0.1;" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline' https://res.wx.qq.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; connect-src 'self' https://qyapi.weixin.qq.com wss://* ws://localhost ws://localhost:8000 ws://127.0.0.1 ws://127.0.0.1:8000;" />
|
||||
<!-- 页面标题 -->
|
||||
<title>智能IT支持服务台</title>
|
||||
<!-- 首屏骨架屏样式 v0.5.2 强化版 -->
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -173,8 +173,13 @@ export interface SoftwareDownload {
|
||||
export interface SendMessageResponse {
|
||||
/** 用户发送的消息 */
|
||||
user_message: Message
|
||||
/** AI 自动回复消息 */
|
||||
ai_reply: Message
|
||||
/**
|
||||
* AI 自动回复消息
|
||||
* 注意:改造方案 A 后,HTTP 接口不再同步返回 AI 回复(恒为 null),
|
||||
* AI 回复由后台任务经 WebSocket 流式推回(ai_reply_chunk / ai_reply)。
|
||||
* 此处可能为 null,调用方(store)需改为依赖 WS 事件。
|
||||
*/
|
||||
ai_reply: Message | null
|
||||
/** 是否为引导类回复(打招呼/呼叫人工),不计入实质回复 */
|
||||
is_guidance: boolean
|
||||
/** 当前 AI 实质性回复计数 */
|
||||
|
||||
@@ -77,7 +77,7 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
|
||||
// 业务成功:Scheme A — 直接返回 inner data(三端统一契约)
|
||||
return res.data
|
||||
return res.data as any
|
||||
},
|
||||
async (error) => {
|
||||
// 网络错误或服务器错误:统一 reject {code, message}(CTRT-03)
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TypeScript 类型定义 — 与后端 Schema 保持一致
|
||||
@@ -96,7 +95,7 @@ export interface TroubleshootingTemplateQuery {
|
||||
export async function getTroubleshootingTemplates(
|
||||
params?: TroubleshootingTemplateQuery,
|
||||
): Promise<TroubleshootingTemplateListData> {
|
||||
const response: AxiosResponse = await apiClient.get('/troubleshooting-templates', {
|
||||
const response = await apiClient.get('/troubleshooting-templates', {
|
||||
params: {
|
||||
category: params?.category || undefined,
|
||||
page: params?.page || 1,
|
||||
@@ -115,6 +114,6 @@ export async function getTroubleshootingTemplates(
|
||||
export async function getTroubleshootingTemplate(
|
||||
id: string,
|
||||
): Promise<TroubleshootingTemplate> {
|
||||
const response: AxiosResponse = await apiClient.get(`/troubleshooting-templates/${id}`)
|
||||
const response = await apiClient.get(`/troubleshooting-templates/${id}`)
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — H5员工端 置信门控横幅(Tier1 / P0-3 / D3)
|
||||
=============================================================================
|
||||
说明:当 AI 回复的 confidence 低于全局阈值 0.7 时,
|
||||
在前端渲染"转人工"入口并附已收集的上下文快照。
|
||||
|
||||
D3 硬约束:
|
||||
- confidence < 0.7 → 渲染"转人工"卡片 + 已收集上下文
|
||||
- 阈值可配置(通过 settings.confidence_gate_threshold)
|
||||
- 转人工动作携带上下文快照
|
||||
- 概率展示为百分比(不显示原始 confidence)
|
||||
============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="confidence-gate-banner" :class="severityClass">
|
||||
<!-- 主提示 -->
|
||||
<div class="confidence-gate-banner__main">
|
||||
<span class="confidence-gate-banner__icon">{{ severityIcon }}</span>
|
||||
<span class="confidence-gate-banner__text">{{ severityText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 推荐概率 -->
|
||||
<div class="confidence-gate-banner__confidence">
|
||||
<span class="confidence-gate-banner__confidence-label">AI 把握度:</span>
|
||||
<span class="confidence-gate-banner__confidence-value">
|
||||
{{ displayProbability }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 已收集上下文快照 -->
|
||||
<div v-if="contextSnapshot.length > 0" class="confidence-gate-banner__context">
|
||||
<div class="confidence-gate-banner__context-label">📋 已收集的信息:</div>
|
||||
<div class="confidence-gate-banner__context-list">
|
||||
<span
|
||||
v-for="(item, idx) in contextSnapshot"
|
||||
:key="idx"
|
||||
class="confidence-gate-banner__context-item"
|
||||
>
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="confidence-gate-banner__actions">
|
||||
<van-button
|
||||
round
|
||||
type="danger"
|
||||
size="small"
|
||||
:loading="transferring"
|
||||
@click="handleTransferToHuman"
|
||||
>
|
||||
转人工处理
|
||||
</van-button>
|
||||
<van-button
|
||||
round
|
||||
type="default"
|
||||
size="small"
|
||||
@click="handleContinueAI"
|
||||
>
|
||||
继续询问 AI
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 可选:拒绝理由选择 -->
|
||||
<div v-if="showRejectOptions" class="confidence-gate-banner__reject">
|
||||
<div class="confidence-gate-banner__reject-label">AI 回复不准确?帮助我们改进:</div>
|
||||
<div class="confidence-gate-banner__reject-tags">
|
||||
<van-tag
|
||||
v-for="reason in rejectReasons"
|
||||
:key="reason"
|
||||
:type="selectedRejectReason === reason ? 'danger' : 'default'"
|
||||
size="medium"
|
||||
plain
|
||||
@click="selectRejectReason(reason)"
|
||||
>
|
||||
{{ reason }}
|
||||
</van-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
/** 组件属性 */
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 是否可见(confidence < threshold 时展示) */
|
||||
visible: boolean
|
||||
/** AI 原始置信度(0.0-1.0,内部使用) */
|
||||
confidence?: number
|
||||
/** 已收集的上下文快照 */
|
||||
contextSnapshot?: string[]
|
||||
/** 门控触发原因 */
|
||||
reason?: string
|
||||
}>(), {
|
||||
visible: false,
|
||||
confidence: 0,
|
||||
contextSnapshot: () => [],
|
||||
reason: '',
|
||||
})
|
||||
|
||||
/** 组件事件 */
|
||||
const emit = defineEmits<{
|
||||
/** 点击转人工 */
|
||||
(e: 'transfer-human', context: string[]): void
|
||||
/** 继续询问 AI */
|
||||
(e: 'continue-ai'): void
|
||||
/** 提交拒绝理由 */
|
||||
(e: 'feedback', reason: string): void
|
||||
}>()
|
||||
|
||||
// ===========================================================================
|
||||
// 常量
|
||||
// ===========================================================================
|
||||
|
||||
/** 预设拒绝理由选项 */
|
||||
const rejectReasons = [
|
||||
'答非所问',
|
||||
'信息不完整',
|
||||
'回答不准确',
|
||||
'过于笼统',
|
||||
'其他',
|
||||
]
|
||||
|
||||
// ===========================================================================
|
||||
// 状态
|
||||
// ===========================================================================
|
||||
|
||||
/** 是否正在转人工 */
|
||||
const transferring = ref(false)
|
||||
|
||||
/** 是否展示拒绝理由 */
|
||||
const showRejectOptions = ref(false)
|
||||
|
||||
/** 选中的拒绝理由 */
|
||||
const selectedRejectReason = ref('')
|
||||
|
||||
// ===========================================================================
|
||||
// 计算属性
|
||||
// ===========================================================================
|
||||
|
||||
/** 展示用概率(百分比,整数,不披露原始confidence) */
|
||||
const displayProbability = computed(() => {
|
||||
const conf = props.confidence ?? 0
|
||||
return Math.round(conf * 100)
|
||||
})
|
||||
|
||||
/** 严重程度级别 */
|
||||
const severityClass = computed(() => {
|
||||
const conf = props.confidence ?? 0
|
||||
if (conf < 0.3) return 'confidence-gate-banner--critical'
|
||||
if (conf < 0.5) return 'confidence-gate-banner--warning'
|
||||
return 'confidence-gate-banner--caution'
|
||||
})
|
||||
|
||||
/** 严重程度图标 */
|
||||
const severityIcon = computed(() => {
|
||||
const conf = props.confidence ?? 0
|
||||
if (conf < 0.3) return '🚨'
|
||||
if (conf < 0.5) return '⚠️'
|
||||
return '💡'
|
||||
})
|
||||
|
||||
/** 严重程度文本 */
|
||||
const severityText = computed(() => {
|
||||
const conf = props.confidence ?? 0
|
||||
if (conf < 0.3) return 'AI 非常不确定此回复,建议转人工处理'
|
||||
if (conf < 0.5) return 'AI 对此回复把握较低,可能需要人工协助'
|
||||
return 'AI 对此回复不太确定,你可选择转人工确认'
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// 方法
|
||||
// ===========================================================================
|
||||
|
||||
/** 转人工处理 */
|
||||
async function handleTransferToHuman() {
|
||||
transferring.value = true
|
||||
try {
|
||||
emit('transfer-human', props.contextSnapshot ?? [])
|
||||
// 展示反馈选项
|
||||
showRejectOptions.value = true
|
||||
} finally {
|
||||
transferring.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 继续询问 AI */
|
||||
function handleContinueAI() {
|
||||
emit('continue-ai')
|
||||
showRejectOptions.value = false
|
||||
}
|
||||
|
||||
/** 选择拒绝理由 */
|
||||
function selectRejectReason(reason: string) {
|
||||
selectedRejectReason.value = reason
|
||||
emit('feedback', reason)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* =========================================================================
|
||||
置信门控横幅 — 置顶悬浮,醒目样式
|
||||
========================================================================= */
|
||||
.confidence-gate-banner {
|
||||
margin: 8px 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 严重程度配色 */
|
||||
.confidence-gate-banner--critical {
|
||||
background: #fff0f0;
|
||||
border-color: #ee0a24;
|
||||
}
|
||||
|
||||
.confidence-gate-banner--warning {
|
||||
background: #fff7f0;
|
||||
border-color: #ff976a;
|
||||
}
|
||||
|
||||
.confidence-gate-banner--caution {
|
||||
background: #f0f7ff;
|
||||
border-color: #1989fa;
|
||||
}
|
||||
|
||||
/* 主提示 */
|
||||
.confidence-gate-banner__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.confidence-gate-banner__icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.confidence-gate-banner__text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #323233;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* AI 把握度 */
|
||||
.confidence-gate-banner__confidence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.confidence-gate-banner__confidence-label {
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.confidence-gate-banner__confidence-value {
|
||||
font-weight: 700;
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
/* 上下文快照 */
|
||||
.confidence-gate-banner__context {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.confidence-gate-banner__context-label {
|
||||
font-size: 11px;
|
||||
color: #969799;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.confidence-gate-banner__context-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.confidence-gate-banner__context-item {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
background: #e8e8e8;
|
||||
border-radius: 3px;
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.confidence-gate-banner__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 拒绝理由 */
|
||||
.confidence-gate-banner__reject {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.confidence-gate-banner__reject-label {
|
||||
font-size: 11px;
|
||||
color: #969799;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.confidence-gate-banner__reject-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,413 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — H5员工端 截图上传组件(Tier1 / P1-3 / D5)
|
||||
=============================================================================
|
||||
说明:员工截图上传和中途补图组件,调用 /api/vision/analyze 获取
|
||||
Qwen-VL 视觉模型的结构化描述,并将结果注入对话上下文。
|
||||
|
||||
D5 硬约束:
|
||||
- 截图理解经 Dify 后端调用本地 Qwen-VL
|
||||
- 支持中途补图(会话中追加图片)
|
||||
- 展示"AI 已理解截图内容"反馈
|
||||
============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="image-uploader">
|
||||
<!-- 上传触发按钮 -->
|
||||
<div v-if="!selectedFile" class="image-uploader__trigger" @click="openFilePicker">
|
||||
<span class="image-uploader__trigger-icon">📷</span>
|
||||
<span class="image-uploader__trigger-text">发截图</span>
|
||||
</div>
|
||||
|
||||
<!-- 已选图片预览 -->
|
||||
<div v-else class="image-uploader__preview">
|
||||
<div class="image-uploader__preview-wrapper">
|
||||
<img
|
||||
:src="previewUrl"
|
||||
alt="截图预览"
|
||||
class="image-uploader__preview-img"
|
||||
/>
|
||||
<div class="image-uploader__preview-remove" @click="removeFile">
|
||||
✕
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 追加描述文字 -->
|
||||
<div class="image-uploader__desc-section">
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="image-uploader__desc-input"
|
||||
placeholder="补充描述(可选):描述你遇到的问题..."
|
||||
rows="2"
|
||||
maxlength="200"
|
||||
/>
|
||||
<span class="image-uploader__desc-count">{{ description.length }}/200</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="image-uploader__actions">
|
||||
<van-button
|
||||
round
|
||||
type="default"
|
||||
size="small"
|
||||
@click="removeFile"
|
||||
>
|
||||
取消
|
||||
</van-button>
|
||||
<van-button
|
||||
round
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="analyzing"
|
||||
@click="sendImage"
|
||||
>
|
||||
{{ analyzing ? 'AI 分析中...' : '发送' }}
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视觉理解结果展示 -->
|
||||
<div v-if="visionResult" class="image-uploader__result">
|
||||
<div class="image-uploader__result-header">
|
||||
<span class="image-uploader__result-icon">🔍</span>
|
||||
<span class="image-uploader__result-title">AI 已理解截图内容:</span>
|
||||
<span class="image-uploader__result-confidence">
|
||||
把握度 {{ visionConfidence }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="image-uploader__result-body">
|
||||
{{ visionResult }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="errorMessage" class="image-uploader__error">
|
||||
⚠️ {{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的文件选择器 -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="image-uploader__file-input"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
/** 支持的文件类型 */
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
|
||||
|
||||
/** 文件大小上限(10MB) */
|
||||
const MAX_SIZE = 10 * 1024 * 1024
|
||||
|
||||
/** 组件属性 */
|
||||
const props = defineProps<{
|
||||
/** 会话 ID */
|
||||
conversationId: string
|
||||
}>()
|
||||
|
||||
/** 组件事件 */
|
||||
const emit = defineEmits<{
|
||||
/** 图片发送成功(含视觉描述) */
|
||||
(e: 'sent', data: { imageBase64: string; description: string; visionResult: any }): void
|
||||
/** 发送出错 */
|
||||
(e: 'error', message: string): void
|
||||
}>()
|
||||
|
||||
// ===========================================================================
|
||||
// 状态
|
||||
// ===========================================================================
|
||||
|
||||
/** 文件选择器引用 */
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
|
||||
/** 选中的文件 */
|
||||
const selectedFile = ref<File | null>(null)
|
||||
|
||||
/** 预览 URL */
|
||||
const previewUrl = ref('')
|
||||
|
||||
/** 补充描述文字 */
|
||||
const description = ref('')
|
||||
|
||||
/** 是否正在分析 */
|
||||
const analyzing = ref(false)
|
||||
|
||||
/** 视觉理解结果文本 */
|
||||
const visionResult = ref('')
|
||||
|
||||
/** 视觉理解置信度 */
|
||||
const visionConfidence = ref(0)
|
||||
|
||||
/** 错误消息 */
|
||||
const errorMessage = ref('')
|
||||
|
||||
// ===========================================================================
|
||||
// 方法
|
||||
// ===========================================================================
|
||||
|
||||
/** 打开文件选择器 */
|
||||
function openFilePicker() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
/** 文件选择回调 */
|
||||
function onFileSelected(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 校验类型
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
errorMessage.value = '不支持的图片格式,请选择 PNG/JPG/GIF/WebP'
|
||||
return
|
||||
}
|
||||
|
||||
// 校验大小
|
||||
if (file.size > MAX_SIZE) {
|
||||
errorMessage.value = `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB),最大 10MB`
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
selectedFile.value = file
|
||||
|
||||
// 生成预览 URL
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
previewUrl.value = e.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
/** 移除选中文件 */
|
||||
function removeFile() {
|
||||
selectedFile.value = null
|
||||
previewUrl.value = ''
|
||||
description.value = ''
|
||||
visionResult.value = ''
|
||||
visionConfidence.value = 0
|
||||
errorMessage.value = ''
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送图片并获取视觉理解结果 */
|
||||
async function sendImage() {
|
||||
if (!selectedFile.value) return
|
||||
|
||||
analyzing.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
// 构建 FormData
|
||||
const formData = new FormData()
|
||||
formData.append('image', selectedFile.value)
|
||||
formData.append('conversation_id', props.conversationId)
|
||||
if (description.value) {
|
||||
formData.append('description_hint', description.value)
|
||||
}
|
||||
|
||||
// 调用视觉理解 API
|
||||
const response = await fetch('/api/vision/analyze', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
// 不要设置 Content-Type,让浏览器自动处理 multipart/form-data
|
||||
},
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.code === 0 && result.data) {
|
||||
visionResult.value = result.data.description || ''
|
||||
visionConfidence.value = Math.round((result.data.confidence || 0) * 100)
|
||||
|
||||
// 触发发送事件
|
||||
emit('sent', {
|
||||
imageBase64: previewUrl.value,
|
||||
description: result.data.description || description.value,
|
||||
visionResult: result.data,
|
||||
})
|
||||
} else {
|
||||
errorMessage.value = result.message || '视觉分析失败,请重试'
|
||||
emit('error', errorMessage.value)
|
||||
}
|
||||
} catch (err: any) {
|
||||
errorMessage.value = '网络异常,视觉分析请求失败'
|
||||
emit('error', errorMessage.value)
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* =========================================================================
|
||||
截图上传组件 — H5 移动端适配
|
||||
========================================================================= */
|
||||
.image-uploader {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* 触发按钮 */
|
||||
.image-uploader__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
background: #f7f8fa;
|
||||
border: 1px dashed #c8c9cc;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.image-uploader__trigger:hover {
|
||||
background: #ecf5ff;
|
||||
border-color: #1989fa;
|
||||
}
|
||||
|
||||
.image-uploader__trigger-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.image-uploader__trigger-text {
|
||||
font-size: 13px;
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
/* 预览区域 */
|
||||
.image-uploader__preview {
|
||||
background: #f7f8fa;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.image-uploader__preview-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.image-uploader__preview-img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
border: 1px solid #ebedf0;
|
||||
}
|
||||
|
||||
.image-uploader__preview-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 描述输入 */
|
||||
.image-uploader__desc-section {
|
||||
position: relative;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.image-uploader__desc-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ebedf0;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.image-uploader__desc-input:focus {
|
||||
border-color: #1989fa;
|
||||
}
|
||||
|
||||
.image-uploader__desc-count {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
color: #c8c9cc;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.image-uploader__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 分析结果 */
|
||||
.image-uploader__result {
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #f0f7ff;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #1989fa;
|
||||
}
|
||||
|
||||
.image-uploader__result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.image-uploader__result-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.image-uploader__result-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1989fa;
|
||||
}
|
||||
|
||||
.image-uploader__result-confidence {
|
||||
font-size: 11px;
|
||||
color: #07c160;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.image-uploader__result-body {
|
||||
font-size: 13px;
|
||||
color: #323233;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 错误提示 */
|
||||
.image-uploader__error {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #fff0f0;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
/* 隐藏的文件选择器 */
|
||||
.image-uploader__file-input {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,529 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — H5员工端 分诊置顶卡片(Tier1 / P1-1 / D4)
|
||||
=============================================================================
|
||||
说明:AI 分诊式回复卡片,将复杂问题拆分为分步选择题(是/否/含概率推荐),
|
||||
卡片置顶悬浮展示,支持专家模式切换。
|
||||
|
||||
D4 硬约束:
|
||||
- 一次给几步由 AI 判复杂度自适应
|
||||
- 概率展示为百分比(不披露原始 confidence 给员工)
|
||||
- 专家模式默认关(分步),老手可开一把梭
|
||||
- 卡片置顶/悬浮
|
||||
============================================================================= -->
|
||||
|
||||
<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 分诊(第 {{ currentStep }}/{{ 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-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': option.selected,
|
||||
'triage-card__option--recommended': option.recommended && !option.selected,
|
||||
'triage-card__option--excluded': option.excluded,
|
||||
}"
|
||||
@click="selectOption(idx)"
|
||||
>
|
||||
<div class="triage-card__option-radio">
|
||||
<span v-if="option.selected" 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': option.probability >= 70 }"
|
||||
>
|
||||
{{ option.probability }}%
|
||||
</span>
|
||||
<!-- 推荐标记 -->
|
||||
<span v-if="option.recommended" class="triage-card__option-recommend">⭐推荐</span>
|
||||
<!-- 排除标记 -->
|
||||
<span v-if="option.excluded" 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 && nextSteps.length > 0" class="triage-card__expert-steps">
|
||||
<div class="triage-card__expert-steps-title">📋 后续步骤预览:</div>
|
||||
<div
|
||||
v-for="(step, idx) in nextSteps"
|
||||
:key="idx"
|
||||
class="triage-card__expert-step-item"
|
||||
>
|
||||
<span class="triage-card__expert-step-num">{{ idx + 1 + currentStep }}</span>
|
||||
{{ step.question }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div v-show="!collapsed" class="triage-card__footer">
|
||||
<van-button
|
||||
round
|
||||
type="default"
|
||||
size="small"
|
||||
@click="handleSkip"
|
||||
>
|
||||
跳过
|
||||
</van-button>
|
||||
<van-button
|
||||
round
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!hasSelection"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ isLastStep ? '完成' : '下一步' }}
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
/** 单个选项结构 */
|
||||
interface TriageOption {
|
||||
/** 选项标签 */
|
||||
label: string
|
||||
/** 是否被用户选中 */
|
||||
selected: boolean
|
||||
/** AI 推荐概率(百分比 0-100,不披露原始confidence) */
|
||||
probability?: number
|
||||
/** 坐席推荐标记 */
|
||||
recommended?: boolean
|
||||
/** 坐席排除标记 */
|
||||
excluded?: boolean
|
||||
}
|
||||
|
||||
/** 单个分诊步骤 */
|
||||
interface TriageStep {
|
||||
/** 问题文本 */
|
||||
question: string
|
||||
/** 选项列表 */
|
||||
options: TriageOption[]
|
||||
/** 该步骤已收集的上下文 */
|
||||
collectedContext: string[]
|
||||
}
|
||||
|
||||
/** 组件属性 */
|
||||
const props = defineProps<{
|
||||
/** 是否可见 */
|
||||
visible: boolean
|
||||
/** 当前步骤序号(1-based) */
|
||||
step: number
|
||||
/** 总步骤数 */
|
||||
total: number
|
||||
/** 所有步骤数据 */
|
||||
steps: TriageStep[]
|
||||
/** AI 置信度(内部使用,不展示给员工) */
|
||||
confidence?: number
|
||||
}>()
|
||||
|
||||
/** 组件事件 */
|
||||
const emit = defineEmits<{
|
||||
/** 确认当前步骤 */
|
||||
(e: 'confirm', stepIndex: number, selectedLabel: string): void
|
||||
/** 跳过当前步骤 */
|
||||
(e: 'skip', stepIndex: number): void
|
||||
/** 专家模式切换 */
|
||||
(e: 'expert-mode-change', enabled: boolean): void
|
||||
/** 转人工 */
|
||||
(e: 'transfer-human', context: string[]): void
|
||||
}>()
|
||||
|
||||
// ===========================================================================
|
||||
// 状态
|
||||
// ===========================================================================
|
||||
|
||||
/** 是否折叠卡片 */
|
||||
const collapsed = ref(false)
|
||||
|
||||
/** 专家模式开关(默认关) */
|
||||
const expertMode = ref(false)
|
||||
|
||||
/** 当前步骤序号(0-based) */
|
||||
const currentStepIndex = computed(() => Math.max(0, Math.min(props.step - 1, props.steps.length - 1)))
|
||||
|
||||
/** 当前步骤数据 */
|
||||
const currentStepData = computed(() => props.steps[currentStepIndex.value] ?? null)
|
||||
|
||||
/** 当前问题文本 */
|
||||
const currentQuestion = computed(() => currentStepData.value?.question ?? '')
|
||||
|
||||
/** 当前选项列表 */
|
||||
const currentOptions = computed(() => currentStepData.value?.options ?? [])
|
||||
|
||||
/** 已收集上下文(所有步骤的上下文汇总) */
|
||||
const collectedContext = computed(() => {
|
||||
const ctx: string[] = []
|
||||
for (let i = 0; i <= currentStepIndex.value; i++) {
|
||||
const step = props.steps[i]
|
||||
if (step?.collectedContext) {
|
||||
ctx.push(...step.collectedContext)
|
||||
}
|
||||
}
|
||||
return ctx
|
||||
})
|
||||
|
||||
/** 总步骤数 */
|
||||
const totalSteps = computed(() => props.total || props.steps.length)
|
||||
|
||||
/** 当前步骤(1-based 展示用) */
|
||||
const currentStep = computed(() => currentStepIndex.value + 1)
|
||||
|
||||
/** 是否有选中项 */
|
||||
const hasSelection = computed(() => currentOptions.value.some(o => o.selected))
|
||||
|
||||
/** 是否为最后一步 */
|
||||
const isLastStep = computed(() => currentStep.value >= totalSteps.value)
|
||||
|
||||
/** 后续步骤预览(专家模式) */
|
||||
const nextSteps = computed(() => {
|
||||
if (!expertMode.value || isLastStep.value) return []
|
||||
return props.steps.slice(currentStepIndex.value + 1)
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// 方法
|
||||
// ===========================================================================
|
||||
|
||||
/** 折叠/展开卡片 */
|
||||
function toggleCollapse() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
/** 选择选项 */
|
||||
function selectOption(idx: number) {
|
||||
// 被排除的选项不可选
|
||||
const option = currentOptions.value[idx]
|
||||
if (option?.excluded) return
|
||||
|
||||
// 单选模式:取消其他,选中当前
|
||||
currentOptions.value.forEach((o, i) => {
|
||||
o.selected = i === idx && !o.selected
|
||||
})
|
||||
}
|
||||
|
||||
/** 确认当前步骤 */
|
||||
function handleConfirm() {
|
||||
const selected = currentOptions.value.find(o => o.selected)
|
||||
if (selected) {
|
||||
emit('confirm', currentStepIndex.value, selected.label)
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳过当前步骤 */
|
||||
function handleSkip() {
|
||||
emit('skip', currentStepIndex.value)
|
||||
}
|
||||
|
||||
/** 专家模式切换 */
|
||||
function onExpertModeChange(enabled: boolean) {
|
||||
emit('expert-mode-change', enabled)
|
||||
}
|
||||
|
||||
/** 暴露方法供父组件调用 */
|
||||
defineExpose({
|
||||
/** 设置坐席排除项 */
|
||||
setExcludedOptions(labels: string[]) {
|
||||
currentOptions.value.forEach(o => {
|
||||
if (labels.includes(o.label)) {
|
||||
o.excluded = true
|
||||
o.selected = false
|
||||
}
|
||||
})
|
||||
},
|
||||
/** 设置坐席推荐项 */
|
||||
setRecommendedOption(label: string) {
|
||||
currentOptions.value.forEach(o => {
|
||||
o.recommended = o.label === label
|
||||
})
|
||||
},
|
||||
})
|
||||
</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__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>
|
||||
@@ -29,8 +29,8 @@
|
||||
>
|
||||
<!-- 发送者名称(坐席和 AI 消息显示在左侧) -->
|
||||
<div v-if="msg.message_type !== 'employee'" class="message-bubble__sender">
|
||||
<!-- AI 消息显示 [AI回复] 标签 -->
|
||||
<span v-if="msg.message_type === 'ai'" class="message-bubble__ai-tag">[AI回复]</span>
|
||||
<!-- AI 消息显示达寇拉头像和名称 -->
|
||||
<img v-if="msg.message_type === 'ai'" src="/duckula.webp" class="message-bubble__ai-avatar" alt="Duckula" />
|
||||
<span class="message-bubble__sender-name">{{ senderName }}</span>
|
||||
</div>
|
||||
|
||||
@@ -220,7 +220,7 @@ const senderName = computed(() => {
|
||||
return props.msg.sender_name || 'IT坐席'
|
||||
}
|
||||
if (props.msg.message_type === 'ai') {
|
||||
return 'AI助手'
|
||||
return 'Duckula(达寇拉)'
|
||||
}
|
||||
return props.msg.sender_name
|
||||
})
|
||||
@@ -366,6 +366,15 @@ function previewImage(): void {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* AI 头像 */
|
||||
.message-bubble__ai-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* AI 标签 */
|
||||
.message-bubble__ai-tag {
|
||||
display: inline-block;
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
>
|
||||
<!-- 发送者名称(坐席和 AI 消息显示在左侧) -->
|
||||
<div v-if="msg.message_type !== 'employee'" class="message-item__sender">
|
||||
<span v-if="msg.message_type === 'ai'" class="message-item__ai-tag">[AI回复]</span>
|
||||
<!-- AI 消息显示达寇拉头像和名称 -->
|
||||
<img v-if="msg.message_type === 'ai'" src="/duckula.webp" class="message-item__ai-avatar" alt="Duckula" />
|
||||
<span class="message-item__sender-name">{{ senderName }}</span>
|
||||
</div>
|
||||
|
||||
@@ -199,7 +200,7 @@ const senderName = computed(() => {
|
||||
return props.msg.sender_name || 'IT坐席'
|
||||
}
|
||||
if (props.msg.message_type === 'ai') {
|
||||
return 'AI助手'
|
||||
return 'Duckula(达寇拉)'
|
||||
}
|
||||
return props.msg.sender_name
|
||||
})
|
||||
@@ -413,6 +414,15 @@ function previewImage(): void {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* AI 头像 */
|
||||
.message-item__ai-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-item__ai-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — H5员工端 置信门控逻辑 Composables(Tier1 / P0-3 / D3)
|
||||
// =============================================================================
|
||||
// 说明:置信门控逻辑复用 composable,封装 confidence 阈值判定、
|
||||
// 转人工上下文组装、阈值读取等核心逻辑。
|
||||
//
|
||||
// D3 硬约束:
|
||||
// - 全局阈值 0.7(可通过 settings.confidence_gate_threshold 配置)
|
||||
// - confidence < 0.7 → 触发门控
|
||||
// - 转人工携带上下文快照
|
||||
// =============================================================================
|
||||
|
||||
import { ref, computed, readonly } from 'vue'
|
||||
|
||||
/** 默认置信门控阈值 */
|
||||
const DEFAULT_CONFIDENCE_GATE_THRESHOLD = 0.7
|
||||
|
||||
/** 置信门控结果 */
|
||||
interface ConfidenceGateResult {
|
||||
/** 是否触发门控(confidence < threshold) */
|
||||
triggered: boolean
|
||||
/** 展示给用户的概率(百分比,0-100) */
|
||||
displayProbability: number
|
||||
/** 门控级别:critical(<0.3) / warning(<0.5) / caution */
|
||||
severity: 'critical' | 'warning' | 'caution' | 'normal'
|
||||
/** 是否应该展示转人工入口 */
|
||||
shouldShowTransfer: boolean
|
||||
}
|
||||
|
||||
/** useConfidenceGate Composable */
|
||||
export function useConfidenceGate(threshold?: number) {
|
||||
// =========================================================================
|
||||
// 状态
|
||||
// =========================================================================
|
||||
|
||||
/** 当前置信门控阈值 */
|
||||
const gateThreshold = ref(threshold ?? DEFAULT_CONFIDENCE_GATE_THRESHOLD)
|
||||
|
||||
/** 最近一次 AI 回复的 confidence */
|
||||
const lastConfidence = ref<number | null>(null)
|
||||
|
||||
/** 已收集的上下文快照 */
|
||||
const contextSnapshot = ref<string[]>([])
|
||||
|
||||
/** 门控是否已被用户处理(继续对话/转人工) */
|
||||
const dismissed = ref(false)
|
||||
|
||||
// =========================================================================
|
||||
// 计算属性
|
||||
// =========================================================================
|
||||
|
||||
/** 当前门控结果 */
|
||||
const gateResult = computed<ConfidenceGateResult>(() => {
|
||||
const conf = lastConfidence.value
|
||||
if (conf === null || conf === undefined) {
|
||||
return {
|
||||
triggered: false,
|
||||
displayProbability: 0,
|
||||
severity: 'normal',
|
||||
shouldShowTransfer: false,
|
||||
}
|
||||
}
|
||||
|
||||
const triggered = conf < gateThreshold.value
|
||||
const displayProbability = Math.round(conf * 100)
|
||||
|
||||
let severity: ConfidenceGateResult['severity'] = 'normal'
|
||||
if (conf < 0.3) severity = 'critical'
|
||||
else if (conf < 0.5) severity = 'warning'
|
||||
else if (conf < gateThreshold.value) severity = 'caution'
|
||||
|
||||
return {
|
||||
triggered,
|
||||
displayProbability,
|
||||
severity,
|
||||
shouldShowTransfer: triggered && !dismissed.value,
|
||||
}
|
||||
})
|
||||
|
||||
/** 是否应该展示置信门控横幅 */
|
||||
const showGateBanner = computed(() => gateResult.value.shouldShowTransfer)
|
||||
|
||||
/** 门控触发状态(只读) */
|
||||
const isGateTriggered = computed(() => gateResult.value.triggered)
|
||||
|
||||
// =========================================================================
|
||||
// 方法
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 评估 AI 回复的置信度
|
||||
* @param confidence AI 回复的 confidence 值(0.0-1.0)
|
||||
* @param context 附加上下文信息(已填写的表单数据等)
|
||||
*/
|
||||
function evaluateConfidence(confidence: number | null | undefined, context?: string[]) {
|
||||
lastConfidence.value = confidence ?? null
|
||||
dismissed.value = false
|
||||
|
||||
if (context && context.length > 0) {
|
||||
// 合并上下文(去重)
|
||||
const merged = new Set([...contextSnapshot.value, ...context])
|
||||
contextSnapshot.value = [...merged]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加上下文快照条目
|
||||
* @param items 上下文条目
|
||||
*/
|
||||
function addContext(...items: string[]) {
|
||||
const merged = new Set([...contextSnapshot.value, ...items])
|
||||
contextSnapshot.value = [...merged]
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除上下文快照
|
||||
*/
|
||||
function clearContext() {
|
||||
contextSnapshot.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户选择"继续询问 AI" — 关闭门控横幅
|
||||
*/
|
||||
function dismissGate() {
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户选择"转人工" — 组装上下文并触发转人工
|
||||
* @returns 上下文快照数组(用于转人工请求)
|
||||
*/
|
||||
function prepareTransferToHuman(): string[] {
|
||||
const snapshot = [...contextSnapshot.value]
|
||||
dismissed.value = true
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有状态(新会话开始时调用)
|
||||
*/
|
||||
function reset() {
|
||||
lastConfidence.value = null
|
||||
contextSnapshot.value = []
|
||||
dismissed.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态更新门控阈值
|
||||
* @param newThreshold 新阈值(0.0-1.0)
|
||||
*/
|
||||
function updateThreshold(newThreshold: number) {
|
||||
if (newThreshold >= 0 && newThreshold <= 1) {
|
||||
gateThreshold.value = newThreshold
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 导出
|
||||
// =========================================================================
|
||||
|
||||
return {
|
||||
// 只读状态
|
||||
gateThreshold: readonly(gateThreshold),
|
||||
lastConfidence: readonly(lastConfidence),
|
||||
contextSnapshot: readonly(contextSnapshot),
|
||||
gateResult,
|
||||
showGateBanner,
|
||||
isGateTriggered,
|
||||
|
||||
// 方法
|
||||
evaluateConfidence,
|
||||
addContext,
|
||||
clearContext,
|
||||
dismissGate,
|
||||
prepareTransferToHuman,
|
||||
reset,
|
||||
updateThreshold,
|
||||
}
|
||||
}
|
||||
@@ -151,9 +151,13 @@ export function useH5WebSocket() {
|
||||
|
||||
// 如果不是主动断开,启动降级和重连
|
||||
if (!intentionalDisconnect) {
|
||||
// WS 断连:清理未完成的打字机占位气泡,避免半成品气泡残留
|
||||
// 为什么:流式推送中途断连时,占位气泡无法收到 ai_reply 终态,
|
||||
// 交由 3 秒轮询兜底重新拉取完整 AI 消息
|
||||
const store = useConversationStore()
|
||||
store.cancelStreamingBubble()
|
||||
// WS 断连,启动轮询 fallback
|
||||
// 为什么:WS 不可用时,仍需通过轮询获取最新数据
|
||||
const store = useConversationStore()
|
||||
store.startPolling()
|
||||
// 尝试重连
|
||||
scheduleReconnect()
|
||||
@@ -353,6 +357,30 @@ export function useH5WebSocket() {
|
||||
}
|
||||
break
|
||||
|
||||
// ==================================================================
|
||||
// AI 回复流式推送(改造方案 A:发送瞬时返回,AI 经 WS 推回)
|
||||
// ==================================================================
|
||||
case 'ai_reply_chunk':
|
||||
// 打字机:逐 chunk 累积到临时 AI 气泡
|
||||
if (msg.data) {
|
||||
store.handleAiReplyChunk(msg.data)
|
||||
}
|
||||
break
|
||||
|
||||
case 'ai_reply':
|
||||
// AI 终态:用真实消息替换打字机气泡
|
||||
if (msg.data) {
|
||||
store.handleAiReply(msg.data)
|
||||
}
|
||||
break
|
||||
|
||||
case 'ai_reply_failed':
|
||||
// AI 异常:占位气泡替换为错误提示
|
||||
if (msg.data) {
|
||||
store.handleAiReplyFailed(msg.data)
|
||||
}
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
// 心跳响应,不需要处理
|
||||
break
|
||||
|
||||
@@ -185,6 +185,18 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
/** 参与者面板是否展开 */
|
||||
const participantPanelVisible = ref<boolean>(false)
|
||||
|
||||
// ==========================================================================
|
||||
// 流式 AI 回复(打字机)临时状态 — 改造方案 A
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 当前正在流式接收的 AI 消息临时气泡 ID
|
||||
* 做什么:记录打字机占位气泡的 message_id,流式 chunk 累积到该气泡,
|
||||
* 收到 ai_reply 终态后替换为真实消息
|
||||
* 为什么:员工端同时只有一个活跃会话,无需用 Map 存多会话
|
||||
*/
|
||||
const streamingAiMessageId = ref<string | null>(null)
|
||||
|
||||
// ==========================================================================
|
||||
// 消息去重相关状态(与 agent 端一致,WS-06 修复)
|
||||
// ==========================================================================
|
||||
@@ -518,11 +530,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
messages.value.push({ ...resp.user_message, status: 'sent' })
|
||||
}
|
||||
|
||||
// 将 AI 回复追加到本地列表(如果存在)
|
||||
if (resp.ai_reply) {
|
||||
messages.value.push(resp.ai_reply)
|
||||
lastMessageId.value = resp.ai_reply.message_id
|
||||
}
|
||||
// 注意:AI 回复不再经 HTTP 同步返回(后端已改为 ai_reply: null),
|
||||
// 而是由后台任务经 WebSocket 推回(ai_reply_chunk / ai_reply 事件)。
|
||||
// 前端收到 WS ai_reply 终态后,在 handleAiReply 中追加真实 AI 消息。
|
||||
|
||||
// 更新「是否可呼叫坐席」标志
|
||||
canCallAgent.value = resp.can_call_agent ?? false
|
||||
@@ -961,6 +971,162 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WS 流式 AI 回复 chunk(打字机效果)
|
||||
* 做什么:首个 chunk 时创建临时 AI 气泡,后续 chunk 累积到该气泡 content
|
||||
* 为什么:后端 AI 推理(Dify)流式返回,逐字推送以获得打字机体验,
|
||||
* 避免整段等待(原同步方案卡"发送中"3~15s)
|
||||
*
|
||||
* @param data - { conversation_id, chunk }
|
||||
*/
|
||||
function handleAiReplyChunk(data: { conversation_id: string; chunk: string }): void {
|
||||
// 仅处理当前会话,避免串台
|
||||
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
||||
if (!data.chunk) return
|
||||
|
||||
// 首个 chunk:创建占位气泡(不设置 status,避免误显示"发送中")
|
||||
if (!streamingAiMessageId.value) {
|
||||
const tempId = `ai_stream_${data.conversation_id}_${Date.now()}`
|
||||
streamingAiMessageId.value = tempId
|
||||
messages.value.push({
|
||||
message_id: tempId,
|
||||
conversation_id: data.conversation_id,
|
||||
message_type: 'ai',
|
||||
msg_type: 'text',
|
||||
content: '',
|
||||
sender_name: 'Duckula(达寇拉)',
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
// 累积 chunk(Vue3 ref 深层响应式,直接改属性即可触发重渲染 → 打字机)
|
||||
const idx = messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
||||
if (idx !== -1) {
|
||||
messages.value[idx].content += data.chunk
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WS AI 回复终态
|
||||
* 做什么:用真实 AI 消息(含 DB message_id)替换打字机占位气泡,
|
||||
* 登记 message_id 去重,同步计数 / 可呼叫坐席 / 会话状态
|
||||
* 为什么:打字机结束后需落定为真实消息,且防止轮询兜底重复添加同一消息
|
||||
*
|
||||
* @param data - 后端 _persist_and_push 推送的 ai_reply 数据
|
||||
*/
|
||||
function handleAiReply(data: {
|
||||
message_id: string
|
||||
conversation_id: string
|
||||
sender_type: string
|
||||
sender_id: string
|
||||
sender_name: string
|
||||
content: string
|
||||
msg_type: string
|
||||
is_guidance: boolean
|
||||
ai_reply_count: number
|
||||
can_call_agent: boolean
|
||||
conversation_status: string
|
||||
}): void {
|
||||
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
||||
|
||||
const finalMessage: Message = {
|
||||
message_id: data.message_id,
|
||||
conversation_id: data.conversation_id,
|
||||
message_type: (data.sender_type || 'ai') as MessageType,
|
||||
msg_type: (data.msg_type || 'text') as MsgContentType,
|
||||
content: data.content,
|
||||
sender_name: data.sender_name || 'Duckula(达寇拉)',
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'sent',
|
||||
}
|
||||
|
||||
// 用真实消息替换占位气泡(若存在)
|
||||
const idx = streamingAiMessageId.value
|
||||
? messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
||||
: -1
|
||||
if (idx !== -1) {
|
||||
messages.value[idx] = finalMessage
|
||||
} else {
|
||||
// 没有占位气泡(如 WS 重连后首条 ai_reply),直接追加
|
||||
messages.value.push(finalMessage)
|
||||
}
|
||||
streamingAiMessageId.value = null
|
||||
|
||||
// 去重登记:防止轮询兜底重复添加同一 AI 消息
|
||||
trackProcessedMessageId(data.message_id)
|
||||
lastMessageId.value = data.message_id
|
||||
|
||||
// 同步计数与可呼叫坐席状态
|
||||
canCallAgent.value = data.can_call_agent ?? false
|
||||
if (currentConversation.value) {
|
||||
currentConversation.value.can_call_agent = data.can_call_agent ?? false
|
||||
currentConversation.value.ai_substantive_reply_count = data.ai_reply_count ?? 0
|
||||
// 后端状态值(ai_handling/queued/serving/resolved)映射到 H5 识别的子集
|
||||
// H5 ConversationInfo.status 仅接受 waiting/serving/resolved,
|
||||
// ai_handling/queued 均表示"进行中未结单",映射为 waiting
|
||||
const statusMap: Record<string, 'waiting' | 'serving' | 'resolved'> = {
|
||||
ai_handling: 'waiting',
|
||||
queued: 'waiting',
|
||||
serving: 'serving',
|
||||
resolved: 'resolved',
|
||||
}
|
||||
currentConversation.value.status =
|
||||
statusMap[data.conversation_status] || currentConversation.value.status
|
||||
}
|
||||
|
||||
// 持久化缓存
|
||||
const convId = currentConversation.value?.conversation_id
|
||||
if (convId) saveMessagesToCache(convId, messages.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WS AI 回复失败(兜底)
|
||||
* 做什么:将占位气泡(或新气泡)替换为错误提示,引导用户转人工
|
||||
* 为什么:Dify 异常时不应让用户一直看到空白打字气泡
|
||||
*
|
||||
* @param data - { conversation_id, message }
|
||||
*/
|
||||
function handleAiReplyFailed(data: { conversation_id: string; message: string }): void {
|
||||
if (currentConversation.value?.conversation_id !== data.conversation_id) return
|
||||
|
||||
const errorMsg: Message = {
|
||||
message_id: `ai_failed_${data.conversation_id}_${Date.now()}`,
|
||||
conversation_id: data.conversation_id,
|
||||
message_type: 'ai',
|
||||
msg_type: 'text',
|
||||
content: data.message || '⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。',
|
||||
sender_name: 'Duckula(达寇拉)',
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'sent',
|
||||
}
|
||||
|
||||
// 若仍有占位气泡,替换之;否则直接追加
|
||||
const idx = streamingAiMessageId.value
|
||||
? messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
||||
: -1
|
||||
if (idx !== -1) {
|
||||
messages.value[idx] = errorMsg
|
||||
} else {
|
||||
messages.value.push(errorMsg)
|
||||
}
|
||||
streamingAiMessageId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消流式气泡(WS 断连时调用)
|
||||
* 做什么:若 WS 在流式推送中途断开,移除未完成的占位气泡,
|
||||
* 交由 3 秒轮询兜底重新拉取完整 AI 消息
|
||||
* 为什么:避免断连后留下半成品打字气泡
|
||||
*/
|
||||
function cancelStreamingBubble(): void {
|
||||
if (!streamingAiMessageId.value) return
|
||||
const idx = messages.value.findIndex(m => m.message_id === streamingAiMessageId.value)
|
||||
if (idx !== -1) {
|
||||
messages.value.splice(idx, 1)
|
||||
}
|
||||
streamingAiMessageId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化应用
|
||||
* 1. 获取用户信息
|
||||
@@ -1129,5 +1295,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
|
||||
// WS-06 消息去重(与 agent 端对齐,WebSocket 接入时使用)
|
||||
handleNewMessage,
|
||||
|
||||
// 流式 AI 回复(改造方案 A:打字机)
|
||||
handleAiReplyChunk,
|
||||
handleAiReply,
|
||||
handleAiReplyFailed,
|
||||
cancelStreamingBubble,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -35,7 +35,10 @@ export default defineConfig({
|
||||
// API 代理:将 /api 请求转发到后端
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
// 开发代理目标:默认 localhost:8000(宿主机本地直跑后端);
|
||||
// Docker 化 dev 栈中前端容器内的 localhost 不是后端,需用 compose 服务名 backend:8000。
|
||||
// 通过 VITE_PROXY_TARGET 注入,避免写死导致容器内 502/ECONNREFUSED。
|
||||
target: process.env.VITE_PROXY_TARGET || 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
// 本地开发剥离 /api 前缀,因为后端路由不包含 /api(生产 nginx 负责剥离)
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
|
||||
Reference in New Issue
Block a user