feat(ctrt): 完成 CTRT-01~03 响应契约统一 - 三端拦截器+portal_token清理+调用点适配
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 管理前端开发版 Docker 镜像
|
||||||
|
# =============================================================================
|
||||||
|
# 说明:基于 node:20 开发模式,支持代码热更新(volume mount 源码)
|
||||||
|
# 用途:本地开发,代码修改自动生效
|
||||||
|
# =============================================================================
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# 安装 pnpm
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# 复制源码(后续通过 volume mount 更新)
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 5175
|
||||||
|
|
||||||
|
# 启动开发服务器(热更新)
|
||||||
|
CMD ["pnpm", "dev", "--host"]
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 企微IT智能服务台 — 阶段5 自动化闭环 API 调用模块(管理后台)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:封装管理端自动化配置 / 规则版本 / 指标相关 HTTP 请求。
|
||||||
|
// 约定:与 admin 其它 api 模块一致 —— 返回「包装对象」{ data: { code, data, message } },
|
||||||
|
// 调用方通过 res.data.data 取业务数据(响应拦截器已做统一错误处理)。
|
||||||
|
// 路径:与后端契约 §15.3 一致,前缀 /itportal/automation。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import apiClient from './index'
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 类型定义(与坐席端保持一致)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
export interface ScenarioConfig {
|
||||||
|
id: string
|
||||||
|
scenario_key: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
enabled: boolean
|
||||||
|
trigger_conditions: Record<string, any> | null
|
||||||
|
actions: any[] | null
|
||||||
|
approval_strategy: Record<string, any> | null
|
||||||
|
current_version_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleVersion {
|
||||||
|
id: string
|
||||||
|
scenario_key: string
|
||||||
|
version: number
|
||||||
|
content: Record<string, any> | null
|
||||||
|
status: string
|
||||||
|
canary_percent: number
|
||||||
|
created_by: string | null
|
||||||
|
remark: string
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoMetrics {
|
||||||
|
total_sessions: number
|
||||||
|
resolved_sessions: number
|
||||||
|
handoff_sessions: number
|
||||||
|
error_sessions: number
|
||||||
|
auto_executed_actions: number
|
||||||
|
approval_required_actions: number
|
||||||
|
by_scenario: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateScenarioPayload {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
enabled?: boolean
|
||||||
|
trigger_conditions?: Record<string, any>
|
||||||
|
actions?: any[]
|
||||||
|
approval_strategy?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishVersionPayload {
|
||||||
|
content?: Record<string, any>
|
||||||
|
canary_percent?: number
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一包装返回类型(与 admin 约定一致) */
|
||||||
|
type ApiResp<T> = { data: { code: number; data: T; message: string } }
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 管理端接口(§15.3 端点)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 场景配置列表(对应后端 /admin/scenarios) */
|
||||||
|
export function getAutomationConfigs(): Promise<ApiResp<ScenarioConfig[]>> {
|
||||||
|
return apiClient.get('/itportal/automation/admin/scenarios')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建场景配置(对应后端 POST /admin/scenarios) */
|
||||||
|
export function createAutomationConfig(payload: UpdateScenarioPayload): Promise<ApiResp<ScenarioConfig>> {
|
||||||
|
return apiClient.post('/itportal/automation/admin/scenarios', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新场景配置(对应后端 PUT /admin/scenarios/{scenario_key}) */
|
||||||
|
export function updateAutomationConfig(
|
||||||
|
scenario_key: string,
|
||||||
|
payload: UpdateScenarioPayload,
|
||||||
|
): Promise<ApiResp<ScenarioConfig>> {
|
||||||
|
return apiClient.put(`/itportal/automation/admin/scenarios/${scenario_key}`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发布规则版本(含灰度比例,对应后端 POST /admin/scenarios/{scenario_key}/version) */
|
||||||
|
export function publishConfigVersion(
|
||||||
|
scenario_key: string,
|
||||||
|
payload: PublishVersionPayload,
|
||||||
|
): Promise<ApiResp<RuleVersion>> {
|
||||||
|
return apiClient.post(`/itportal/automation/admin/scenarios/${scenario_key}/version`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规则版本列表(对应后端 GET /admin/rule-versions) */
|
||||||
|
export function getConfigVersions(scenario_key: string): Promise<ApiResp<RuleVersion[]>> {
|
||||||
|
return apiClient.get('/itportal/automation/admin/rule-versions', { params: { scenario_key } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自动化指标看板(对应后端 GET /admin/metrics) */
|
||||||
|
export function getAutomationMetrics(): Promise<ApiResp<AutoMetrics>> {
|
||||||
|
return apiClient.get('/itportal/automation/admin/metrics')
|
||||||
|
}
|
||||||
@@ -60,12 +60,9 @@ interface ApiResponse<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Axios 实例(继承全局 baseURL)
|
// 复用全局 apiClient(CTRT-01 统一契约)
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
const http = axios.create({
|
import apiClient from './index'
|
||||||
baseURL: '/api',
|
|
||||||
timeout: 30000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// 5 个端点
|
// 5 个端点
|
||||||
@@ -73,29 +70,20 @@ const http = axios.create({
|
|||||||
|
|
||||||
/** GET /api/troubleshooting-templates — 获取模板列表 */
|
/** GET /api/troubleshooting-templates — 获取模板列表 */
|
||||||
export async function listTemplates(): Promise<TroubleshootingTemplate[]> {
|
export async function listTemplates(): Promise<TroubleshootingTemplate[]> {
|
||||||
const res = await http.get<ApiResponse<TroubleshootingTemplate[]>>(
|
const res = await apiClient.get<TroubleshootingTemplate[]>('/troubleshooting-templates')
|
||||||
'/troubleshooting-templates'
|
return res || []
|
||||||
)
|
|
||||||
return res.data.data || []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/troubleshooting-templates/{id} — 获取模板详情 */
|
/** GET /api/troubleshooting-templates/{id} — 获取模板详情 */
|
||||||
export async function getTemplate(id: string): Promise<TroubleshootingTemplate> {
|
export async function getTemplate(id: string): Promise<TroubleshootingTemplate> {
|
||||||
const res = await http.get<ApiResponse<TroubleshootingTemplate>>(
|
return await apiClient.get<TroubleshootingTemplate>(`/troubleshooting-templates/${id}`)
|
||||||
`/troubleshooting-templates/${id}`
|
|
||||||
)
|
|
||||||
return res.data.data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/troubleshooting-templates — 新建模板 */
|
/** POST /api/troubleshooting-templates — 新建模板 */
|
||||||
export async function createTemplate(
|
export async function createTemplate(
|
||||||
data: TroubleshootingTemplate
|
data: TroubleshootingTemplate
|
||||||
): Promise<TroubleshootingTemplate> {
|
): Promise<TroubleshootingTemplate> {
|
||||||
const res = await http.post<ApiResponse<TroubleshootingTemplate>>(
|
return await apiClient.post<TroubleshootingTemplate>('/troubleshooting-templates', data)
|
||||||
'/troubleshooting-templates',
|
|
||||||
data
|
|
||||||
)
|
|
||||||
return res.data.data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PUT /api/troubleshooting-templates/{id} — 更新模板 */
|
/** PUT /api/troubleshooting-templates/{id} — 更新模板 */
|
||||||
@@ -103,16 +91,12 @@ export async function updateTemplate(
|
|||||||
id: string,
|
id: string,
|
||||||
data: TroubleshootingTemplate
|
data: TroubleshootingTemplate
|
||||||
): Promise<TroubleshootingTemplate> {
|
): Promise<TroubleshootingTemplate> {
|
||||||
const res = await http.put<ApiResponse<TroubleshootingTemplate>>(
|
return await apiClient.put<TroubleshootingTemplate>(`/troubleshooting-templates/${id}`, data)
|
||||||
`/troubleshooting-templates/${id}`,
|
|
||||||
data
|
|
||||||
)
|
|
||||||
return res.data.data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** DELETE /api/troubleshooting-templates/{id} — 删除模板 */
|
/** DELETE /api/troubleshooting-templates/{id} — 删除模板 */
|
||||||
export async function deleteTemplate(id: string): Promise<void> {
|
export async function deleteTemplate(id: string): Promise<void> {
|
||||||
await http.delete(`/troubleshooting-templates/${id}`)
|
await apiClient.delete(`/troubleshooting-templates/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工具:把对象格式化成 JSON 字符串(带缩进) */
|
/** 工具:把对象格式化成 JSON 字符串(带缩进) */
|
||||||
|
|||||||
@@ -104,6 +104,21 @@
|
|||||||
<span>知识库建议</span>
|
<span>知识库建议</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|
||||||
|
<!-- 🤖 自动化闭环(阶段5) -->
|
||||||
|
<div class="menu-section-title">🤖 自动化闭环</div>
|
||||||
|
<el-menu-item index="/automation-scenarios">
|
||||||
|
<el-icon><MagicStick /></el-icon>
|
||||||
|
<span>自动化场景</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/automation-versions">
|
||||||
|
<el-icon><Files /></el-icon>
|
||||||
|
<span>规则版本</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/automation-metrics">
|
||||||
|
<el-icon><TrendCharts /></el-icon>
|
||||||
|
<span>自动化指标</span>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
<!-- 🔒 开发中(P2占位) -->
|
<!-- 🔒 开发中(P2占位) -->
|
||||||
<div class="menu-section-title">🔒 开发中</div>
|
<div class="menu-section-title">🔒 开发中</div>
|
||||||
<el-menu-item index="/themes" class="locked-menu-item">
|
<el-menu-item index="/themes" class="locked-menu-item">
|
||||||
@@ -131,7 +146,7 @@
|
|||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { Headset, PieChart, Switch, UserFilled, Connection, Warning, ChatLineSquare, Sort, Share, Monitor, Brush, DataAnalysis, Reading, Lock, Key, Document, TrendCharts, Notebook, Star } from '@element-plus/icons-vue'
|
import { Headset, PieChart, Switch, UserFilled, Connection, Warning, ChatLineSquare, Sort, Share, Monitor, Brush, DataAnalysis, Reading, Lock, Key, Document, TrendCharts, Notebook, Star, MagicStick, Files } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// 当前激活菜单
|
// 当前激活菜单
|
||||||
|
|||||||
@@ -160,6 +160,27 @@ const routes = [
|
|||||||
component: () => import('@/views/KnowledgeSuggestions.vue'),
|
component: () => import('@/views/KnowledgeSuggestions.vue'),
|
||||||
meta: { title: '知识库建议', requiresAuth: true },
|
meta: { title: '知识库建议', requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 阶段5 自动化闭环 — 场景配置
|
||||||
|
path: 'automation-scenarios',
|
||||||
|
name: 'AutomationScenarios',
|
||||||
|
component: () => import('@/views/automation/ScenarioConfig.vue'),
|
||||||
|
meta: { title: '自动化场景', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 阶段5 自动化闭环 — 规则版本/灰度
|
||||||
|
path: 'automation-versions',
|
||||||
|
name: 'AutomationVersions',
|
||||||
|
component: () => import('@/views/automation/RuleVersion.vue'),
|
||||||
|
meta: { title: '规则版本', requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 阶段5 自动化闭环 — 指标看板
|
||||||
|
path: 'automation-metrics',
|
||||||
|
name: 'AutomationMetrics',
|
||||||
|
component: () => import('@/views/dashboard/AutoMetrics.vue'),
|
||||||
|
meta: { title: '自动化指标', requiresAuth: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 企微IT智能服务台 — 阶段5 自动化闭环 状态管理(Pinia Store,管理后台)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:管理自动化场景配置、规则版本、运营指标。
|
||||||
|
// 约定:api 返回包装对象,调用方统一从 res.data.data 取业务数据。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type {
|
||||||
|
ScenarioConfig,
|
||||||
|
RuleVersion,
|
||||||
|
AutoMetrics,
|
||||||
|
UpdateScenarioPayload,
|
||||||
|
PublishVersionPayload,
|
||||||
|
} from '@/api/automation'
|
||||||
|
import {
|
||||||
|
getAutomationConfigs,
|
||||||
|
updateAutomationConfig,
|
||||||
|
publishConfigVersion,
|
||||||
|
getConfigVersions,
|
||||||
|
getAutomationMetrics,
|
||||||
|
} from '@/api/automation'
|
||||||
|
|
||||||
|
export const useAutomationStore = defineStore('automation', () => {
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// 状态
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
const scenarios = ref<ScenarioConfig[]>([])
|
||||||
|
const versions = ref<RuleVersion[]>([])
|
||||||
|
const metrics = ref<AutoMetrics | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const currentScenarioKey = ref<string>('')
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// 配置(场景开关)
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
async function loadScenarios(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAutomationConfigs()
|
||||||
|
scenarios.value = res.data.data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateScenario(
|
||||||
|
id: string,
|
||||||
|
payload: UpdateScenarioPayload,
|
||||||
|
): Promise<ScenarioConfig> {
|
||||||
|
const res = await updateAutomationConfig(id, payload)
|
||||||
|
const updated = res.data.data
|
||||||
|
// 本地同步,避免整表刷新
|
||||||
|
const idx = scenarios.value.findIndex((s) => s.id === id)
|
||||||
|
if (idx >= 0) scenarios.value[idx] = updated
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// 规则版本(灰度)
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
async function loadVersions(scenarioKey: string): Promise<void> {
|
||||||
|
currentScenarioKey.value = scenarioKey
|
||||||
|
const res = await getConfigVersions(scenarioKey)
|
||||||
|
versions.value = res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishVersion(
|
||||||
|
id: string,
|
||||||
|
payload: PublishVersionPayload,
|
||||||
|
): Promise<RuleVersion> {
|
||||||
|
const res = await publishConfigVersion(id, payload)
|
||||||
|
const created = res.data.data
|
||||||
|
versions.value.unshift(created)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// 指标
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
async function loadMetrics(): Promise<void> {
|
||||||
|
const res = await getAutomationMetrics()
|
||||||
|
metrics.value = res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
scenarios,
|
||||||
|
versions,
|
||||||
|
metrics,
|
||||||
|
loading,
|
||||||
|
currentScenarioKey,
|
||||||
|
loadScenarios,
|
||||||
|
updateScenario,
|
||||||
|
loadVersions,
|
||||||
|
publishVersion,
|
||||||
|
loadMetrics,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<!--
|
||||||
|
=============================================================================
|
||||||
|
企微IT智能服务台 — 自动化规则版本管理 / 灰度(管理后台)
|
||||||
|
=============================================================================
|
||||||
|
说明:查看某场景的规则版本列表(版本号 / 状态 / 灰度比例),并发布新版本。
|
||||||
|
- 灰度:发布时可设置 canary_percent(灰度放量比例)
|
||||||
|
- 风格:Element Plus 组件 + Tailwind 布局
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="rule-version">
|
||||||
|
<div class="page-title">规则版本管理 / 灰度</div>
|
||||||
|
<div class="page-desc">管理各场景自动化规则的版本与灰度放量,新版本先小流量验证再全量。</div>
|
||||||
|
|
||||||
|
<!-- 场景选择 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-select
|
||||||
|
v-model="selectedKey"
|
||||||
|
placeholder="选择场景"
|
||||||
|
filterable
|
||||||
|
style="width: 260px"
|
||||||
|
@change="onSelectScenario"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="sc in store.scenarios"
|
||||||
|
:key="sc.scenario_key"
|
||||||
|
:label="sc.name"
|
||||||
|
:value="sc.scenario_key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :disabled="!selectedKey" @click="openPublish">
|
||||||
|
发布新版本
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="store.loading" class="loading-state">
|
||||||
|
<el-skeleton :rows="6" animated />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-else :data="store.versions" class="ver-table" stripe>
|
||||||
|
<el-table-column prop="version" label="版本" width="90" />
|
||||||
|
<el-table-column label="状态" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTag(row.status)" size="small">{{ row.status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="灰度比例" width="120">
|
||||||
|
<template #default="{ row }">{{ row.canary_percent }}%</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="remark" label="备注" />
|
||||||
|
<el-table-column prop="created_by" label="发布人" width="120" />
|
||||||
|
<el-table-column label="发布时间" width="180">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 发布新版本弹窗 -->
|
||||||
|
<el-dialog v-model="publishVisible" title="发布新版本(灰度)" width="520px" destroy-on-close>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="灰度放量比例:{{ canaryPercent }}%">
|
||||||
|
<el-slider v-model="canaryPercent" :min="0" :max="100" :step="5" show-input />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="规则内容(JSON)">
|
||||||
|
<el-input v-model="contentText" type="textarea" :rows="6" placeholder="可选,留空沿用上一版本" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="remark" placeholder="如:修复 VPN 重置流程" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="publishVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="publishing" @click="onPublish">发布</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAutomationStore } from '@/stores/automation'
|
||||||
|
import type { ScenarioConfig } from '@/api/automation'
|
||||||
|
|
||||||
|
const store = useAutomationStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const selectedKey = ref<string>('')
|
||||||
|
const publishVisible = ref(false)
|
||||||
|
const publishing = ref(false)
|
||||||
|
const canaryPercent = ref(10)
|
||||||
|
const contentText = ref('')
|
||||||
|
const remark = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 先加载场景列表,再决定默认选中项(支持 ?scenario=KEY)
|
||||||
|
await store.loadScenarios()
|
||||||
|
const fromQuery = (route.query.scenario as string) || ''
|
||||||
|
const firstKey = store.scenarios[0]?.scenario_key || ''
|
||||||
|
selectedKey.value = fromQuery && store.scenarios.some((s) => s.scenario_key === fromQuery)
|
||||||
|
? fromQuery
|
||||||
|
: firstKey
|
||||||
|
if (selectedKey.value) await store.loadVersions(selectedKey.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSelectScenario(key: string): Promise<void> {
|
||||||
|
if (key) await store.loadVersions(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTag(status: string): 'success' | 'warning' | 'info' | 'danger' {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
case 'full':
|
||||||
|
return 'success'
|
||||||
|
case 'canary':
|
||||||
|
return 'warning'
|
||||||
|
case 'rollback':
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPublish(): void {
|
||||||
|
if (!selectedKey.value) return
|
||||||
|
canaryPercent.value = 10
|
||||||
|
contentText.value = ''
|
||||||
|
remark.value = ''
|
||||||
|
publishVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPublish(): Promise<void> {
|
||||||
|
if (!selectedKey.value) return
|
||||||
|
const sc = store.scenarios.find((s: ScenarioConfig) => s.scenario_key === selectedKey.value)
|
||||||
|
if (!sc) return
|
||||||
|
let content: Record<string, any> | undefined
|
||||||
|
if (contentText.value.trim()) {
|
||||||
|
try {
|
||||||
|
content = JSON.parse(contentText.value)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('规则内容 JSON 格式有误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publishing.value = true
|
||||||
|
try {
|
||||||
|
await store.publishVersion(sc.id, {
|
||||||
|
content,
|
||||||
|
canary_percent: canaryPercent.value,
|
||||||
|
remark: remark.value,
|
||||||
|
})
|
||||||
|
ElMessage.success('新版本已发布')
|
||||||
|
publishVisible.value = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('发布失败')
|
||||||
|
} finally {
|
||||||
|
publishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t: string | null): string {
|
||||||
|
if (!t) return '-'
|
||||||
|
try {
|
||||||
|
return new Date(t).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
} catch {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rule-version {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.page-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 6px 0 16px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.ver-table {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<!--
|
||||||
|
=============================================================================
|
||||||
|
企微IT智能服务台 — 自动化场景配置页(管理后台)
|
||||||
|
=============================================================================
|
||||||
|
说明:按场景展示自动化开关、触发条件、动作与审批策略。
|
||||||
|
- 开关:el-switch 即时切换 enabled
|
||||||
|
- 编辑:弹窗编辑触发条件 / 动作 / 审批策略(JSON)
|
||||||
|
- 风格:Element Plus 组件 + Tailwind 布局(与 Configs.vue 一致)
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="automation-config">
|
||||||
|
<div class="page-title">自动化场景配置</div>
|
||||||
|
<div class="page-desc">
|
||||||
|
按场景控制自动化处置的开关与策略,运行时切换即时生效。高危动作需员工/H5 二次确认。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="store.loading" class="loading-state">
|
||||||
|
<el-skeleton :rows="8" animated />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="feature-grid">
|
||||||
|
<el-card
|
||||||
|
v-for="sc in store.scenarios"
|
||||||
|
:key="sc.id"
|
||||||
|
class="scenario-card"
|
||||||
|
shadow="hover"
|
||||||
|
>
|
||||||
|
<div class="sc-header">
|
||||||
|
<div>
|
||||||
|
<div class="sc-name">{{ sc.name }}</div>
|
||||||
|
<div class="sc-key">{{ sc.scenario_key }}</div>
|
||||||
|
</div>
|
||||||
|
<el-switch
|
||||||
|
:model-value="sc.enabled"
|
||||||
|
@change="(v: boolean) => onToggle(sc, v)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sc-desc">{{ sc.description || '暂无描述' }}</div>
|
||||||
|
|
||||||
|
<el-descriptions :column="1" size="small" class="sc-meta" border>
|
||||||
|
<el-descriptions-item label="触发条件">
|
||||||
|
{{ sc.trigger_conditions ? '已配置' : '默认' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="动作数">
|
||||||
|
{{ Array.isArray(sc.actions) ? sc.actions.length : 0 }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="审批策略">
|
||||||
|
{{ sc.approval_strategy ? '已配置' : '默认' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<div class="sc-actions">
|
||||||
|
<el-button size="small" @click="openEdit(sc)">编辑策略</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="goVersions(sc)">版本管理</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="editVisible"
|
||||||
|
:title="'编辑场景 — ' + (editing?.name || '')"
|
||||||
|
width="640px"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<el-form v-if="editing" label-position="top">
|
||||||
|
<el-form-item label="场景名称">
|
||||||
|
<el-input v-model="form.name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="form.enabled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="触发条件(JSON)">
|
||||||
|
<el-input v-model="form.trigger_conditions" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="动作(JSON 数组)">
|
||||||
|
<el-input v-model="form.actions" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="审批策略(JSON)">
|
||||||
|
<el-input v-model="form.approval_strategy" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAutomationStore } from '@/stores/automation'
|
||||||
|
import type { ScenarioConfig } from '@/api/automation'
|
||||||
|
|
||||||
|
const store = useAutomationStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const editVisible = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const editing = ref<ScenarioConfig | null>(null)
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
enabled: true,
|
||||||
|
trigger_conditions: '',
|
||||||
|
actions: '',
|
||||||
|
approval_strategy: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadScenarios()
|
||||||
|
})
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 开关切换:只提交 enabled 字段
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
async function onToggle(sc: ScenarioConfig, val: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
await store.updateScenario(sc.id, { enabled: val })
|
||||||
|
ElMessage.success(val ? '已启用' : '已停用')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('切换失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 打开编辑:把对象序列化为 JSON 文本填入表单
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
function openEdit(sc: ScenarioConfig): void {
|
||||||
|
editing.value = sc
|
||||||
|
form.name = sc.name
|
||||||
|
form.description = sc.description
|
||||||
|
form.enabled = sc.enabled
|
||||||
|
form.trigger_conditions = sc.trigger_conditions ? JSON.stringify(sc.trigger_conditions, null, 2) : ''
|
||||||
|
form.actions = sc.actions ? JSON.stringify(sc.actions, null, 2) : '[]'
|
||||||
|
form.approval_strategy = sc.approval_strategy ? JSON.stringify(sc.approval_strategy, null, 2) : ''
|
||||||
|
editVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 保存:将 JSON 文本解析回对象,提交更新
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
async function onSave(): Promise<void> {
|
||||||
|
if (!editing.value) return
|
||||||
|
let trigger_conditions: Record<string, any> | undefined
|
||||||
|
let actions: any[] | undefined
|
||||||
|
let approval_strategy: Record<string, any> | undefined
|
||||||
|
try {
|
||||||
|
trigger_conditions = form.trigger_conditions ? JSON.parse(form.trigger_conditions) : undefined
|
||||||
|
actions = form.actions ? JSON.parse(form.actions) : undefined
|
||||||
|
approval_strategy = form.approval_strategy ? JSON.parse(form.approval_strategy) : undefined
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('JSON 格式有误,请检查后重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await store.updateScenario(editing.value.id, {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
enabled: form.enabled,
|
||||||
|
trigger_conditions,
|
||||||
|
actions,
|
||||||
|
approval_strategy,
|
||||||
|
})
|
||||||
|
ElMessage.success('配置已保存')
|
||||||
|
editVisible.value = false
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到该场景的版本管理页
|
||||||
|
function goVersions(sc: ScenarioConfig): void {
|
||||||
|
router.push({ name: 'AutomationVersions', query: { scenario: sc.scenario_key } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.automation-config {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.page-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 6px 0 16px;
|
||||||
|
}
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.scenario-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sc-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.sc-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.sc-key {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.sc-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 10px 0;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.sc-meta {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.sc-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<!--
|
||||||
|
=============================================================================
|
||||||
|
企微IT智能服务台 — 自动化指标看板(管理后台,扩展阶段4)
|
||||||
|
=============================================================================
|
||||||
|
说明:展示阶段5 自动化闭环的运营指标:会话量、解决率、转人工、异常、
|
||||||
|
自动执行动作数、需审批动作数,以及按场景分布。
|
||||||
|
- 复用现有 StatCard 组件(深色统计卡片)
|
||||||
|
- 风格:Element Plus + Tailwind(与 Dashboard.vue 一致)
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="auto-metrics">
|
||||||
|
<div class="page-title">自动化运营指标</div>
|
||||||
|
<div class="page-desc">阶段5 自动化闭环核心指标看板,数据实时反映自动化处置成效。</div>
|
||||||
|
|
||||||
|
<div v-if="!store.metrics" class="loading-state">
|
||||||
|
<el-skeleton :rows="6" animated />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 核心指标卡片 -->
|
||||||
|
<div class="stat-grid">
|
||||||
|
<StatCard
|
||||||
|
label="自动化会话总数"
|
||||||
|
:value="m.total_sessions"
|
||||||
|
:subtitle="resolveRate"
|
||||||
|
value-color="var(--accent)"
|
||||||
|
/>
|
||||||
|
<StatCard label="已解决" :value="m.resolved_sessions" value-color="var(--success)" />
|
||||||
|
<StatCard label="转人工" :value="m.handoff_sessions" value-color="var(--warning)" />
|
||||||
|
<StatCard label="异常会话" :value="m.error_sessions" value-color="var(--danger)" />
|
||||||
|
<StatCard label="自动执行动作" :value="m.auto_executed_actions" />
|
||||||
|
<StatCard label="需审批动作" :value="m.approval_required_actions" value-color="var(--warning)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 按场景分布 -->
|
||||||
|
<el-card class="breakdown" shadow="never">
|
||||||
|
<template #header>按场景分布(会话数)</template>
|
||||||
|
<el-table :data="scenarioRows" stripe>
|
||||||
|
<el-table-column prop="scenario" label="场景" />
|
||||||
|
<el-table-column prop="count" label="会话数" width="140" />
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import StatCard from '@/components/StatCard.vue'
|
||||||
|
import { useAutomationStore } from '@/stores/automation'
|
||||||
|
|
||||||
|
const store = useAutomationStore()
|
||||||
|
const m = computed(() => store.metrics as NonNullable<typeof store.metrics>)
|
||||||
|
|
||||||
|
// 解决率(核心健康度指标)
|
||||||
|
const resolveRate = computed(() => {
|
||||||
|
if (!m.value || m.value.total_sessions === 0) return '解决率 -'
|
||||||
|
const rate = Math.round((m.value.resolved_sessions / m.value.total_sessions) * 100)
|
||||||
|
return `解决率 ${rate}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按场景分布行
|
||||||
|
const scenarioRows = computed(() => {
|
||||||
|
const by = m.value?.by_scenario || {}
|
||||||
|
return Object.entries(by).map(([scenario, count]) => ({ scenario, count }))
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.loadMetrics()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auto-metrics {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.page-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 6px 0 16px;
|
||||||
|
}
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.breakdown {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — 坐席前端开发版 Docker 镜像
|
||||||
|
# =============================================================================
|
||||||
|
# 说明:基于 node:20 开发模式,支持代码热更新(volume mount 源码)
|
||||||
|
# 用途:本地开发,代码修改自动生效
|
||||||
|
# =============================================================================
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# 安装 pnpm
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# 复制源码(后续通过 volume mount 更新)
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# 启动开发服务器(热更新)
|
||||||
|
CMD ["pnpm", "dev", "--host"]
|
||||||
Binary file not shown.
@@ -5,9 +5,11 @@
|
|||||||
<!-- 移动端视口设置 -->
|
<!-- 移动端视口设置 -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!-- CSP 安全策略 -->
|
<!-- CSP 安全策略 -->
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 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://*;" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 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 ws://localhost:8000 ws://127.0.0.1:8000;" />
|
||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<title>智能IT支持服务台 - 坐席工作台</title>
|
<title>智能IT支持服务台 - 坐席工作台</title>
|
||||||
|
<!-- 企微 JS-SDK (用于企微客户端检测和快捷登录) -->
|
||||||
|
<script src="https://res.wx.qq.com/wwopen/js/jwxwork-1.0.0.js"></script>
|
||||||
<!-- ElementPlus 图标 -->
|
<!-- ElementPlus 图标 -->
|
||||||
<link rel="icon" type="image/svg+xml" href="/itagent/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/itagent/vite.svg" />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Generated
+1200
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,13 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "企微智能IT支持服务台 - 坐席工作台前端",
|
"description": "企微智能IT支持服务台 - 坐席工作台前端",
|
||||||
"engines": { "node": ">=20.0.0 <21.0.0", "pnpm": ">=9.0.0" },
|
"engines": {
|
||||||
|
"node": ">=20.0.0 <21.0.0",
|
||||||
|
"pnpm": ">=9.0.0"
|
||||||
|
},
|
||||||
"packageManager": "pnpm@9.15.0",
|
"packageManager": "pnpm@9.15.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"type-check": "vue-tsc --noEmit"
|
"type-check": "vue-tsc --noEmit"
|
||||||
@@ -14,9 +17,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.0",
|
"@element-plus/icons-vue": "^2.3.0",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
|
"cropperjs": "^2.1.1",
|
||||||
"element-plus": "^2.7.0",
|
"element-plus": "^2.7.0",
|
||||||
|
"fabric": "^7.4.0",
|
||||||
"html2canvas-pro": "^2.0.4",
|
"html2canvas-pro": "^2.0.4",
|
||||||
|
"js-web-screen-shot": "^2.0.2",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
|
"snapdom": "^0.1.2",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
"vue3-emoji-picker": "^1.1.8"
|
"vue3-emoji-picker": "^1.1.8"
|
||||||
|
|||||||
@@ -120,9 +120,8 @@ async function handleAuthExpired(source: 'http401' | 'biz1002'): Promise<void> {
|
|||||||
// 第二步:刷新失败,清除凭证并跳转登录
|
// 第二步:刷新失败,清除凭证并跳转登录
|
||||||
console.warn('[API] Token 刷新失败,清除凭证并跳转登录')
|
console.warn('[API] Token 刷新失败,清除凭证并跳转登录')
|
||||||
|
|
||||||
// 清除本地 token
|
// 清除本地 token(C8:移除 portal_token 遗留)
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
localStorage.removeItem('portal_token')
|
|
||||||
|
|
||||||
// 跳转登录页
|
// 跳转登录页
|
||||||
ElMessage.warning('登录已过期,请重新登录')
|
ElMessage.warning('登录已过期,请重新登录')
|
||||||
|
|||||||
@@ -82,12 +82,12 @@ export function useWebSocket() {
|
|||||||
let agentId = agentStore.userId
|
let agentId = agentStore.userId
|
||||||
let token = agentStore.token
|
let token = agentStore.token
|
||||||
|
|
||||||
// 如果 store 中没有,从 localStorage 读取(解决 QR 码登录时 store 未及时刷新的问题)
|
// 如果 store 中没有,从 localStorage 读取(C8:移除 portal_token)
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
agentId = localStorage.getItem('agent_user_id') || ''
|
agentId = localStorage.getItem('agent_user_id') || ''
|
||||||
}
|
}
|
||||||
if (!token) {
|
if (!token) {
|
||||||
token = localStorage.getItem('agent_token') || localStorage.getItem('portal_token') || ''
|
token = localStorage.getItem('agent_token') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调试日志
|
// 调试日志
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ const routes = [
|
|||||||
// 这里依靠守卫检查 token,但不强制 MFA 已绑定(否则永远进不去)
|
// 这里依靠守卫检查 token,但不强制 MFA 已绑定(否则永远进不去)
|
||||||
meta: { title: '绑定 MFA', requiresAuth: true },
|
meta: { title: '绑定 MFA', requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
// 阶段5 自动化闭环 — 坐席端会话工作台
|
||||||
|
{
|
||||||
|
path: '/automation/:id',
|
||||||
|
name: 'AutomationSession',
|
||||||
|
component: () => import('@/views/automation/SessionWorkbench.vue'),
|
||||||
|
meta: { title: '自动化会话', requiresAuth: true },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@@ -73,15 +80,13 @@ router.beforeEach((to, _from, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Portal Token 传递:从 URL 参数 ?token=xxx 读取并保存到 localStorage
|
// Token 传递:从 URL 参数 ?token=xxx 读取并保存到 localStorage(C8:移除 portal_token)
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const urlToken = urlParams.get('token')
|
const urlToken = urlParams.get('token')
|
||||||
if (urlToken) {
|
if (urlToken) {
|
||||||
// 保存 token 到坐席端 localStorage key
|
// 保存 token 到坐席端 localStorage
|
||||||
localStorage.setItem('agent_token', urlToken)
|
localStorage.setItem('agent_token', urlToken)
|
||||||
// 同时保存到 portal_token key(方便跨端共享)
|
|
||||||
localStorage.setItem('portal_token', urlToken)
|
|
||||||
// 清除 URL 参数,避免刷新页面重复读取
|
// 清除 URL 参数,避免刷新页面重复读取
|
||||||
const cleanUrl = window.location.pathname
|
const cleanUrl = window.location.pathname
|
||||||
window.history.replaceState({}, '', cleanUrl)
|
window.history.replaceState({}, '', cleanUrl)
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ import router from '@/router'
|
|||||||
import { useWebSocket } from '@/composables/useWebSocket'
|
import { useWebSocket } from '@/composables/useWebSocket'
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// Token 存储 key
|
// Token 存储 key(C8:移除 portal_token)
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
const TOKEN_KEY = 'agent_token'
|
const TOKEN_KEY = 'agent_token'
|
||||||
const PORTAL_TOKEN_KEY = 'portal_token'
|
|
||||||
const AGENT_USER_ID_KEY = 'agent_user_id'
|
const AGENT_USER_ID_KEY = 'agent_user_id'
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@@ -35,8 +34,8 @@ export const useAgentStore = defineStore('agent', () => {
|
|||||||
/** 当前登录的坐席信息 */
|
/** 当前登录的坐席信息 */
|
||||||
const agentInfo = ref<Agent | null>(null)
|
const agentInfo = ref<Agent | null>(null)
|
||||||
|
|
||||||
/** 认证 token — 优先从 agent_token 读取,降级读取 portal_token */
|
/** 认证 token — 从 agent_token 读取(C8:移除 portal_token) */
|
||||||
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY) || localStorage.getItem(PORTAL_TOKEN_KEY))
|
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
|
||||||
|
|
||||||
/** 坐席用户ID */
|
/** 坐席用户ID */
|
||||||
const agentUserId = ref<string | null>(localStorage.getItem(AGENT_USER_ID_KEY))
|
const agentUserId = ref<string | null>(localStorage.getItem(AGENT_USER_ID_KEY))
|
||||||
|
|||||||
@@ -21,14 +21,10 @@ import {
|
|||||||
} from '@/api/automation'
|
} from '@/api/automation'
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// WebSocket 辅助
|
// WebSocket 辅助(C8:移除 portal_token)
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
function getAgentToken(): string {
|
function getAgentToken(): string {
|
||||||
return (
|
return localStorage.getItem('agent_token') || ''
|
||||||
localStorage.getItem('agent_token') ||
|
|
||||||
localStorage.getItem('portal_token') ||
|
|
||||||
''
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWsUrl(sessionId: string): string {
|
function buildWsUrl(sessionId: string): string {
|
||||||
|
|||||||
@@ -187,12 +187,11 @@ function onRightResizeEnd(): void {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 修复 v0.5.1: 企微点坐席直接打开 /itagent/ 时,URL 没 ?token=
|
// 修复 v0.5.1: 企微点坐席直接打开 /itagent/ 时,URL 没 ?token=
|
||||||
// 路由守卫虽然会跳到 /itportal/,但在这之前 axios 已经发了请求 → 弹 401
|
// 路由守卫虽然会跳到 /login,但在这之前 axios 已经发了请求 → 弹 401
|
||||||
// 这里在 onMounted 第一行主动检查 token,没 token 立刻跳 portal,避免 401 弹错
|
// 这里在 onMounted 第一行主动检查 token,没 token 立刻跳登录页,避免 401 弹错(C8:移除 portal_token)
|
||||||
const hasAgentToken = localStorage.getItem('agent_token')
|
const hasAgentToken = localStorage.getItem('agent_token')
|
||||||
const hasPortalToken = localStorage.getItem('portal_token')
|
if (!hasAgentToken) {
|
||||||
if (!hasAgentToken && !hasPortalToken) {
|
window.location.href = '/login'
|
||||||
window.location.href = '/itportal/'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
<!-- =============================================================================
|
||||||
|
// 企微IT智能服务台 — 阶段5 自动化会话工作台(坐席端)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:坐席查看某个自动化会话的实时进展,对待审批高危/写动作进行
|
||||||
|
// 通过/驳回,并可对会话执行「转人工接管」。
|
||||||
|
// 复用已落地的 @/stores/automation(含专用 WebSocket 实时推送)。
|
||||||
|
// 约定:与 ActionApprovalCard / TakeoverPanel 配套,使用 Element Plus。
|
||||||
|
// ============================================================================= -->
|
||||||
|
<template>
|
||||||
|
<div class="session-workbench">
|
||||||
|
<!-- 顶部栏:返回 / 标题 / 实时连接状态 -->
|
||||||
|
<div class="wb-header">
|
||||||
|
<el-button text :icon="ArrowLeft" @click="goBack">返回</el-button>
|
||||||
|
<div class="wb-title">
|
||||||
|
<span class="wb-title-text">{{ session?.title || '自动化会话' }}</span>
|
||||||
|
<el-tag :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="wb-ws" :class="{ connected: store.wsConnected }">
|
||||||
|
<span class="dot" />
|
||||||
|
{{ store.wsConnected ? '实时连接中' : '未连接' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已解决横幅 -->
|
||||||
|
<el-alert
|
||||||
|
v-if="session?.status === 'resolved'"
|
||||||
|
type="success"
|
||||||
|
:closable="false"
|
||||||
|
title="会话已自动解决并关闭"
|
||||||
|
class="wb-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 加载态 -->
|
||||||
|
<div v-if="store.loading" class="wb-loading">
|
||||||
|
<el-skeleton :rows="8" animated />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="session">
|
||||||
|
<!-- 会话概览 -->
|
||||||
|
<el-card class="wb-card" shadow="never">
|
||||||
|
<div class="overview">
|
||||||
|
<div class="ov-item"><span class="ov-label">场景</span><span>{{ session.scenario_key || '-' }}</span></div>
|
||||||
|
<div class="ov-item"><span class="ov-label">模式</span><span>{{ modeLabel }}</span></div>
|
||||||
|
<div class="ov-item"><span class="ov-label">置信度</span><span>{{ Math.round((session.confidence || 0) * 100) }}%</span></div>
|
||||||
|
<div class="ov-item"><span class="ov-label">自动关单</span><span>{{ autoCloseText }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="session.intent" class="intent">意图:{{ intentText }}</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 处置进展(时间线) -->
|
||||||
|
<el-card class="wb-card" shadow="never">
|
||||||
|
<template #header>处置进展</template>
|
||||||
|
<el-timeline>
|
||||||
|
<el-timeline-item
|
||||||
|
v-for="act in session.actions"
|
||||||
|
:key="act.id"
|
||||||
|
:type="timelineType(act)"
|
||||||
|
:hollow="act.status === 'pending'"
|
||||||
|
>
|
||||||
|
<div class="tl-title">
|
||||||
|
<span>{{ act.title }}</span>
|
||||||
|
<el-tag size="small" :type="riskTagType(act)">{{ riskLabel(act) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="tl-desc">{{ act.description }}</div>
|
||||||
|
<div class="tl-status">状态:{{ actionStatusLabel(act) }}</div>
|
||||||
|
<pre v-if="act.result" class="tl-result">{{ prettyJson(act.result) }}</pre>
|
||||||
|
</el-timeline-item>
|
||||||
|
<el-timeline-item v-if="!session.actions.length" type="info">暂无动作</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 待坐席审批:高危/写操作动作 -->
|
||||||
|
<el-card v-if="pendingActions.length" class="wb-card" shadow="never">
|
||||||
|
<template #header>待坐席审批(高危 / 写操作)</template>
|
||||||
|
<ActionApprovalCard
|
||||||
|
v-for="act in pendingActions"
|
||||||
|
:key="act.id"
|
||||||
|
:action="act"
|
||||||
|
:ticket="session.approval"
|
||||||
|
@approve="onApprove"
|
||||||
|
@reject="onReject"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 转人工接管 -->
|
||||||
|
<TakeoverPanel
|
||||||
|
:session-id="sessionId"
|
||||||
|
:default-agent-id="defaultAgentId"
|
||||||
|
@takeover="onTakeover"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-empty v-else description="会话不存在或已结束" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAutomationStore } from '@/stores/automation'
|
||||||
|
import { useAgentStore } from '@/stores/agent'
|
||||||
|
import ActionApprovalCard from '@/components/automation/ActionApprovalCard.vue'
|
||||||
|
import TakeoverPanel from '@/components/automation/TakeoverPanel.vue'
|
||||||
|
import type { AutomationAction } from '@/api/automation'
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 路由 / Store
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
const props = defineProps<{ sessionId?: string }>()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useAutomationStore()
|
||||||
|
|
||||||
|
const sessionId = computed(() => props.sessionId || (route.params.id as string))
|
||||||
|
const session = computed(() => store.currentSession)
|
||||||
|
// 接管默认坐席 ID:优先用坐席 store,降级读 localStorage(避免额外耦合)
|
||||||
|
const defaultAgentId = computed(
|
||||||
|
() => useAgentStore().agentUserId || localStorage.getItem('agent_user_id') || '',
|
||||||
|
)
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 待审批动作筛选:高危/写操作且尚未结案
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
const pendingActions = computed(() =>
|
||||||
|
(session.value?.actions || []).filter((a) => needsApproval(a)),
|
||||||
|
)
|
||||||
|
|
||||||
|
function needsApproval(a: AutomationAction): boolean {
|
||||||
|
if (['done', 'executed', 'approved', 'rejected', 'skipped', 'failed'].includes(a.status)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
a.risk_level === 'high' ||
|
||||||
|
a.risk_level === 'write' ||
|
||||||
|
a.status === 'pending' ||
|
||||||
|
a.status === 'awaiting_approval'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 生命周期:挂载即拉详情并连接专用 WebSocket,卸载断开
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!sessionId.value) return
|
||||||
|
await store.fetchSession(sessionId.value)
|
||||||
|
store.connectWs(sessionId.value)
|
||||||
|
})
|
||||||
|
onUnmounted(() => store.disconnectWs())
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 事件处理:审批 / 接管(均走已落地 store 的会话级接口)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
async function onApprove(p: { actionId: string; note?: string }): Promise<void> {
|
||||||
|
if (!sessionId.value) return
|
||||||
|
try {
|
||||||
|
// 坐席端审批:沿用已落地 store.approve -> POST /sessions/{id}/approve
|
||||||
|
await store.approve(sessionId.value, 'approve', p.note)
|
||||||
|
await store.fetchSession(sessionId.value)
|
||||||
|
ElMessage.success('已通过并执行')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('审批失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReject(p: { actionId: string; note?: string }): Promise<void> {
|
||||||
|
if (!sessionId.value) return
|
||||||
|
try {
|
||||||
|
await store.approve(sessionId.value, 'reject', p.note)
|
||||||
|
await store.fetchSession(sessionId.value)
|
||||||
|
ElMessage.success('已驳回并转人工')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('操作失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTakeover(p: { agentId: string; note?: string }): Promise<void> {
|
||||||
|
if (!sessionId.value) return
|
||||||
|
try {
|
||||||
|
await store.takeover(sessionId.value, p.agentId, p.note)
|
||||||
|
await store.fetchSession(sessionId.value)
|
||||||
|
ElMessage.success('已接管,自动化终止')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('接管失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack(): void {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 展示辅助
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
const statusLabel = computed(() => statusText(session.value?.status || ''))
|
||||||
|
const modeLabel = computed(() => {
|
||||||
|
switch (session.value?.mode) {
|
||||||
|
case 'auto':
|
||||||
|
return '全自动'
|
||||||
|
case 'assist':
|
||||||
|
return '辅助坐席'
|
||||||
|
case 'supervised':
|
||||||
|
return '监督模式'
|
||||||
|
default:
|
||||||
|
return session.value?.mode || '-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const intentText = computed(() => prettyJson(session.value?.intent))
|
||||||
|
const autoCloseText = computed(() => {
|
||||||
|
const t = session.value?.auto_close_at
|
||||||
|
return t ? formatTime(t) : '无'
|
||||||
|
})
|
||||||
|
|
||||||
|
function statusText(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'created':
|
||||||
|
return '已创建'
|
||||||
|
case 'running':
|
||||||
|
return '执行中'
|
||||||
|
case 'paused':
|
||||||
|
return '已暂停'
|
||||||
|
case 'resolved':
|
||||||
|
return '已解决'
|
||||||
|
case 'handoff':
|
||||||
|
return '已转人工'
|
||||||
|
case 'error':
|
||||||
|
return '异常'
|
||||||
|
case 'closed':
|
||||||
|
return '已关闭'
|
||||||
|
default:
|
||||||
|
return s || '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusTagType = computed<'success' | 'warning' | 'danger' | 'info' | 'primary'>(() => {
|
||||||
|
switch (session.value?.status) {
|
||||||
|
case 'resolved':
|
||||||
|
return 'success'
|
||||||
|
case 'running':
|
||||||
|
return 'primary'
|
||||||
|
case 'paused':
|
||||||
|
case 'handoff':
|
||||||
|
return 'warning'
|
||||||
|
case 'error':
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function riskLabel(a: AutomationAction): string {
|
||||||
|
switch (a.risk_level) {
|
||||||
|
case 'read':
|
||||||
|
return '只读'
|
||||||
|
case 'low':
|
||||||
|
return '低风险'
|
||||||
|
case 'write':
|
||||||
|
return '写操作'
|
||||||
|
case 'high':
|
||||||
|
return '高危'
|
||||||
|
default:
|
||||||
|
return a.risk_level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskTagType(a: AutomationAction): 'info' | 'success' | 'warning' | 'danger' {
|
||||||
|
switch (a.risk_level) {
|
||||||
|
case 'read':
|
||||||
|
return 'info'
|
||||||
|
case 'low':
|
||||||
|
return 'success'
|
||||||
|
case 'write':
|
||||||
|
return 'warning'
|
||||||
|
case 'high':
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionStatusLabel(a: AutomationAction): string {
|
||||||
|
switch (a.status) {
|
||||||
|
case 'pending':
|
||||||
|
return '待审批'
|
||||||
|
case 'awaiting_approval':
|
||||||
|
return '待审批'
|
||||||
|
case 'approved':
|
||||||
|
return '已通过'
|
||||||
|
case 'rejected':
|
||||||
|
return '已驳回'
|
||||||
|
case 'running':
|
||||||
|
return '执行中'
|
||||||
|
case 'done':
|
||||||
|
case 'executed':
|
||||||
|
return '已完成'
|
||||||
|
case 'failed':
|
||||||
|
return '失败'
|
||||||
|
case 'skipped':
|
||||||
|
return '已跳过'
|
||||||
|
default:
|
||||||
|
return a.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineType(a: AutomationAction): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
|
||||||
|
if (a.status === 'done' || a.status === 'executed' || a.status === 'approved') return 'success'
|
||||||
|
if (a.status === 'failed') return 'danger'
|
||||||
|
if (a.status === 'rejected') return 'warning'
|
||||||
|
if (a.status === 'pending' || a.status === 'awaiting_approval') return 'warning'
|
||||||
|
return 'primary'
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyJson(v: unknown): string {
|
||||||
|
try {
|
||||||
|
return typeof v === 'string' ? v : JSON.stringify(v, null, 2)
|
||||||
|
} catch {
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(t).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
} catch {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.session-workbench {
|
||||||
|
padding: 16px;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.wb-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.wb-title {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.wb-title-text {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.wb-ws {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.wb-ws .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--el-color-info);
|
||||||
|
}
|
||||||
|
.wb-ws.connected .dot {
|
||||||
|
background: var(--el-color-success);
|
||||||
|
}
|
||||||
|
.wb-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.wb-card {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.overview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.ov-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.ov-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.intent {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.tl-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tl-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.tl-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.tl-result {
|
||||||
|
margin-top: 6px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -20,8 +20,16 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
// 开发服务器端口(避免和H5前端冲突)
|
// 开发服务器端口(避免和H5前端冲突)
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
// 端口被占用时自动尝试下一个端口
|
||||||
|
strictPort: false,
|
||||||
// 自动打开浏览器
|
// 自动打开浏览器
|
||||||
open: true,
|
open: false,
|
||||||
|
// 禁用热更新,改用页面刷新(更稳定)
|
||||||
|
hmr: false,
|
||||||
|
// 增加超时时间
|
||||||
|
timeout: 30000,
|
||||||
|
// 最大并发请求数
|
||||||
|
maxConcurrency: 200,
|
||||||
// API 代理:将 /api 请求转发到后端,解决开发环境跨域问题
|
// API 代理:将 /api 请求转发到后端,解决开发环境跨域问题
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 企微IT智能服务台 — H5前端开发版 Docker 镜像
|
||||||
|
# =============================================================================
|
||||||
|
# 说明:基于 node:20 开发模式,支持代码热更新(volume mount 源码)
|
||||||
|
# 用途:本地开发,代码修改自动生效
|
||||||
|
# =============================================================================
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# 安装 pnpm
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# 复制源码(后续通过 volume mount 更新)
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 暴露端口
|
||||||
|
EXPOSE 5174
|
||||||
|
|
||||||
|
# 启动开发服务器(热更新)
|
||||||
|
CMD ["pnpm", "dev", "--host"]
|
||||||
Vendored
+5
@@ -7,18 +7,21 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
ActionConfirmDialog: typeof import('./src/components/ActionConfirmDialog.vue')['default']
|
||||||
AiHelperPanel: typeof import('./src/components/assistant/AiHelperPanel.vue')['default']
|
AiHelperPanel: typeof import('./src/components/assistant/AiHelperPanel.vue')['default']
|
||||||
ApprovalCardModal: typeof import('./src/components/chat/ApprovalCardModal.vue')['default']
|
ApprovalCardModal: typeof import('./src/components/chat/ApprovalCardModal.vue')['default']
|
||||||
ApprovalLinks: typeof import('./src/components/assistant/ApprovalLinks.vue')['default']
|
ApprovalLinks: typeof import('./src/components/assistant/ApprovalLinks.vue')['default']
|
||||||
CallAgentModal: typeof import('./src/components/chat/CallAgentModal.vue')['default']
|
CallAgentModal: typeof import('./src/components/chat/CallAgentModal.vue')['default']
|
||||||
ChatPanel: typeof import('./src/components/chat/ChatPanel.vue')['default']
|
ChatPanel: typeof import('./src/components/chat/ChatPanel.vue')['default']
|
||||||
ComingSoon: typeof import('./src/components/assistant/ComingSoon.vue')['default']
|
ComingSoon: typeof import('./src/components/assistant/ComingSoon.vue')['default']
|
||||||
|
EvaluationDialog: typeof import('./src/components/chat/EvaluationDialog.vue')['default']
|
||||||
InputBar: typeof import('./src/components/chat/InputBar.vue')['default']
|
InputBar: typeof import('./src/components/chat/InputBar.vue')['default']
|
||||||
InputBox: typeof import('./src/components/chat/InputBox.vue')['default']
|
InputBox: typeof import('./src/components/chat/InputBox.vue')['default']
|
||||||
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
|
MessageBubble: typeof import('./src/components/chat/MessageBubble.vue')['default']
|
||||||
MessageItem: typeof import('./src/components/chat/MessageItem.vue')['default']
|
MessageItem: typeof import('./src/components/chat/MessageItem.vue')['default']
|
||||||
MessageList: typeof import('./src/components/chat/MessageList.vue')['default']
|
MessageList: typeof import('./src/components/chat/MessageList.vue')['default']
|
||||||
ParticipantList: typeof import('./src/components/chat/ParticipantList.vue')['default']
|
ParticipantList: typeof import('./src/components/chat/ParticipantList.vue')['default']
|
||||||
|
ResolveFeedback: typeof import('./src/components/ResolveFeedback.vue')['default']
|
||||||
RightPanel: typeof import('./src/components/assistant/RightPanel.vue')['default']
|
RightPanel: typeof import('./src/components/assistant/RightPanel.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
@@ -29,10 +32,12 @@ declare module 'vue' {
|
|||||||
TroubleshootProgress: typeof import('./src/components/chat/TroubleshootProgress.vue')['default']
|
TroubleshootProgress: typeof import('./src/components/chat/TroubleshootProgress.vue')['default']
|
||||||
VanButton: typeof import('vant/es')['Button']
|
VanButton: typeof import('vant/es')['Button']
|
||||||
VanConfigProvider: typeof import('vant/es')['ConfigProvider']
|
VanConfigProvider: typeof import('vant/es')['ConfigProvider']
|
||||||
|
VanDialog: typeof import('vant/es')['Dialog']
|
||||||
VanEmpty: typeof import('vant/es')['Empty']
|
VanEmpty: typeof import('vant/es')['Empty']
|
||||||
VanField: typeof import('vant/es')['Field']
|
VanField: typeof import('vant/es')['Field']
|
||||||
VanIcon: typeof import('vant/es')['Icon']
|
VanIcon: typeof import('vant/es')['Icon']
|
||||||
VanLoading: typeof import('vant/es')['Loading']
|
VanLoading: typeof import('vant/es')['Loading']
|
||||||
VanPopup: typeof import('vant/es')['Popup']
|
VanPopup: typeof import('vant/es')['Popup']
|
||||||
|
VanRate: typeof import('vant/es')['Rate']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<!-- 移动端视口设置(适配企微 WebView) -->
|
<!-- 移动端视口设置(适配企微 WebView) -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<!-- CSP 安全策略 -->
|
<!-- CSP 安全策略 -->
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 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://*;" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-eval' 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;" />
|
||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<title>智能IT支持服务台</title>
|
<title>智能IT支持服务台</title>
|
||||||
<!-- 首屏骨架屏样式 v0.5.2 强化版 -->
|
<!-- 首屏骨架屏样式 v0.5.2 强化版 -->
|
||||||
|
|||||||
Generated
+1140
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,10 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "企微智能IT支持服务台 - H5用户端前端",
|
"description": "企微智能IT支持服务台 - H5用户端前端",
|
||||||
"engines": { "node": ">=20.0.0 <21.0.0", "pnpm": ">=9.0.0" },
|
"engines": {
|
||||||
|
"node": ">=20.0.0 <21.0.0",
|
||||||
|
"pnpm": ">=9.0.0"
|
||||||
|
},
|
||||||
"packageManager": "pnpm@9.15.0",
|
"packageManager": "pnpm@9.15.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -14,6 +17,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
|
"cropperjs": "^2.1.1",
|
||||||
|
"fabric": "^7.4.0",
|
||||||
"html2canvas-pro": "^2.0.4",
|
"html2canvas-pro": "^2.0.4",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"vant": "^4.8.0",
|
"vant": "^4.8.0",
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 企微IT智能服务台 — 阶段5 自动化闭环 API 调用模块(H5 员工端)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:封装员工侧自动化会话相关 HTTP 请求。
|
||||||
|
// 约定:与坐席端一致,baseURL 已在 api/index.ts 配置为 /api,
|
||||||
|
// 这里只写相对路径 /itportal/automation/...;响应拦截器已解包为 data。
|
||||||
|
// 注意:本模块返回「内层 data」(与 h5 其它 api 模块一致),调用方直接使用。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import apiClient from '@/api'
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 类型定义(与后端 Schema / 坐席端保持一致)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
export interface AutomationAction {
|
||||||
|
id: string
|
||||||
|
session_id: string
|
||||||
|
action_index: number
|
||||||
|
action_type: string
|
||||||
|
adapter: string
|
||||||
|
risk_level: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: string
|
||||||
|
payload: Record<string, any> | null
|
||||||
|
result: Record<string, any> | null
|
||||||
|
error: string | null
|
||||||
|
approved_by: string | null
|
||||||
|
approved_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalTicket {
|
||||||
|
id: string
|
||||||
|
action_id: string
|
||||||
|
session_id: string
|
||||||
|
approver_id: string | null
|
||||||
|
channel: string
|
||||||
|
status: string
|
||||||
|
reason: string | null
|
||||||
|
decision_note: string | null
|
||||||
|
decided_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationSession {
|
||||||
|
id: string
|
||||||
|
conversation_id: string | null
|
||||||
|
employee_id: string
|
||||||
|
agent_id: string | null
|
||||||
|
scenario_key: string | null
|
||||||
|
status: string
|
||||||
|
mode: string
|
||||||
|
confidence: number
|
||||||
|
title: string
|
||||||
|
intent: Record<string, any> | null
|
||||||
|
current_action_id: string | null
|
||||||
|
auto_close_at: string | null
|
||||||
|
resolved_at: string | null
|
||||||
|
closed_by: string | null
|
||||||
|
meta: Record<string, any> | null
|
||||||
|
actions: AutomationAction[]
|
||||||
|
approval: ApprovalTicket | null
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发起自动化会话请求体 */
|
||||||
|
export interface StartSessionPayload {
|
||||||
|
conversation_id?: string | null
|
||||||
|
employee_id: string
|
||||||
|
description: string
|
||||||
|
scenario_key?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 员工侧高危动作二次确认请求体 */
|
||||||
|
export interface ConfirmPayload {
|
||||||
|
confirmed: boolean
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记已解决 / 静默关单反馈请求体 */
|
||||||
|
export interface ResolvePayload {
|
||||||
|
satisfied?: boolean
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 员工端接口(路径与后端契约 §15.3 一致)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 发起自动化会话(员工侧触发) */
|
||||||
|
export async function startAutomationSession(
|
||||||
|
payload: StartSessionPayload,
|
||||||
|
): Promise<AutomationSession> {
|
||||||
|
const res = await apiClient.post('/itportal/automation/sessions/start', payload)
|
||||||
|
return res as AutomationSession
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话详情 */
|
||||||
|
export async function getAutomationSession(sessionId: string): Promise<AutomationSession> {
|
||||||
|
const res = await apiClient.get(`/itportal/automation/sessions/${sessionId}`)
|
||||||
|
return res as AutomationSession
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个动作详情(用于二次确认弹窗展示) */
|
||||||
|
export async function getAutomationAction(actionId: string): Promise<AutomationAction> {
|
||||||
|
const res = await apiClient.get(`/itportal/automation/actions/${actionId}`)
|
||||||
|
return res as AutomationAction
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 员工侧高危动作二次确认(P1:员工确认/取消执行) */
|
||||||
|
export async function confirmAutomationAction(
|
||||||
|
actionId: string,
|
||||||
|
payload: ConfirmPayload,
|
||||||
|
): Promise<AutomationAction> {
|
||||||
|
const res = await apiClient.post(`/itportal/automation/actions/${actionId}/confirm`, payload)
|
||||||
|
return res as AutomationAction
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记会话已解决 / 提交静默关单反馈 */
|
||||||
|
export async function resolveAutomationSession(
|
||||||
|
sessionId: string,
|
||||||
|
payload: ResolvePayload,
|
||||||
|
): Promise<AutomationSession> {
|
||||||
|
const res = await apiClient.post(`/itportal/automation/sessions/${sessionId}/resolved`, payload)
|
||||||
|
return res as AutomationSession
|
||||||
|
}
|
||||||
@@ -40,8 +40,8 @@ export interface ConversationInfo {
|
|||||||
employee_id: string
|
employee_id: string
|
||||||
/** 员工姓名(会话发起人) */
|
/** 员工姓名(会话发起人) */
|
||||||
employee_name: string
|
employee_name: string
|
||||||
/** 会话状态:waiting(排队中) / serving(服务中) / closed(已结单) */
|
/** 会话状态:waiting(排队中) / serving(服务中) / resolved(已结单) */
|
||||||
status: 'waiting' | 'serving' | 'closed'
|
status: 'waiting' | 'serving' | 'resolved'
|
||||||
/** 坐席 ID(未接入时为空) */
|
/** 坐席 ID(未接入时为空) */
|
||||||
agent_id: string
|
agent_id: string
|
||||||
/** 坐席名称(未接入时为空) */
|
/** 坐席名称(未接入时为空) */
|
||||||
@@ -131,6 +131,10 @@ export interface ShakeResponse {
|
|||||||
/** 会话状态:queued(排队中) / serving(服务中) / closed(已结单) */
|
/** 会话状态:queued(排队中) / serving(服务中) / closed(已结单) */
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
/** 分配结果:assigned(已分配坐席) / queued(排队中) / assign_failed(分配失败) */
|
||||||
|
assign_result?: string
|
||||||
|
/** 已分配的坐席ID(当 assign_result 为 assigned 时) */
|
||||||
|
assigned_agent_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 审批流程链接 */
|
/** 审批流程链接 */
|
||||||
@@ -243,8 +247,8 @@ function mapMessages(rawList: any[]): Message[] {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// API 方法
|
// API 方法
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// 注意:响应拦截器返回 response.data(即 {code, data, message} 包装对象)
|
// 注意:响应拦截器返回 response(即 {code, data, message} 包装对象)
|
||||||
// API 函数通过 await + response.data 取出业务数据(与原始工作代码一致)
|
// API 函数通过 await + response 取出业务数据(与原始工作代码一致)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -255,7 +259,7 @@ function mapMessages(rawList: any[]): Message[] {
|
|||||||
*/
|
*/
|
||||||
export async function getUser(): Promise<UserInfo> {
|
export async function getUser(): Promise<UserInfo> {
|
||||||
const response: any = await apiClient.get('/h5/user')
|
const response: any = await apiClient.get('/h5/user')
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -265,7 +269,7 @@ export async function getUser(): Promise<UserInfo> {
|
|||||||
*/
|
*/
|
||||||
export async function getCurrentConversation(): Promise<ConversationInfo | null> {
|
export async function getCurrentConversation(): Promise<ConversationInfo | null> {
|
||||||
const response: any = await apiClient.get('/h5/conversations/current')
|
const response: any = await apiClient.get('/h5/conversations/current')
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -281,14 +285,14 @@ export async function sendMessage(data: SendMessageRequest): Promise<SendMessage
|
|||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
})
|
})
|
||||||
// 注意:apiClient 拦截器返回的是 {code: 0, data: {...}, message: "success"} 包装对象,
|
// 注意:apiClient 拦截器返回的是 {code: 0, data: {...}, message: "success"} 包装对象,
|
||||||
// 需要通过 response.data 获取实际业务数据
|
// 需要通过 response 获取实际业务数据
|
||||||
// 修复字段映射:后端返回 id/sender_type,H5前端期望 message_id/message_type
|
// 修复字段映射:后端返回 id/sender_type,H5前端期望 message_id/message_type
|
||||||
return {
|
return {
|
||||||
user_message: mapMessage(response.data.user_message),
|
user_message: mapMessage(response.user_message),
|
||||||
ai_reply: response.data.ai_reply ? mapMessage(response.data.ai_reply) : response.data.ai_reply,
|
ai_reply: response.ai_reply ? mapMessage(response.ai_reply) : response.ai_reply,
|
||||||
is_guidance: response.data.is_guidance,
|
is_guidance: response.is_guidance,
|
||||||
ai_reply_count: response.data.ai_reply_count,
|
ai_reply_count: response.ai_reply_count,
|
||||||
can_call_agent: response.data.can_call_agent,
|
can_call_agent: response.can_call_agent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,13 +304,45 @@ export async function sendMessage(data: SendMessageRequest): Promise<SendMessage
|
|||||||
*/
|
*/
|
||||||
export async function pollMessages(params?: PollMessagesParams): Promise<Message[]> {
|
export async function pollMessages(params?: PollMessagesParams): Promise<Message[]> {
|
||||||
const response: any = await apiClient.get('/h5/conversations/current/messages/poll', { params })
|
const response: any = await apiClient.get('/h5/conversations/current/messages/poll', { params })
|
||||||
// response.data = { items: [...], has_more: bool }
|
// response = { items: [...], has_more: bool }
|
||||||
const data = response.data
|
const data = response
|
||||||
const rawItems = data?.items || data || []
|
const rawItems = data?.items || data || []
|
||||||
// 修复字段映射:后端返回 id/sender_type,H5前端期望 message_id/message_type
|
// 修复字段映射:后端返回 id/sender_type,H5前端期望 message_id/message_type
|
||||||
return mapMessages(rawItems)
|
return mapMessages(rawItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取消息列表请求参数 */
|
||||||
|
export interface GetMessagesParams {
|
||||||
|
/** 每页消息数量(默认50) */
|
||||||
|
limit?: number
|
||||||
|
/** 获取此消息ID之前的消息(向上翻页) */
|
||||||
|
before?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消息列表响应 */
|
||||||
|
export interface MessageListData {
|
||||||
|
items: Message[]
|
||||||
|
has_more: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取消息列表(历史消息)
|
||||||
|
* 获取当前会话的消息历史记录,支持分页向上翻页
|
||||||
|
* @param params 查询参数(limit 和 before)
|
||||||
|
* @returns 消息列表数据
|
||||||
|
*/
|
||||||
|
export async function getMessages(params?: GetMessagesParams): Promise<MessageListData> {
|
||||||
|
const response: any = await apiClient.get('/h5/conversations/current/messages', { params })
|
||||||
|
// response = { items: [...], has_more: bool }
|
||||||
|
const data = response
|
||||||
|
const rawItems = data?.items || []
|
||||||
|
// 修复字段映射:后端返回 id/sender_type,H5前端期望 message_id/message_type
|
||||||
|
return {
|
||||||
|
items: mapMessages(rawItems),
|
||||||
|
has_more: data?.has_more || false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 摇人 — 一键呼叫 IT 坐席
|
* 摇人 — 一键呼叫 IT 坐席
|
||||||
* 触发转人工流程,返回趣味话术和会话状态
|
* 触发转人工流程,返回趣味话术和会话状态
|
||||||
@@ -315,7 +351,7 @@ export async function pollMessages(params?: PollMessagesParams): Promise<Message
|
|||||||
*/
|
*/
|
||||||
export async function shake(data: ShakeRequest): Promise<ShakeResponse> {
|
export async function shake(data: ShakeRequest): Promise<ShakeResponse> {
|
||||||
const response: any = await apiClient.post('/h5/conversations/current/shake', data)
|
const response: any = await apiClient.post('/h5/conversations/current/shake', data)
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -325,8 +361,8 @@ export async function shake(data: ShakeRequest): Promise<ShakeResponse> {
|
|||||||
*/
|
*/
|
||||||
export async function getApprovalLinks(): Promise<ApprovalLink[]> {
|
export async function getApprovalLinks(): Promise<ApprovalLink[]> {
|
||||||
const response: any = await apiClient.get('/h5/approval-links')
|
const response: any = await apiClient.get('/h5/approval-links')
|
||||||
// response.data = { items: [...] } 或 [...]
|
// response = { items: [...] } 或 [...]
|
||||||
const data = response.data
|
const data = response
|
||||||
return (data?.items || data || []) as ApprovalLink[]
|
return (data?.items || data || []) as ApprovalLink[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +385,7 @@ export interface ApprovalKeyword {
|
|||||||
*/
|
*/
|
||||||
export async function getApprovalKeywords(): Promise<ApprovalKeyword[]> {
|
export async function getApprovalKeywords(): Promise<ApprovalKeyword[]> {
|
||||||
const response: any = await apiClient.get('/approval/keywords')
|
const response: any = await apiClient.get('/approval/keywords')
|
||||||
return response.data || []
|
return response || []
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -359,7 +395,7 @@ export async function getApprovalKeywords(): Promise<ApprovalKeyword[]> {
|
|||||||
*/
|
*/
|
||||||
export async function createApprovalJump(templateId: string): Promise<{ url: string; template_name: string }> {
|
export async function createApprovalJump(templateId: string): Promise<{ url: string; template_name: string }> {
|
||||||
const response: any = await apiClient.post('/approval/jump', { template_id: templateId })
|
const response: any = await apiClient.post('/approval/jump', { template_id: templateId })
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,8 +405,8 @@ export async function createApprovalJump(templateId: string): Promise<{ url: str
|
|||||||
*/
|
*/
|
||||||
export async function getSoftwareDownloads(): Promise<SoftwareDownload[]> {
|
export async function getSoftwareDownloads(): Promise<SoftwareDownload[]> {
|
||||||
const response: any = await apiClient.get('/h5/software-downloads')
|
const response: any = await apiClient.get('/h5/software-downloads')
|
||||||
// response.data = { items: [...] } 或 [...]
|
// response = { items: [...] } 或 [...]
|
||||||
const data = response.data
|
const data = response
|
||||||
return (data?.items || data || []) as SoftwareDownload[]
|
return (data?.items || data || []) as SoftwareDownload[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +430,7 @@ export async function joinConversation(
|
|||||||
const response: any = await apiClient.post(
|
const response: any = await apiClient.post(
|
||||||
`/h5/conversations/${conversationId}/join`
|
`/h5/conversations/${conversationId}/join`
|
||||||
)
|
)
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -410,7 +446,7 @@ export async function leaveAsParticipant(
|
|||||||
const response: any = await apiClient.post(
|
const response: any = await apiClient.post(
|
||||||
`/h5/conversations/${conversationId}/leave-participant`
|
`/h5/conversations/${conversationId}/leave-participant`
|
||||||
)
|
)
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -426,6 +462,95 @@ export async function getParticipants(
|
|||||||
const response: any = await apiClient.get(
|
const response: any = await apiClient.get(
|
||||||
`/h5/conversations/${conversationId}/participants`
|
`/h5/conversations/${conversationId}/participants`
|
||||||
)
|
)
|
||||||
const data = response.data
|
const data = response
|
||||||
return data?.participants || []
|
return data?.participants || []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 摇人按钮 - 呼叫坐席
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
export interface CallAgentResponse {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data?: {
|
||||||
|
conversation_id: string
|
||||||
|
status: 'waiting' | 'serving'
|
||||||
|
queue_position?: number
|
||||||
|
estimated_wait_seconds?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 呼叫坐席(摇人按钮)
|
||||||
|
* 用户点击摇人按钮后,触发转人工流程
|
||||||
|
*/
|
||||||
|
export async function callAgent(): Promise<CallAgentResponse> {
|
||||||
|
const response: any = await apiClient.post('/h5/conversations/current/call-agent', {})
|
||||||
|
return response || { code: -1, message: '网络错误' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 满意度评价 API (P1-25)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 评价提交请求 */
|
||||||
|
export interface EvaluationSubmitRequest {
|
||||||
|
/** 星级评分(1-5) */
|
||||||
|
star_rating: number
|
||||||
|
/** 表情评价(satisfied/neutral/dissatisfied) */
|
||||||
|
emoji: string
|
||||||
|
/** 文字反馈(可选) */
|
||||||
|
feedback_text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 评价记录 */
|
||||||
|
export interface EvaluationRecord {
|
||||||
|
/** 评价ID */
|
||||||
|
id: string
|
||||||
|
/** 会话ID */
|
||||||
|
conversation_id: string
|
||||||
|
/** 员工ID */
|
||||||
|
employee_id: string
|
||||||
|
/** 员工姓名 */
|
||||||
|
employee_name: string
|
||||||
|
/** 星级评分 */
|
||||||
|
star_rating: number
|
||||||
|
/** 表情评价 */
|
||||||
|
emoji: string
|
||||||
|
/** 文字反馈 */
|
||||||
|
feedback_text?: string
|
||||||
|
/** 评价时间 */
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交满意度评价
|
||||||
|
* 员工对已结束的会话进行满意度评价
|
||||||
|
* @param conversationId - 会话ID
|
||||||
|
* @param data - 评价内容
|
||||||
|
* @returns 评价记录
|
||||||
|
*/
|
||||||
|
export async function submitEvaluation(
|
||||||
|
conversationId: string,
|
||||||
|
data: EvaluationSubmitRequest
|
||||||
|
): Promise<EvaluationRecord> {
|
||||||
|
const response: any = await apiClient.post(
|
||||||
|
`/conversation/${conversationId}/evaluate`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话的评价记录
|
||||||
|
* @param conversationId - 会话ID
|
||||||
|
* @returns 评价记录(如果已评价)
|
||||||
|
*/
|
||||||
|
export async function getEvaluation(
|
||||||
|
conversationId: string
|
||||||
|
): Promise<EvaluationRecord | null> {
|
||||||
|
const response: any = await apiClient.get(
|
||||||
|
`/conversation/${conversationId}/evaluation`
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ export interface OAuthAuthorizeResponse {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// API 方法
|
// API 方法
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// 注意:响应拦截器返回 response.data(即 {code, data, message} 包装对象)
|
// 注意:响应拦截器返回 response(即 {code, data, message} 包装对象)
|
||||||
// API 函数通过 await + response.data 取出业务数据(与原始工作代码一致)
|
// API 函数通过 await + response 取出业务数据(与原始工作代码一致)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,8 +83,8 @@ export interface OAuthAuthorizeResponse {
|
|||||||
export async function oauthCallback(data: OAuthCallbackRequest): Promise<OAuthCallbackResponse> {
|
export async function oauthCallback(data: OAuthCallbackRequest): Promise<OAuthCallbackResponse> {
|
||||||
const response: any = await apiClient.post('/h5/oauth/callback', data)
|
const response: any = await apiClient.post('/h5/oauth/callback', data)
|
||||||
// response = {code:0, data: {token:"...", ...}, message:"success"}(拦截器返回值)
|
// response = {code:0, data: {token:"...", ...}, message:"success"}(拦截器返回值)
|
||||||
// response.data = 业务数据 {token:"...", employee_id:"...", ...}
|
// response = 业务数据 {token:"...", employee_id:"...", ...}
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,7 +98,7 @@ export async function oauthCallback(data: OAuthCallbackRequest): Promise<OAuthCa
|
|||||||
*/
|
*/
|
||||||
export async function mockLogin(data: { employee_id: string; employee_name?: string }): Promise<OAuthCallbackResponse> {
|
export async function mockLogin(data: { employee_id: string; employee_name?: string }): Promise<OAuthCallbackResponse> {
|
||||||
const response: any = await apiClient.post('/h5/mock-login', data)
|
const response: any = await apiClient.post('/h5/mock-login', data)
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,7 +109,7 @@ export async function mockLogin(data: { employee_id: string; employee_name?: str
|
|||||||
*/
|
*/
|
||||||
export async function getEmployeeInfo(): Promise<EmployeeInfo> {
|
export async function getEmployeeInfo(): Promise<EmployeeInfo> {
|
||||||
const response: any = await apiClient.get('/h5/me')
|
const response: any = await apiClient.get('/h5/me')
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,5 +123,45 @@ export async function getOAuthAuthorizeUrl(): Promise<OAuthAuthorizeResponse> {
|
|||||||
const response: any = await apiClient.get('/h5/oauth/authorize', {
|
const response: any = await apiClient.get('/h5/oauth/authorize', {
|
||||||
params: { redirect_uri: redirectUri },
|
params: { redirect_uri: redirectUri },
|
||||||
})
|
})
|
||||||
return response.data
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 密码管理
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 修改密码请求参数 */
|
||||||
|
export interface ChangePasswordRequest {
|
||||||
|
/** 旧密码 */
|
||||||
|
old_password: string
|
||||||
|
/** 新密码 */
|
||||||
|
new_password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置密码请求参数 */
|
||||||
|
export interface ResetPasswordRequest {
|
||||||
|
/** 新密码 */
|
||||||
|
new_password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改当前坐席密码
|
||||||
|
* 需要验证旧密码
|
||||||
|
* @param data 包含旧密码和新密码的请求参数
|
||||||
|
* @returns 修改结果
|
||||||
|
*/
|
||||||
|
export async function changePassword(data: ChangePasswordRequest): Promise<{ message: string }> {
|
||||||
|
const response: any = await apiClient.post('/agents/password', data)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员重置坐席密码
|
||||||
|
* @param userId 坐席用户ID
|
||||||
|
* @param newPassword 新密码
|
||||||
|
* @returns 重置结果
|
||||||
|
*/
|
||||||
|
export async function adminResetPassword(userId: string, newPassword: string): Promise<{ message: string }> {
|
||||||
|
const response: any = await apiClient.post(`/admin/agents/${userId}/reset-password`, { new_password: newPassword })
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,15 +44,6 @@ apiClient.interceptors.request.use(
|
|||||||
config.headers['Authorization'] = `Bearer ${token}`
|
config.headers['Authorization'] = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容过渡:如果同时存在 employee_id 且无 token,则仍然发送 X-Employee-Id
|
|
||||||
// 这确保了在 token 过期但 localStorage 中仍有旧数据的降级场景
|
|
||||||
if (!token) {
|
|
||||||
const employeeId = localStorage.getItem('employee_id')
|
|
||||||
if (employeeId) {
|
|
||||||
config.headers['X-Employee-Id'] = employeeId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@@ -77,23 +68,24 @@ apiClient.interceptors.response.use(
|
|||||||
// 后端 _get_current_employee 在 Redis 查不到 token 时返回此码
|
// 后端 _get_current_employee 在 Redis 查不到 token 时返回此码
|
||||||
if (res.code === 1002) {
|
if (res.code === 1002) {
|
||||||
handleAuthExpired('biz1002')
|
handleAuthExpired('biz1002')
|
||||||
return Promise.reject(new Error(res.message || '未授权'))
|
return Promise.reject({ code: 1002, message: res.message || '未授权' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 普通业务错误:显示轻提示
|
// 普通业务错误:显示轻提示
|
||||||
showToast(res.message || '请求失败')
|
showToast(res.message || '请求失败')
|
||||||
return Promise.reject(new Error(res.message || '请求失败'))
|
return Promise.reject({ code: res.code, message: res.message || '请求失败' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 业务成功:返回 response.data(即 {code, data, message} 包装对象)
|
// 业务成功:Scheme A — 直接返回 inner data(三端统一契约)
|
||||||
// API 函数通过 response.data 取出业务数据(与原始工作代码一致)
|
return res.data
|
||||||
return response.data
|
|
||||||
},
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
// 网络错误或服务器错误
|
// 网络错误或服务器错误:统一 reject {code, message}(CTRT-03)
|
||||||
let message = '网络异常,请稍后重试'
|
let message = '网络异常,请稍后重试'
|
||||||
|
let code = -1
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
|
code = error.response.status
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 401:
|
case 401:
|
||||||
// HTTP 401:Token 过期或无效(FastAPI 直接返回的 HTTP 状态码)
|
// HTTP 401:Token 过期或无效(FastAPI 直接返回的 HTTP 状态码)
|
||||||
@@ -116,11 +108,11 @@ apiClient.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 显示轻提示(401 时不显示通用提示,因为会自动跳转授权)
|
// 显示轻提示(401 时不显示通用提示,因为会自动跳转授权)
|
||||||
if (!error.response || error.response.status !== 401) {
|
if (code !== 401) {
|
||||||
showToast(message)
|
showToast(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error)
|
return Promise.reject({ code, message })
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export async function recallMessage(messageId: string): Promise<any> {
|
|||||||
const response: AxiosResponse = await apiClient.post(
|
const response: AxiosResponse = await apiClient.post(
|
||||||
`/messages/${messageId}/recall`
|
`/messages/${messageId}/recall`
|
||||||
)
|
)
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +46,7 @@ export async function deleteMessage(messageId: string): Promise<any> {
|
|||||||
const response: AxiosResponse = await apiClient.delete(
|
const response: AxiosResponse = await apiClient.delete(
|
||||||
`/messages/${messageId}`
|
`/messages/${messageId}`
|
||||||
)
|
)
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +59,7 @@ export async function markConversationRead(conversationId: string): Promise<any>
|
|||||||
const response: AxiosResponse = await apiClient.post(
|
const response: AxiosResponse = await apiClient.post(
|
||||||
`/conversations/${conversationId}/mark-read`
|
`/conversations/${conversationId}/mark-read`
|
||||||
)
|
)
|
||||||
return response.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,7 +77,7 @@ export async function pollMessages(afterMessageId?: string): Promise<Message[]>
|
|||||||
'/h5/conversations/current/messages/poll',
|
'/h5/conversations/current/messages/poll',
|
||||||
{ params }
|
{ params }
|
||||||
)
|
)
|
||||||
const data = response.data.data
|
const data = response
|
||||||
const items = data?.items || []
|
const items = data?.items || []
|
||||||
// 映射后端字段到前端字段
|
// 映射后端字段到前端字段
|
||||||
return items.map((item: any) => ({
|
return items.map((item: any) => ({
|
||||||
@@ -121,7 +121,7 @@ export async function uploadImage(file: File): Promise<{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return response.data.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,5 +148,5 @@ export async function uploadMessageFile(file: File): Promise<{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return response.data.data
|
return response
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ export async function getTroubleshootingTemplates(
|
|||||||
page_size: params?.page_size || 20,
|
page_size: params?.page_size || 20,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return response.data.data
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,5 +116,5 @@ export async function getTroubleshootingTemplate(
|
|||||||
id: string,
|
id: string,
|
||||||
): Promise<TroubleshootingTemplate> {
|
): Promise<TroubleshootingTemplate> {
|
||||||
const response: AxiosResponse = await apiClient.get(`/troubleshooting-templates/${id}`)
|
const response: AxiosResponse = await apiClient.get(`/troubleshooting-templates/${id}`)
|
||||||
return response.data.data
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ async function uploadWithRetry(formData: FormData, maxRetries: number = 3): Prom
|
|||||||
})
|
})
|
||||||
// 响应拦截器已确保 code === 0
|
// 响应拦截器已确保 code === 0
|
||||||
// response = {code:0, data: {url:"...",...}, message:"success"}(拦截器返回值)
|
// response = {code:0, data: {url:"...",...}, message:"success"}(拦截器返回值)
|
||||||
// response.data = 业务数据 {url:"...", filename:"...", ...}
|
// response = 业务数据 {url:"...", filename:"...", ...}
|
||||||
return response.data as UploadResponse
|
return response as UploadResponse
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (attempt === maxRetries) throw err
|
if (attempt === maxRetries) throw err
|
||||||
// 指数退避:1s, 2s, 4s
|
// 指数退避:1s, 2s, 4s
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<!-- =============================================================================
|
||||||
|
// 企微IT智能服务台 — P1 员工侧高危动作二次确认弹窗
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:当自动化处置遇到高危/写操作时,由 WS automation.action_required 触发,
|
||||||
|
// 弹窗展示动作详情,员工确认执行或取消。
|
||||||
|
// 约定:Vant4 van-dialog(受控 v-model:show),emit confirm/cancel。
|
||||||
|
// ============================================================================= -->
|
||||||
|
<template>
|
||||||
|
<van-dialog
|
||||||
|
v-model:show="show"
|
||||||
|
title="高危操作确认"
|
||||||
|
:show-confirm-button="true"
|
||||||
|
:show-cancel-button="true"
|
||||||
|
confirm-button-text="确认执行"
|
||||||
|
cancel-button-text="暂不执行"
|
||||||
|
confirm-button-color="#ee0a24"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
@cancel="onCancel"
|
||||||
|
>
|
||||||
|
<div v-if="action" class="confirm-body">
|
||||||
|
<div class="risk-row">
|
||||||
|
<van-tag :type="riskTagType" mark>{{ riskLabel }}</van-tag>
|
||||||
|
</div>
|
||||||
|
<div class="action-title">{{ action.title }}</div>
|
||||||
|
<div class="action-desc">{{ action.description }}</div>
|
||||||
|
|
||||||
|
<div v-if="action.payload" class="payload-block">
|
||||||
|
<div class="block-label">操作参数</div>
|
||||||
|
<pre class="payload-pre">{{ prettyPayload }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="warn-tip">该操作将实际执行,请确认信息无误后再继续。</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="confirm-body">
|
||||||
|
<p class="warn-tip">暂无动作详情。</p>
|
||||||
|
</div>
|
||||||
|
</van-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { AutomationAction } from '@/api/automation'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
action: AutomationAction | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'confirm', payload: { confirmed: boolean; note?: string }): void
|
||||||
|
(e: 'cancel'): void
|
||||||
|
(e: 'update:visible', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 受控显示:与父组件 v-model:visible 同步
|
||||||
|
const show = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (v: boolean) => emit('update:visible', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const riskLabel = computed(() => {
|
||||||
|
switch (props.action?.risk_level) {
|
||||||
|
case 'read':
|
||||||
|
return '只读'
|
||||||
|
case 'low':
|
||||||
|
return '低风险'
|
||||||
|
case 'write':
|
||||||
|
return '写操作'
|
||||||
|
case 'high':
|
||||||
|
return '高危'
|
||||||
|
default:
|
||||||
|
return props.action?.risk_level || '未知'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const riskTagType = computed<'primary' | 'success' | 'warning' | 'danger'>(() => {
|
||||||
|
switch (props.action?.risk_level) {
|
||||||
|
case 'read':
|
||||||
|
return 'primary'
|
||||||
|
case 'low':
|
||||||
|
return 'success'
|
||||||
|
case 'write':
|
||||||
|
return 'warning'
|
||||||
|
case 'high':
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'primary'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const prettyPayload = computed(() => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(props.action?.payload, null, 2)
|
||||||
|
} catch {
|
||||||
|
return String(props.action?.payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onConfirm(): void {
|
||||||
|
emit('confirm', { confirmed: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel(): void {
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.confirm-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.risk-row {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.action-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #323233);
|
||||||
|
}
|
||||||
|
.action-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary, #969799);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.payload-block {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.block-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary, #969799);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.payload-pre {
|
||||||
|
background: var(--bg-secondary, #f7f8fa);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
max-height: 160px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.warn-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ee0a24;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<!-- =============================================================================
|
||||||
|
// 企微IT智能服务台 — 「已解决」反馈 / 静默关单提示
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:会话 resolved 后展示,告知员工将静默自动关单,并收集满意度反馈。
|
||||||
|
// 约定:Vant4 van-popup(底部弹出),emit feedback { satisfied, note }。
|
||||||
|
// ============================================================================= -->
|
||||||
|
<template>
|
||||||
|
<van-popup
|
||||||
|
v-model:show="show"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
:style="{ padding: '20px' }"
|
||||||
|
@close="onClose"
|
||||||
|
>
|
||||||
|
<div class="resolve-feedback">
|
||||||
|
<div class="title">问题已解决 🎉</div>
|
||||||
|
<p class="desc">
|
||||||
|
本次自动化处置已完成。如无异议,会话将在
|
||||||
|
<b>{{ autoCloseText }}</b>
|
||||||
|
后自动关闭(静默关单)。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="question">本次服务是否解决了您的问题?</div>
|
||||||
|
<div class="btns">
|
||||||
|
<van-button type="primary" block @click="submit(true)">已解决,满意</van-button>
|
||||||
|
<van-button block class="btn-secondary" @click="submit(false)">仍未解决</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-field
|
||||||
|
v-model="note"
|
||||||
|
type="textarea"
|
||||||
|
rows="2"
|
||||||
|
placeholder="补充意见(可选)"
|
||||||
|
class="note-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { AutomationSession } from '@/api/automation'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
session: AutomationSession | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'feedback', payload: { satisfied: boolean; note?: string }): void
|
||||||
|
(e: 'update:visible', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const show = computed({
|
||||||
|
get: () => props.visible,
|
||||||
|
set: (v: boolean) => emit('update:visible', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const note = ref('')
|
||||||
|
|
||||||
|
// 静默关单倒计时文本
|
||||||
|
const autoCloseText = computed(() => {
|
||||||
|
const t = props.session?.auto_close_at
|
||||||
|
if (!t) return '稍后'
|
||||||
|
const diff = new Date(t).getTime() - Date.now()
|
||||||
|
if (diff <= 0) return '即将'
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
const secs = Math.floor((diff % 60000) / 1000)
|
||||||
|
return mins > 0 ? `${mins} 分 ${secs} 秒` : `${secs} 秒`
|
||||||
|
})
|
||||||
|
|
||||||
|
function submit(satisfied: boolean): void {
|
||||||
|
emit('feedback', { satisfied, note: note.value || undefined })
|
||||||
|
note.value = ''
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose(): void {
|
||||||
|
// 弹窗关闭(员工未反馈)不强制提交,仅同步可见状态
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resolve-feedback {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #323233);
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary, #969799);
|
||||||
|
margin: 10px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.question {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
color: var(--text-primary, #323233);
|
||||||
|
}
|
||||||
|
.btns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.note-field {
|
||||||
|
margin-top: 12px;
|
||||||
|
background: var(--bg-secondary, #f7f8fa);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -258,7 +258,15 @@
|
|||||||
<span>正在通知 IT 坐席...</span>
|
<span>正在通知 IT 坐席...</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="sendSuccess" class="call-modal__success">
|
<div v-if="sendSuccess" class="call-modal__success">
|
||||||
✅ 呼叫成功!坐席马上就来~
|
<template v-if="assignResult === 'assigned'">
|
||||||
|
🎉 呼叫成功!坐席已为您服务
|
||||||
|
</template>
|
||||||
|
<template v-else-if="assignResult === 'queued'">
|
||||||
|
⏳ 坐席正忙,您已进入排队,请耐心等待...
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
✅ 呼叫成功!坐席马上就来~
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -339,20 +347,25 @@ watch(() => props.visible, (newVal) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 分配结果状态
|
||||||
|
const assignResult = ref<string>('')
|
||||||
|
|
||||||
async function startCall(): Promise<void> {
|
async function startCall(): Promise<void> {
|
||||||
selectedScene.value = pickScene()
|
selectedScene.value = pickScene()
|
||||||
sending.value = true
|
sending.value = true
|
||||||
sendSuccess.value = false
|
sendSuccess.value = false
|
||||||
|
assignResult.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.shakeAgent()
|
const result = await store.shakeAgent()
|
||||||
|
assignResult.value = result
|
||||||
sendSuccess.value = true
|
sendSuccess.value = true
|
||||||
emit('call-success')
|
emit('call-success')
|
||||||
|
|
||||||
// 3秒后自动关闭
|
// 4秒后自动关闭(给用户时间阅读分配结果)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (sendSuccess.value) handleClose()
|
if (sendSuccess.value) handleClose()
|
||||||
}, 4000)
|
}, 5000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 发送失败,关闭弹窗
|
// 发送失败,关闭弹窗
|
||||||
handleClose()
|
handleClose()
|
||||||
|
|||||||
@@ -49,6 +49,28 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="switch-icon">🌙</span>
|
<span class="switch-icon">🌙</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 用户头像 / 菜单 -->
|
||||||
|
<div class="user-menu">
|
||||||
|
<button class="user-avatar-btn" @click="showUserMenu = !showUserMenu">
|
||||||
|
<span class="user-avatar">{{ employeeStore.employeeInfo?.employee_name?.charAt(0) || '?' }}</span>
|
||||||
|
</button>
|
||||||
|
<!-- 用户菜单下拉 -->
|
||||||
|
<div v-if="showUserMenu" class="user-dropdown">
|
||||||
|
<div class="user-dropdown__info">
|
||||||
|
<div class="user-dropdown__name">{{ employeeStore.employeeInfo?.employee_name }}</div>
|
||||||
|
<div class="user-dropdown__id">{{ employeeStore.employeeInfo?.employee_id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="user-dropdown__divider"></div>
|
||||||
|
<div class="user-dropdown__item" @click="openChangePasswordDialog">
|
||||||
|
<span class="user-dropdown__icon">🔑</span>
|
||||||
|
修改密码
|
||||||
|
</div>
|
||||||
|
<div class="user-dropdown__item user-dropdown__item--danger" @click="handleLogout">
|
||||||
|
<span class="user-dropdown__icon">🚪</span>
|
||||||
|
退出登录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -119,6 +141,46 @@
|
|||||||
@select="handleApprovalSelect"
|
@select="handleApprovalSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 满意度评价弹窗(P1-25) -->
|
||||||
|
<EvaluationDialog
|
||||||
|
v-model="showEvaluationDialog"
|
||||||
|
:conversation-id="evaluationConversationId"
|
||||||
|
@submitted="handleEvaluationSubmitted"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 修改密码弹窗 -->
|
||||||
|
<van-dialog
|
||||||
|
v-model:show="showChangePasswordDialog"
|
||||||
|
title="修改密码"
|
||||||
|
show-cancel-button
|
||||||
|
confirm-button-text="确认修改"
|
||||||
|
@confirm="handleChangePassword"
|
||||||
|
>
|
||||||
|
<div class="change-password-form">
|
||||||
|
<van-field
|
||||||
|
v-model="changePasswordForm.oldPassword"
|
||||||
|
type="password"
|
||||||
|
label="旧密码"
|
||||||
|
placeholder="请输入旧密码"
|
||||||
|
:rules="[{ required: true, message: '请输入旧密码' }]"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="changePasswordForm.newPassword"
|
||||||
|
type="password"
|
||||||
|
label="新密码"
|
||||||
|
placeholder="请输入新密码(6-128位)"
|
||||||
|
:rules="[{ required: true, message: '请输入新密码' }]"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="changePasswordForm.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
label="确认密码"
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
:rules="[{ required: true, message: '请再次输入新密码' }]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</van-dialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -130,28 +192,104 @@
|
|||||||
* 排查步骤固定在消息区顶部,不随消息滚动消失
|
* 排查步骤固定在消息区顶部,不随消息滚动消失
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, watch, nextTick, onMounted } from 'vue'
|
import { ref, reactive, watch, nextTick, onMounted } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import { useEmployeeStore } from '@/stores/employee'
|
||||||
|
import { changePassword } from '@/api/employee'
|
||||||
import MessageBubble from './MessageBubble.vue'
|
import MessageBubble from './MessageBubble.vue'
|
||||||
import InputBar from './InputBar.vue'
|
import InputBar from './InputBar.vue'
|
||||||
import CallAgentModal from './CallAgentModal.vue'
|
import CallAgentModal from './CallAgentModal.vue'
|
||||||
import ApprovalCardModal from './ApprovalCardModal.vue'
|
import ApprovalCardModal from './ApprovalCardModal.vue'
|
||||||
import TroubleshootFlow from './TroubleshootFlow.vue'
|
import TroubleshootFlow from './TroubleshootFlow.vue'
|
||||||
import ParticipantList from './ParticipantList.vue'
|
import ParticipantList from './ParticipantList.vue'
|
||||||
|
import EvaluationDialog from './EvaluationDialog.vue'
|
||||||
|
|
||||||
const store = useConversationStore()
|
const store = useConversationStore()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
|
const employeeStore = useEmployeeStore()
|
||||||
|
|
||||||
/** 消息列表容器的 DOM 引用 */
|
/** 消息列表容器的 DOM 引用 */
|
||||||
const messageListRef = ref<HTMLElement | null>(null)
|
const messageListRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// 用户菜单状态
|
||||||
|
const showUserMenu = ref<boolean>(false)
|
||||||
|
const showChangePasswordDialog = ref<boolean>(false)
|
||||||
|
const changePasswordForm = reactive({
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
|
||||||
/** 是否显示「呼叫坐席」弹窗 */
|
/** 是否显示「呼叫坐席」弹窗 */
|
||||||
const showCallModal = ref<boolean>(false)
|
const showCallModal = ref<boolean>(false)
|
||||||
|
|
||||||
|
/** 满意度评价弹窗显示状态 */
|
||||||
|
const showEvaluationDialog = ref<boolean>(false)
|
||||||
|
|
||||||
|
/** 当前待评价的会话ID */
|
||||||
|
const evaluationConversationId = ref<string>('')
|
||||||
|
|
||||||
|
/** 会话状态(用于检测会话结束) */
|
||||||
|
const previousConversationStatus = ref<string>('')
|
||||||
|
|
||||||
/** 是否应该自动滚动到底部(用户手动上滚时暂停自动滚动) */
|
/** 是否应该自动滚动到底部(用户手动上滚时暂停自动滚动) */
|
||||||
const shouldAutoScroll = ref<boolean>(true)
|
const shouldAutoScroll = ref<boolean>(true)
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 用户菜单功能
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
/** 打开修改密码对话框 */
|
||||||
|
function openChangePasswordDialog(): void {
|
||||||
|
showUserMenu.value = false
|
||||||
|
changePasswordForm.oldPassword = ''
|
||||||
|
changePasswordForm.newPassword = ''
|
||||||
|
changePasswordForm.confirmPassword = ''
|
||||||
|
showChangePasswordDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交修改密码 */
|
||||||
|
async function handleChangePassword(): Promise<void> {
|
||||||
|
// 验证密码
|
||||||
|
if (!changePasswordForm.oldPassword) {
|
||||||
|
showToast('请输入旧密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!changePasswordForm.newPassword || changePasswordForm.newPassword.length < 6) {
|
||||||
|
showToast('新密码长度不能少于6位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (changePasswordForm.newPassword !== changePasswordForm.confirmPassword) {
|
||||||
|
showToast('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (changePasswordForm.oldPassword === changePasswordForm.newPassword) {
|
||||||
|
showToast('新密码不能与旧密码相同')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changePassword({
|
||||||
|
old_password: changePasswordForm.oldPassword,
|
||||||
|
new_password: changePasswordForm.newPassword,
|
||||||
|
})
|
||||||
|
showToast('密码修改成功')
|
||||||
|
showChangePasswordDialog.value = false
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('修改密码失败:', error)
|
||||||
|
const msg = error?.response?.data?.message || error?.message || '修改密码失败'
|
||||||
|
showToast(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退出登录 */
|
||||||
|
function handleLogout(): void {
|
||||||
|
showUserMenu.value = false
|
||||||
|
employeeStore.logout()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 滚动到消息列表底部
|
* 滚动到消息列表底部
|
||||||
* 使用 nextTick 确保 DOM 更新后再滚动
|
* 使用 nextTick 确保 DOM 更新后再滚动
|
||||||
@@ -186,6 +324,12 @@ function handleApprovalSelect(option: any): void {
|
|||||||
store.closeApprovalCard()
|
store.closeApprovalCard()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 评价提交成功回调 */
|
||||||
|
function handleEvaluationSubmitted(): void {
|
||||||
|
console.log('[ChatPanel] 评价已提交')
|
||||||
|
// 可以在这里添加其他逻辑,如显示感谢等
|
||||||
|
}
|
||||||
|
|
||||||
// 监听消息列表变化,自动滚动到底部
|
// 监听消息列表变化,自动滚动到底部
|
||||||
watch(
|
watch(
|
||||||
() => store.messages.length,
|
() => store.messages.length,
|
||||||
@@ -194,9 +338,31 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听会话状态变化,弹出评价弹窗
|
||||||
|
watch(
|
||||||
|
() => store.currentConversation?.status,
|
||||||
|
(newStatus, oldStatus) => {
|
||||||
|
// 会话从"服务中"变为"已结单"时,弹出评价弹窗
|
||||||
|
if (oldStatus === 'serving' && newStatus === 'resolved') {
|
||||||
|
const convId = store.currentConversation?.conversation_id
|
||||||
|
if (convId) {
|
||||||
|
// 延迟3秒弹出评价弹窗
|
||||||
|
setTimeout(() => {
|
||||||
|
evaluationConversationId.value = convId
|
||||||
|
showEvaluationDialog.value = true
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 记录当前状态
|
||||||
|
previousConversationStatus.value = newStatus || ''
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 组件挂载后滚动到底部
|
// 组件挂载后滚动到底部
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
|
// 记录初始会话状态
|
||||||
|
previousConversationStatus.value = store.currentConversation?.status || ''
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -288,6 +454,94 @@ onMounted(() => {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 用户菜单 */
|
||||||
|
.user-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--accent, #07C160);
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__info {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__id {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__item:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__item--danger {
|
||||||
|
color: var(--color-danger, #F56C6C);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown__icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 修改密码表单 */
|
||||||
|
.change-password-form {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* 🔔 呼叫坐席按钮(标题栏) */
|
/* 🔔 呼叫坐席按钮(标题栏) */
|
||||||
.chat-panel__bell-btn {
|
.chat-panel__bell-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
<!--
|
||||||
|
企微IT智能服务台 — H5用户端满意度评价弹窗
|
||||||
|
说明:会话结束后弹出的满意度评价对话框
|
||||||
|
功能:
|
||||||
|
1. 5星评分(必选)
|
||||||
|
2. 表情选择:满意/一般/不满意(必选)
|
||||||
|
3. 文字反馈输入框(可选,限200字)
|
||||||
|
4. 提交评价
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<van-popup
|
||||||
|
v-model:show="visible"
|
||||||
|
round
|
||||||
|
:close-on-click-overlay="false"
|
||||||
|
class="evaluation-popup"
|
||||||
|
>
|
||||||
|
<div class="evaluation-dialog">
|
||||||
|
<div class="evaluation-dialog__header">
|
||||||
|
<h3 class="evaluation-dialog__title">请对本次服务进行评价</h3>
|
||||||
|
<p class="evaluation-dialog__subtitle">您的评价对我们非常重要</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evaluation-dialog__section">
|
||||||
|
<div class="evaluation-dialog__label">服务评分</div>
|
||||||
|
<div class="star-rating">
|
||||||
|
<van-rate
|
||||||
|
v-model="formData.star_rating"
|
||||||
|
:count="5"
|
||||||
|
size="32"
|
||||||
|
color="#FFD21E"
|
||||||
|
void-color="#E5E5E5"
|
||||||
|
@change="handleStarChange"
|
||||||
|
/>
|
||||||
|
<span class="star-label">{{ starLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evaluation-dialog__section">
|
||||||
|
<div class="evaluation-dialog__label">整体感受</div>
|
||||||
|
<div class="emoji-selector">
|
||||||
|
<div class="emoji-item" :class="{ 'emoji-item--selected': formData.emoji === 'satisfied' }" @click="selectEmoji('satisfied')">
|
||||||
|
<span class="emoji-icon">😀</span>
|
||||||
|
<span class="emoji-text">满意</span>
|
||||||
|
</div>
|
||||||
|
<div class="emoji-item" :class="{ 'emoji-item--selected': formData.emoji === 'neutral' }" @click="selectEmoji('neutral')">
|
||||||
|
<span class="emoji-icon">😐</span>
|
||||||
|
<span class="emoji-text">一般</span>
|
||||||
|
</div>
|
||||||
|
<div class="emoji-item" :class="{ 'emoji-item--selected': formData.emoji === 'dissatisfied' }" @click="selectEmoji('dissatisfied')">
|
||||||
|
<span class="emoji-icon">😞</span>
|
||||||
|
<span class="emoji-text">不满意</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evaluation-dialog__section">
|
||||||
|
<div class="evaluation-dialog__label">
|
||||||
|
改进建议
|
||||||
|
<span class="evaluation-dialog__optional">(选填)</span>
|
||||||
|
</div>
|
||||||
|
<van-field
|
||||||
|
v-model="formData.feedback_text"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="您对本次服务有什么建议?"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
rows="3"
|
||||||
|
class="evaluation-dialog__textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evaluation-dialog__actions">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
提交评价
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evaluation-dialog__skip">
|
||||||
|
<van-button size="small" type="default" :disabled="submitting" @click="handleSkip">
|
||||||
|
暂不评价
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import { submitEvaluation, getEvaluation } from '@/api/conversation'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean
|
||||||
|
conversationId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
modelValue: false,
|
||||||
|
conversationId: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'submitted'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val),
|
||||||
|
})
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
star_rating: 0,
|
||||||
|
emoji: '',
|
||||||
|
feedback_text: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const starLabel = computed(() => {
|
||||||
|
const labels: Record<number, string> = {
|
||||||
|
0: '',
|
||||||
|
1: '非常差',
|
||||||
|
2: '较差',
|
||||||
|
3: '一般',
|
||||||
|
4: '满意',
|
||||||
|
5: '非常满意',
|
||||||
|
}
|
||||||
|
return labels[formData.value.star_rating] || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
return formData.value.star_rating > 0 && formData.value.emoji !== ''
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(visible, async (val) => {
|
||||||
|
if (val && props.conversationId) {
|
||||||
|
try {
|
||||||
|
const evaluation = await getEvaluation(props.conversationId)
|
||||||
|
if (evaluation) {
|
||||||
|
visible.value = false
|
||||||
|
showToast('您已评价过该会话')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[EvaluationDialog] 检查评价状态失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function selectEmoji(emoji: string): void {
|
||||||
|
formData.value.emoji = emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStarChange(value: number): void {
|
||||||
|
if (value >= 4) {
|
||||||
|
formData.value.emoji = 'satisfied'
|
||||||
|
} else if (value >= 2) {
|
||||||
|
formData.value.emoji = 'neutral'
|
||||||
|
} else {
|
||||||
|
formData.value.emoji = 'dissatisfied'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
if (!canSubmit.value || submitting.value) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await submitEvaluation(props.conversationId, {
|
||||||
|
star_rating: formData.value.star_rating,
|
||||||
|
emoji: formData.value.emoji,
|
||||||
|
feedback_text: formData.value.feedback_text || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
showToast('评价成功,感谢您的反馈!')
|
||||||
|
visible.value = false
|
||||||
|
emit('submitted')
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[EvaluationDialog] 提交评价失败:', error)
|
||||||
|
const message = error?.response?.data?.message || '提交失败,请稍后重试'
|
||||||
|
showToast(message)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSkip(): void {
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm(): void {
|
||||||
|
formData.value = {
|
||||||
|
star_rating: 0,
|
||||||
|
emoji: '',
|
||||||
|
feedback_text: '',
|
||||||
|
}
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
resetForm,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.evaluation-popup {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog {
|
||||||
|
padding: 24px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog__header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog__subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog__section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog__label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog__optional {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-selector {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: var(--bg-tertiary, #f5f5f5);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-item:hover {
|
||||||
|
background: var(--bg-secondary, #f0f0f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-item--selected {
|
||||||
|
background: var(--accent-soft, rgba(7, 193, 96, 0.1));
|
||||||
|
border-color: var(--accent, #07C160);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-item--selected .emoji-text {
|
||||||
|
color: var(--accent, #07C160);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog__textarea {
|
||||||
|
background: var(--bg-tertiary, #f5f5f5);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog__actions {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog__actions .van-button--primary {
|
||||||
|
background: var(--accent, #07C160);
|
||||||
|
border-color: var(--accent, #07C160);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog__skip {
|
||||||
|
margin-top: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evaluation-dialog__skip .van-button--default {
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,8 +13,22 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="input-box">
|
<div class="input-box">
|
||||||
<!-- 工具栏:表情/文件/截图/快捷申请 (2026-07-05移除图片和拍照) -->
|
<!-- 工具栏:摇人按钮/表情/文件/截图/快捷申请 (2026-07-05移除图片和拍照) -->
|
||||||
<div class="input-box__toolbar">
|
<div class="input-box__toolbar">
|
||||||
|
<!-- 摇人按钮 - 输入框左侧,橙色渐变铃铛图标 -->
|
||||||
|
<button
|
||||||
|
v-if="store.canCallAgent"
|
||||||
|
class="input-box__tool-btn input-box__tool-btn--yaoren"
|
||||||
|
:class="{ 'input-box__tool-btn--calling': isCallingAgent }"
|
||||||
|
title="呼叫IT坐席"
|
||||||
|
:disabled="isCallingAgent"
|
||||||
|
@click="handleCallAgent"
|
||||||
|
>
|
||||||
|
<svg v-if="!isCallingAgent" class="yaoren-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2C10.9 2 10 2.9 10 4V8C10 9.1 10.9 10 12 10C13.1 10 14 9.1 14 8V4C14 2.9 13.1 2 12 2ZM12 12C10.9 12 10 12.9 10 14V16C10 17.1 10.9 18 12 18C13.1 18 14 17.1 14 16V14C14 12.9 13.1 12 12 12ZM6 6H18V8H6V6ZM4 4V20H20V4H4Z"/>
|
||||||
|
</svg>
|
||||||
|
<span v-else class="yaoren-text">呼叫中...</span>
|
||||||
|
</button>
|
||||||
<button class="input-box__tool-btn" title="表情" @click="handleEmoji">
|
<button class="input-box__tool-btn" title="表情" @click="handleEmoji">
|
||||||
<span>😊</span>
|
<span>😊</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -22,7 +36,7 @@
|
|||||||
<span>📎</span>
|
<span>📎</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="input-box__tool-btn" title="截图" @click="handleScreenshot">
|
<button class="input-box__tool-btn" title="截图" @click="handleScreenshot">
|
||||||
<span>✂️</span>
|
<span>📷</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="input-box__tool-btn input-box__tool-btn--accent" title="快捷申请" @click="handleQuickApply">
|
<button class="input-box__tool-btn input-box__tool-btn--accent" title="快捷申请" @click="handleQuickApply">
|
||||||
<span>📝</span>
|
<span>📝</span>
|
||||||
@@ -117,6 +131,7 @@ import { showToast } from 'vant'
|
|||||||
import html2canvas from 'html2canvas-pro'
|
import html2canvas from 'html2canvas-pro'
|
||||||
import { useConversationStore } from '@/stores/conversation'
|
import { useConversationStore } from '@/stores/conversation'
|
||||||
import { uploadFile } from '@/api/upload'
|
import { uploadFile } from '@/api/upload'
|
||||||
|
import { callAgent } from '@/api/conversation'
|
||||||
import ScreenshotEditor from './ScreenshotEditor.vue'
|
import ScreenshotEditor from './ScreenshotEditor.vue'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -161,6 +176,9 @@ const showEmojiPanel = ref(false)
|
|||||||
/** 截图编辑器是否可见 */
|
/** 截图编辑器是否可见 */
|
||||||
const showScreenshotEditor = ref(false)
|
const showScreenshotEditor = ref(false)
|
||||||
|
|
||||||
|
/** 是否正在呼叫坐席中 */
|
||||||
|
const isCallingAgent = ref(false)
|
||||||
|
|
||||||
/** html2canvas 生成的截图 Canvas */
|
/** html2canvas 生成的截图 Canvas */
|
||||||
let screenshotCanvas: HTMLCanvasElement | null = null
|
let screenshotCanvas: HTMLCanvasElement | null = null
|
||||||
|
|
||||||
@@ -411,6 +429,29 @@ function onScreenshotCancel(): void {
|
|||||||
screenshotCanvas = null
|
screenshotCanvas = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 摇人按钮 - 呼叫坐席
|
||||||
|
// ============================================================================
|
||||||
|
async function handleCallAgent(): Promise<void> {
|
||||||
|
if (isCallingAgent.value) return // 防止重复点击
|
||||||
|
|
||||||
|
isCallingAgent.value = true
|
||||||
|
try {
|
||||||
|
// 调用后端API触发转人工
|
||||||
|
const resp = await callAgent()
|
||||||
|
if (resp.code === 0) {
|
||||||
|
showToast({ message: '已为您呼叫坐席,请稍候...', position: 'bottom' })
|
||||||
|
} else {
|
||||||
|
showToast({ message: resp.message || '呼叫失败,请重试', position: 'bottom' })
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('呼叫坐席失败:', error)
|
||||||
|
showToast({ message: '呼叫失败,请重试', position: 'bottom' })
|
||||||
|
} finally {
|
||||||
|
isCallingAgent.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 快捷申请按钮
|
// 快捷申请按钮
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -471,6 +512,48 @@ function handleQuickApply(): void {
|
|||||||
border-color: var(--accent-hover, #06ad56);
|
border-color: var(--accent-hover, #06ad56);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 摇人按钮 - 橙色渐变铃铛图标 */
|
||||||
|
.input-box__tool-btn--yaoren {
|
||||||
|
width: 40px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #FF6B35 0%, #FF8F5E 100%);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-box__tool-btn--yaoren:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-box__tool-btn--yaoren:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 呼叫中状态 */
|
||||||
|
.input-box__tool-btn--calling {
|
||||||
|
animation: shake 0.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-2px); }
|
||||||
|
75% { transform: translateX(2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaoren-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yaoren-text {
|
||||||
|
font-size: 10px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* 输入区域 */
|
/* 输入区域 */
|
||||||
.input-box__area {
|
.input-box__area {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
<div class="participant-item__avatar">
|
<div class="participant-item__avatar">
|
||||||
<!-- 有头像URL:渲染<img>,加载失败降级显示首字 -->
|
<!-- 有头像URL:渲染<img>,加载失败降级显示首字 -->
|
||||||
<img
|
<img
|
||||||
v-if="ownerInfo.avatar"
|
v-if="ownerInfo.avatar && !ownerAvatarFailed"
|
||||||
:src="ownerInfo.avatar"
|
:src="ownerInfo.avatar"
|
||||||
:alt="ownerInfo.name"
|
:alt="ownerInfo.name"
|
||||||
class="participant-item__avatar-img"
|
class="participant-item__avatar-img"
|
||||||
@error="onAvatarError($event)"
|
@error="ownerAvatarFailed = true"
|
||||||
/>
|
/>
|
||||||
<span v-else class="participant-item__avatar-letter">
|
<span v-else class="participant-item__avatar-letter">
|
||||||
{{ avatarLetter(ownerInfo.name) }}
|
{{ avatarLetter(ownerInfo.name) }}
|
||||||
@@ -58,11 +58,11 @@
|
|||||||
<div class="participant-item__avatar">
|
<div class="participant-item__avatar">
|
||||||
<!-- 有头像URL:渲染<img>,加载失败降级显示首字 -->
|
<!-- 有头像URL:渲染<img>,加载失败降级显示首字 -->
|
||||||
<img
|
<img
|
||||||
v-if="p.avatar"
|
v-if="p.avatar && !failedIds[p.id]"
|
||||||
:src="p.avatar"
|
:src="p.avatar"
|
||||||
:alt="p.name"
|
:alt="p.name"
|
||||||
class="participant-item__avatar-img"
|
class="participant-item__avatar-img"
|
||||||
@error="onAvatarError($event)"
|
@error="failedIds[p.id] = true"
|
||||||
/>
|
/>
|
||||||
<span v-else class="participant-item__avatar-letter">
|
<span v-else class="participant-item__avatar-letter">
|
||||||
{{ avatarLetter(p.name) }}
|
{{ avatarLetter(p.name) }}
|
||||||
@@ -123,6 +123,12 @@ const employeeStore = useEmployeeStore()
|
|||||||
/** 退出操作进行中 */
|
/** 退出操作进行中 */
|
||||||
const leaving = ref(false)
|
const leaving = ref(false)
|
||||||
|
|
||||||
|
/** 头像加载失败标记:发起人单独标记(URL 过期/网络异常时降级显示首字) */
|
||||||
|
const ownerAvatarFailed = ref(false)
|
||||||
|
|
||||||
|
/** 头像加载失败标记:被邀请参与者按 id 记录(各自 URL 独立,可能单独过期) */
|
||||||
|
const failedIds = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
/** 当前登录用户 ID */
|
/** 当前登录用户 ID */
|
||||||
const currentUserId = computed(() => store.userInfo?.employee_id || '')
|
const currentUserId = computed(() => store.userInfo?.employee_id || '')
|
||||||
|
|
||||||
@@ -162,17 +168,6 @@ function avatarLetter(name: string): string {
|
|||||||
return (name || '?').charAt(0)
|
return (name || '?').charAt(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 头像加载失败时的降级处理
|
|
||||||
* 做什么:隐藏 <img>,显示父容器的首字降级
|
|
||||||
* 为什么:企微头像 URL 可能过期或网络异常
|
|
||||||
*/
|
|
||||||
function onAvatarError(event: Event): void {
|
|
||||||
const img = event.target as HTMLImageElement
|
|
||||||
// 隐藏图片元素,让 CSS 显示首字降级
|
|
||||||
img.style.display = 'none'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 退出会话
|
* 退出会话
|
||||||
* 做什么:被邀请人主动退出当前会话
|
* 做什么:被邀请人主动退出当前会话
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,13 @@ const routes = [
|
|||||||
component: H5PreviewView,
|
component: H5PreviewView,
|
||||||
meta: { title: '员工自助', requiresAuth: false },
|
meta: { title: '员工自助', requiresAuth: false },
|
||||||
},
|
},
|
||||||
|
// 阶段5 自动化闭环 — 员工侧进度页
|
||||||
|
{
|
||||||
|
path: '/automation/:id',
|
||||||
|
name: 'AutomationProgress',
|
||||||
|
component: () => import('@/views/AutomationProgress.vue'),
|
||||||
|
meta: { title: '自动化处置', requiresAuth: true },
|
||||||
|
},
|
||||||
// 404 兜底:未匹配的路径重定向到首页
|
// 404 兜底:未匹配的路径重定向到首页
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// =============================================================================
|
||||||
|
// 企微IT智能服务台 — 阶段5 自动化闭环 状态管理(Pinia Store,H5 员工端)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:管理当前自动化会话详情、处置进展时间线、待确认高危动作,
|
||||||
|
// 以及专用 WebSocket 实时推送(/ws/automation/{sessionId})。
|
||||||
|
// 约定:与坐席端 stores/automation 保持一致的 WS 事件前缀 automation.* 与
|
||||||
|
// 连接方式(subprotocol 传递 token)。
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import type {
|
||||||
|
AutomationSession,
|
||||||
|
AutomationAction,
|
||||||
|
ConfirmPayload,
|
||||||
|
ResolvePayload,
|
||||||
|
} from '@/api/automation'
|
||||||
|
import {
|
||||||
|
getAutomationSession,
|
||||||
|
confirmAutomationAction,
|
||||||
|
resolveAutomationSession,
|
||||||
|
} from '@/api/automation'
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// WebSocket 辅助(C8:移除 portal_token)
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
function getH5Token(): string {
|
||||||
|
return localStorage.getItem('h5_token') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWsUrl(sessionId: string): string {
|
||||||
|
const token = getH5Token()
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
// 开发环境直连后端 8000(与坐席端一致);生产走同源 wss
|
||||||
|
const host = isDev ? 'localhost:8000' : window.location.host
|
||||||
|
return `${proto}//${host}/ws/automation/${sessionId}?token=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressEventItem {
|
||||||
|
type: string
|
||||||
|
time: string
|
||||||
|
data?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAutomationStore = defineStore('automation', () => {
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// 状态
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
const currentSession = ref<AutomationSession | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const ws = ref<WebSocket | null>(null)
|
||||||
|
const wsConnected = ref(false)
|
||||||
|
const wsSessionId = ref<string | null>(null)
|
||||||
|
/** 处置进展时间线(WS automation.progress 累积) */
|
||||||
|
const progressEvents = ref<ProgressEventItem[]>([])
|
||||||
|
/** 当前需要员工二次确认的高危动作(WS automation.action_required 设置) */
|
||||||
|
const pendingAction = ref<AutomationAction | null>(null)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// 计算属性
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
const isResolved = computed(() => currentSession.value?.status === 'resolved')
|
||||||
|
const isHandoff = computed(() => currentSession.value?.status === 'handoff')
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// 数据加载
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
async function fetchSession(sessionId: string): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
currentSession.value = await getAutomationSession(sessionId)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 员工确认/取消高危动作 */
|
||||||
|
async function confirmAction(actionId: string, payload: ConfirmPayload): Promise<void> {
|
||||||
|
await confirmAutomationAction(actionId, payload)
|
||||||
|
pendingAction.value = null
|
||||||
|
if (wsSessionId.value) await fetchSession(wsSessionId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记已解决 / 静默关单反馈 */
|
||||||
|
async function resolveSession(sessionId: string, payload: ResolvePayload): Promise<void> {
|
||||||
|
await resolveAutomationSession(sessionId, payload)
|
||||||
|
if (wsSessionId.value) await fetchSession(wsSessionId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
// WebSocket
|
||||||
|
// ------------------------------------------------------------------------
|
||||||
|
function connectWs(sessionId: string): void {
|
||||||
|
disconnectWs()
|
||||||
|
const url = buildWsUrl(sessionId)
|
||||||
|
const token = getH5Token()
|
||||||
|
const socket = new WebSocket(url, [`bearer.${token}`])
|
||||||
|
ws.value = socket
|
||||||
|
wsSessionId.value = sessionId
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
wsConnected.value = true
|
||||||
|
}
|
||||||
|
socket.onmessage = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
handleWsMessage(msg)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[automation H5 WS] 消息解析失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.onclose = () => {
|
||||||
|
wsConnected.value = false
|
||||||
|
}
|
||||||
|
socket.onerror = () => {
|
||||||
|
wsConnected.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectWs(): void {
|
||||||
|
if (ws.value) {
|
||||||
|
ws.value.close()
|
||||||
|
ws.value = null
|
||||||
|
}
|
||||||
|
wsConnected.value = false
|
||||||
|
wsSessionId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWsMessage(msg: { type: string; session_id?: string; data?: any }): void {
|
||||||
|
// 仅处理当前会话的事件
|
||||||
|
if (msg.session_id && wsSessionId.value && msg.session_id !== wsSessionId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'automation.progress':
|
||||||
|
// 进展更新:追加时间线并刷新详情
|
||||||
|
progressEvents.value.push({ type: msg.type, time: now(), data: msg.data })
|
||||||
|
if (wsSessionId.value) fetchSession(wsSessionId.value)
|
||||||
|
break
|
||||||
|
case 'automation.action_required':
|
||||||
|
// 需要员工二次确认的高危动作
|
||||||
|
pendingAction.value = (msg.data?.action as AutomationAction) || null
|
||||||
|
if (wsSessionId.value) fetchSession(wsSessionId.value)
|
||||||
|
break
|
||||||
|
case 'automation.resolved':
|
||||||
|
if (wsSessionId.value) fetchSession(wsSessionId.value)
|
||||||
|
showToast('问题已解决')
|
||||||
|
break
|
||||||
|
case 'automation.takeover':
|
||||||
|
// 被坐席接管:清空待确认动作
|
||||||
|
pendingAction.value = null
|
||||||
|
if (wsSessionId.value) fetchSession(wsSessionId.value)
|
||||||
|
break
|
||||||
|
case 'automation.error':
|
||||||
|
progressEvents.value.push({ type: msg.type, time: now(), data: msg.data })
|
||||||
|
showToast('自动化处置出现异常')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function now(): string {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentSession,
|
||||||
|
loading,
|
||||||
|
wsConnected,
|
||||||
|
progressEvents,
|
||||||
|
pendingAction,
|
||||||
|
isResolved,
|
||||||
|
isHandoff,
|
||||||
|
fetchSession,
|
||||||
|
confirmAction,
|
||||||
|
resolveSession,
|
||||||
|
connectWs,
|
||||||
|
disconnectWs,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
getCurrentConversation,
|
getCurrentConversation,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
pollMessages,
|
pollMessages,
|
||||||
|
getMessages,
|
||||||
shake,
|
shake,
|
||||||
getApprovalLinks,
|
getApprovalLinks,
|
||||||
getSoftwareDownloads,
|
getSoftwareDownloads,
|
||||||
@@ -211,7 +212,8 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
/** 是否有活跃会话(会话未结单) */
|
/** 是否有活跃会话(会话未结单) */
|
||||||
const hasActiveConversation = computed(() => {
|
const hasActiveConversation = computed(() => {
|
||||||
if (!currentConversation.value) return false
|
if (!currentConversation.value) return false
|
||||||
return currentConversation.value.status !== 'closed'
|
// resolved: 已结单(后端状态)
|
||||||
|
return currentConversation.value.status !== 'resolved'
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 当前用户是否为被邀请的参与者(非原始员工) */
|
/** 当前用户是否为被邀请的参与者(非原始员工) */
|
||||||
@@ -613,6 +615,56 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取消息列表(历史消息)
|
||||||
|
* 获取当前会话的完整消息历史,用于首次加载或翻页
|
||||||
|
* @param params 查询参数(limit 和 before)
|
||||||
|
*/
|
||||||
|
async function fetchMessages(params?: { limit?: number; before?: string }): Promise<void> {
|
||||||
|
// 未登录或无活跃会话时不获取
|
||||||
|
if (!isLoggedIn.value || !hasActiveConversation.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getMessages(params)
|
||||||
|
if (data.items && data.items.length > 0) {
|
||||||
|
// 消息去重
|
||||||
|
const uniqueMessages = data.items.filter(msg => {
|
||||||
|
if (processedMessageIds.value.has(msg.message_id)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
trackProcessedMessageId(msg.message_id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (uniqueMessages.length > 0) {
|
||||||
|
// 如果没有 before 参数(全量加载),直接替换消息列表
|
||||||
|
// 如果有 before 参数(翻页),追加到列表末尾
|
||||||
|
if (!params?.before) {
|
||||||
|
messages.value = uniqueMessages
|
||||||
|
} else {
|
||||||
|
messages.value.push(...uniqueMessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后消息 ID
|
||||||
|
const lastMsg = uniqueMessages[uniqueMessages.length - 1]
|
||||||
|
if (lastMsg) {
|
||||||
|
lastMessageId.value = lastMsg.message_id
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Store] 获取到历史消息:', uniqueMessages.length, '条')
|
||||||
|
|
||||||
|
// 保存到缓存
|
||||||
|
const convId = currentConversation.value?.conversation_id
|
||||||
|
if (convId) {
|
||||||
|
saveMessagesToCache(convId, messages.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Store] 获取历史消息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动消息轮询
|
* 启动消息轮询
|
||||||
* 使用 setInterval 每 3 秒轮询一次新消息
|
* 使用 setInterval 每 3 秒轮询一次新消息
|
||||||
@@ -644,10 +696,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
* 招手/敲桌子 — 呼叫 IT 坐席
|
* 招手/敲桌子 — 呼叫 IT 坐席
|
||||||
* 调用后端招手接口,返回趣味话术
|
* 调用后端招手接口,返回趣味话术
|
||||||
* 将话术以系统消息形式插入对话列表
|
* 将话术以系统消息形式插入对话列表
|
||||||
|
* @returns 分配结果: assigned(已分配坐席) / queued(排队中) / assign_failed(分配失败)
|
||||||
*/
|
*/
|
||||||
async function shakeAgent(): Promise<void> {
|
async function shakeAgent(): Promise<string> {
|
||||||
// 防止重复点击
|
// 防止重复点击
|
||||||
if (shaking.value) return
|
if (shaking.value) return 'pending'
|
||||||
|
|
||||||
shaking.value = true
|
shaking.value = true
|
||||||
try {
|
try {
|
||||||
@@ -670,12 +723,28 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
}
|
}
|
||||||
messages.value.push(systemMsg)
|
messages.value.push(systemMsg)
|
||||||
|
|
||||||
|
// 如果已分配坐席,更新话术内容
|
||||||
|
if (data.assign_result === 'assigned' && data.assigned_agent_id) {
|
||||||
|
systemMsg.content = `${data.funny_phrase}\n\n🎉 坐席已为您服务,请稍候...`
|
||||||
|
// 更新会话状态
|
||||||
|
if (currentConversation.value) {
|
||||||
|
currentConversation.value.status = 'serving'
|
||||||
|
}
|
||||||
|
} else if (data.assign_result === 'queued') {
|
||||||
|
// 进入排队
|
||||||
|
systemMsg.content = `${data.funny_phrase}\n\n⏳ 当前无空闲坐席,您已进入排队,请耐心等待...`
|
||||||
|
}
|
||||||
|
|
||||||
// 如果招手后坐席已接入(status === 'serving'),刷新会话信息
|
// 如果招手后坐席已接入(status === 'serving'),刷新会话信息
|
||||||
if (data.conversation?.status === 'serving') {
|
if (data.conversation?.status === 'serving') {
|
||||||
await fetchCurrentConversation()
|
await fetchCurrentConversation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 返回分配结果
|
||||||
|
return data.assign_result || 'queued'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Store] 招手失败:', error)
|
console.error('[Store] 招手失败:', error)
|
||||||
|
return 'error'
|
||||||
} finally {
|
} finally {
|
||||||
shaking.value = false
|
shaking.value = false
|
||||||
}
|
}
|
||||||
@@ -774,11 +843,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
// (后端 /h5/conversations/current 理论上应返回刚加入的会话)
|
// (后端 /h5/conversations/current 理论上应返回刚加入的会话)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清空消息列表,重新加载
|
// 清空消息列表,重新加载历史消息
|
||||||
messages.value = []
|
messages.value = []
|
||||||
lastMessageId.value = ''
|
lastMessageId.value = ''
|
||||||
// 立即拉取一次消息,避免等3秒轮询
|
// 获取完整的历史消息
|
||||||
await pollNewMessages()
|
await fetchMessages()
|
||||||
console.log('[Store] 已切换到邀请会话:', conversationId)
|
console.log('[Store] 已切换到邀请会话:', conversationId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Store] 切换会话失败:', error)
|
console.error('[Store] 切换会话失败:', error)
|
||||||
@@ -896,8 +965,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
* 初始化应用
|
* 初始化应用
|
||||||
* 1. 获取用户信息
|
* 1. 获取用户信息
|
||||||
* 2. 获取当前会话
|
* 2. 获取当前会话
|
||||||
* 3. 加载审批链接和软件下载
|
* 3. 获取消息历史(新增 fetchMessages)
|
||||||
* 4. 启动消息轮询
|
* 4. 加载审批链接和软件下载
|
||||||
|
* 5. 启动消息轮询
|
||||||
*/
|
*/
|
||||||
async function initialize(): Promise<void> {
|
async function initialize(): Promise<void> {
|
||||||
if (initialized.value) return
|
if (initialized.value) return
|
||||||
@@ -905,7 +975,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
try {
|
try {
|
||||||
console.log('[Store] 开始初始化应用...')
|
console.log('[Store] 开始初始化应用...')
|
||||||
|
|
||||||
// ===== 步骤1:并行加载用户信息和会话(不等待消息) =====
|
// ===== 步骤1:并行加载用户信息和会话 =====
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchUserInfo(),
|
fetchUserInfo(),
|
||||||
fetchCurrentConversation(),
|
fetchCurrentConversation(),
|
||||||
@@ -918,32 +988,52 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
if (cached && cached.length > 0) {
|
if (cached && cached.length > 0) {
|
||||||
console.log(`[Store] 加载缓存消息 ${cached.length} 条`)
|
console.log(`[Store] 加载缓存消息 ${cached.length} 条`)
|
||||||
messages.value = cached
|
messages.value = cached
|
||||||
|
// 从缓存更新最后消息ID
|
||||||
|
const lastCached = cached[cached.length - 1]
|
||||||
|
if (lastCached) {
|
||||||
|
lastMessageId.value = lastCached.message_id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 步骤3:后台加载后端消息(不阻塞UI) =====
|
// ===== 步骤3:获取历史消息(新增,使用 getMessages API) =====
|
||||||
// 使用 Promise.resolve().then() 让它在下一次事件循环执行,不阻塞主线程
|
// 后台加载,不阻塞 UI
|
||||||
Promise.resolve().then(async () => {
|
Promise.resolve().then(async () => {
|
||||||
try {
|
try {
|
||||||
const freshMessages = await pollMessages()
|
const data = await getMessages({ limit: 50 })
|
||||||
if (freshMessages.length > 0) {
|
if (data.items && data.items.length > 0) {
|
||||||
// 合并缓存和最新消息
|
// 消息去重
|
||||||
if (convId) {
|
const uniqueMessages = data.items.filter(msg => {
|
||||||
messages.value = mergeMessages(messages.value, freshMessages)
|
if (processedMessageIds.value.has(msg.message_id)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
trackProcessedMessageId(msg.message_id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (uniqueMessages.length > 0) {
|
||||||
|
// 合并缓存和历史消息
|
||||||
|
if (convId && messages.value.length > 0) {
|
||||||
|
messages.value = mergeMessages(messages.value, uniqueMessages)
|
||||||
|
} else {
|
||||||
|
messages.value = uniqueMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新最后消息ID
|
||||||
|
const lastMsg = uniqueMessages[uniqueMessages.length - 1]
|
||||||
|
if (lastMsg) {
|
||||||
|
lastMessageId.value = lastMsg.message_id
|
||||||
|
}
|
||||||
|
|
||||||
// 保存到缓存
|
// 保存到缓存
|
||||||
saveMessagesToCache(convId, messages.value)
|
if (convId) {
|
||||||
} else {
|
saveMessagesToCache(convId, messages.value)
|
||||||
messages.value = freshMessages
|
}
|
||||||
|
console.log(`[Store] 历史消息已加载,共 ${messages.value.length} 条`)
|
||||||
}
|
}
|
||||||
// 更新最后消息ID
|
|
||||||
const lastMsg = freshMessages[freshMessages.length - 1]
|
|
||||||
if (lastMsg) {
|
|
||||||
lastMessageId.value = lastMsg.message_id
|
|
||||||
}
|
|
||||||
console.log(`[Store] 后端消息已合并,共 ${messages.value.length} 条`)
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Store] 加载后端消息失败:', e)
|
console.warn('[Store] 加载历史消息失败:', e)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1016,6 +1106,7 @@ export const useConversationStore = defineStore('conversation', () => {
|
|||||||
handleOAuthCallback,
|
handleOAuthCallback,
|
||||||
fetchUserInfo,
|
fetchUserInfo,
|
||||||
fetchCurrentConversation,
|
fetchCurrentConversation,
|
||||||
|
fetchMessages,
|
||||||
sendNewMessage,
|
sendNewMessage,
|
||||||
pollNewMessages,
|
pollNewMessages,
|
||||||
startPolling,
|
startPolling,
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ import {
|
|||||||
import { registerAuthExpiredHandler } from '@/utils/authCallback'
|
import { registerAuthExpiredHandler } from '@/utils/authCallback'
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// localStorage Key 常量
|
// localStorage Key 常量(C8:移除 portal_token)
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
const TOKEN_KEY = 'h5_token'
|
const TOKEN_KEY = 'h5_token'
|
||||||
const PORTAL_TOKEN_KEY = 'portal_token'
|
|
||||||
const EMPLOYEE_ID_KEY = 'employee_id'
|
const EMPLOYEE_ID_KEY = 'employee_id'
|
||||||
const EMPLOYEE_NAME_KEY = 'employee_name'
|
const EMPLOYEE_NAME_KEY = 'employee_name'
|
||||||
/** OAuth2 重定向计数器 key(防止无限重定向循环) */
|
/** OAuth2 重定向计数器 key(防止无限重定向循环) */
|
||||||
@@ -42,8 +41,8 @@ export const useEmployeeStore = defineStore('employee', () => {
|
|||||||
// 响应式状态
|
// 响应式状态
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
/** 访问令牌(Bearer Token)— 优先从 h5_token 读取,降级读取 portal_token */
|
/** 访问令牌(Bearer Token)— 从 h5_token 读取(C8:移除 portal_token) */
|
||||||
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || localStorage.getItem(PORTAL_TOKEN_KEY) || '')
|
const token = ref<string>(localStorage.getItem(TOKEN_KEY) || '')
|
||||||
|
|
||||||
// 页面刷新时:如果 token 存在且有效,重置重定向计数,避免残留计数导致误报"登录状态异常"
|
// 页面刷新时:如果 token 存在且有效,重置重定向计数,避免残留计数导致误报"登录状态异常"
|
||||||
if (token.value && !isTokenExpired(token.value)) {
|
if (token.value && !isTokenExpired(token.value)) {
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<!-- =============================================================================
|
||||||
|
// 企微IT智能服务台 — 阶段5 自动化进度页(H5 员工端)
|
||||||
|
// =============================================================================
|
||||||
|
// 说明:员工查看自动化会话的实时处置进展;当收到 automation.action_required
|
||||||
|
// 事件时弹出高危动作二次确认;会话 resolved 后展示「已解决」反馈。
|
||||||
|
// 约定:Vant4 组件 + 专用 WebSocket(store 内管理)。
|
||||||
|
// ============================================================================= -->
|
||||||
|
<template>
|
||||||
|
<div class="auto-progress">
|
||||||
|
<van-nav-bar :title="sessionTitle" left-arrow @click-left="onBack" />
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<van-loading v-if="store.loading" class="page-loading" type="spinner" color="#07c160" />
|
||||||
|
|
||||||
|
<template v-else-if="store.currentSession">
|
||||||
|
<!-- 状态概览 -->
|
||||||
|
<van-cell-group inset class="block">
|
||||||
|
<van-cell title="状态" :value="statusLabel" />
|
||||||
|
<van-cell title="场景" :value="session.scenario_key || '-'" />
|
||||||
|
<van-cell title="置信度" :value="confidenceText" />
|
||||||
|
<van-cell v-if="session.auto_close_at" title="自动关单" :value="autoCloseText" />
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<!-- 处置进展时间线 -->
|
||||||
|
<div class="section-title">处置进展</div>
|
||||||
|
<van-steps direction="vertical" :active="store.progressEvents.length">
|
||||||
|
<van-step
|
||||||
|
v-for="(ev, i) in store.progressEvents"
|
||||||
|
:key="i"
|
||||||
|
:title="eventTitle(ev)"
|
||||||
|
>
|
||||||
|
{{ formatTime(ev.time) }}
|
||||||
|
</van-step>
|
||||||
|
<van-step v-if="!store.progressEvents.length" title="正在初始化自动化处置…" />
|
||||||
|
</van-steps>
|
||||||
|
|
||||||
|
<!-- 当前动作列表 -->
|
||||||
|
<div class="section-title">执行动作</div>
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-cell
|
||||||
|
v-for="act in session.actions"
|
||||||
|
:key="act.id"
|
||||||
|
:title="act.title"
|
||||||
|
:label="act.description"
|
||||||
|
:value="actionStatusLabel(act)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<van-tag :type="riskTagType(act)" class="act-tag">{{ riskLabel(act) }}</van-tag>
|
||||||
|
</template>
|
||||||
|
</van-cell>
|
||||||
|
<van-cell v-if="!session.actions.length" title="暂无动作" />
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<!-- 高危动作二次确认弹窗 -->
|
||||||
|
<ActionConfirmDialog
|
||||||
|
:visible="!!store.pendingAction"
|
||||||
|
:action="store.pendingAction"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
@cancel="onCancel"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 已解决反馈 / 静默关单提示 -->
|
||||||
|
<ResolveFeedback
|
||||||
|
:visible="store.isResolved && !feedbackDismissed"
|
||||||
|
:session="store.currentSession"
|
||||||
|
@feedback="onFeedback"
|
||||||
|
@update:visible="(v: boolean) => { if (!v) feedbackDismissed = true }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<van-empty v-else description="会话不存在或已结束" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { showToast } from 'vant'
|
||||||
|
import { useAutomationStore } from '@/stores/automation'
|
||||||
|
import ActionConfirmDialog from '@/components/ActionConfirmDialog.vue'
|
||||||
|
import ResolveFeedback from '@/components/ResolveFeedback.vue'
|
||||||
|
import type { AutomationAction, AutomationSession } from '@/api/automation'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useAutomationStore()
|
||||||
|
|
||||||
|
const sessionId = computed(() => (route.params.id as string) || '')
|
||||||
|
const session = computed(() => store.currentSession as AutomationSession)
|
||||||
|
// 已解决反馈弹窗是否已 dismiss(避免 resolved 状态下反复弹出)
|
||||||
|
const feedbackDismissed = ref(false)
|
||||||
|
watch(
|
||||||
|
() => sessionId.value,
|
||||||
|
() => {
|
||||||
|
feedbackDismissed.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const sessionTitle = computed(() => session.value?.title || '自动化处置')
|
||||||
|
const confidenceText = computed(() => `${Math.round((session.value?.confidence || 0) * 100)}%`)
|
||||||
|
const autoCloseText = computed(() => (session.value?.auto_close_at ? formatTime(session.value.auto_close_at) : '-'))
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 生命周期:挂载拉详情 + 连接 WS,卸载断开
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!sessionId.value) {
|
||||||
|
showToast('缺少会话参数')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await store.fetchSession(sessionId.value)
|
||||||
|
store.connectWs(sessionId.value)
|
||||||
|
})
|
||||||
|
onUnmounted(() => store.disconnectWs())
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 事件:确认 / 取消高危动作、已解决反馈
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
async function onConfirm(payload: { confirmed: boolean; note?: string }): Promise<void> {
|
||||||
|
const action = store.pendingAction
|
||||||
|
if (!action) return
|
||||||
|
try {
|
||||||
|
await store.confirmAction(action.id, payload)
|
||||||
|
showToast(payload.confirmed ? '已确认执行' : '已取消执行')
|
||||||
|
} catch {
|
||||||
|
showToast('操作失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCancel(): Promise<void> {
|
||||||
|
// 员工取消:以 confirmed=false 提交(不执行该高危动作)
|
||||||
|
const action = store.pendingAction
|
||||||
|
if (!action) return
|
||||||
|
try {
|
||||||
|
await store.confirmAction(action.id, { confirmed: false })
|
||||||
|
showToast('已取消执行')
|
||||||
|
} catch {
|
||||||
|
showToast('操作失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFeedback(payload: { satisfied: boolean; note?: string }): Promise<void> {
|
||||||
|
if (!sessionId.value) return
|
||||||
|
try {
|
||||||
|
await store.resolveSession(sessionId.value, payload)
|
||||||
|
feedbackDismissed.value = true
|
||||||
|
showToast('感谢反馈')
|
||||||
|
} catch {
|
||||||
|
showToast('提交失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBack(): void {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// 展示辅助
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
const statusLabel = computed(() => statusText(session.value?.status || ''))
|
||||||
|
|
||||||
|
function statusText(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'created':
|
||||||
|
return '已创建'
|
||||||
|
case 'running':
|
||||||
|
return '执行中'
|
||||||
|
case 'paused':
|
||||||
|
return '已暂停'
|
||||||
|
case 'resolved':
|
||||||
|
return '已解决'
|
||||||
|
case 'handoff':
|
||||||
|
return '已转人工'
|
||||||
|
case 'error':
|
||||||
|
return '异常'
|
||||||
|
case 'closed':
|
||||||
|
return '已关闭'
|
||||||
|
default:
|
||||||
|
return s || '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskLabel(a: AutomationAction): string {
|
||||||
|
switch (a.risk_level) {
|
||||||
|
case 'read':
|
||||||
|
return '只读'
|
||||||
|
case 'low':
|
||||||
|
return '低风险'
|
||||||
|
case 'write':
|
||||||
|
return '写操作'
|
||||||
|
case 'high':
|
||||||
|
return '高危'
|
||||||
|
default:
|
||||||
|
return a.risk_level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskTagType(a: AutomationAction): 'primary' | 'success' | 'warning' | 'danger' {
|
||||||
|
switch (a.risk_level) {
|
||||||
|
case 'read':
|
||||||
|
return 'primary'
|
||||||
|
case 'low':
|
||||||
|
return 'success'
|
||||||
|
case 'write':
|
||||||
|
return 'warning'
|
||||||
|
case 'high':
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'primary'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionStatusLabel(a: AutomationAction): string {
|
||||||
|
switch (a.status) {
|
||||||
|
case 'pending':
|
||||||
|
case 'awaiting_approval':
|
||||||
|
return '待确认'
|
||||||
|
case 'approved':
|
||||||
|
return '已通过'
|
||||||
|
case 'rejected':
|
||||||
|
return '已取消'
|
||||||
|
case 'running':
|
||||||
|
return '执行中'
|
||||||
|
case 'done':
|
||||||
|
case 'executed':
|
||||||
|
return '已完成'
|
||||||
|
case 'failed':
|
||||||
|
return '失败'
|
||||||
|
case 'skipped':
|
||||||
|
return '已跳过'
|
||||||
|
default:
|
||||||
|
return a.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTitle(ev: { type: string }): string {
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'automation.progress':
|
||||||
|
return '处置进展更新'
|
||||||
|
case 'automation.action_required':
|
||||||
|
return '需要您确认高危操作'
|
||||||
|
case 'automation.resolved':
|
||||||
|
return '问题已解决'
|
||||||
|
case 'automation.takeover':
|
||||||
|
return '已转人工坐席'
|
||||||
|
case 'automation.error':
|
||||||
|
return '处置异常'
|
||||||
|
default:
|
||||||
|
return ev.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(t).toLocaleString('zh-CN', { hour12: false })
|
||||||
|
} catch {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auto-progress {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-secondary, #f7f8fa);
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.page-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
.block {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #323233);
|
||||||
|
margin: 16px 4px 8px;
|
||||||
|
}
|
||||||
|
.act-tag {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -52,8 +52,42 @@
|
|||||||
通过后端 Mock 登录接口获取真实 Token。<br />
|
通过后端 Mock 登录接口获取真实 Token。<br />
|
||||||
正式上线后将使用企微 OAuth2 静默授权。
|
正式上线后将使用企微 OAuth2 静默授权。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p class="forgot-password">
|
||||||
|
<a href="javascript:;" @click="handleForgotPassword">忘记密码?</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 忘记密码弹窗(通过企微扫码重置) -->
|
||||||
|
<van-dialog
|
||||||
|
v-model:show="showForgotPasswordDialog"
|
||||||
|
title="忘记密码"
|
||||||
|
show-cancel-button
|
||||||
|
confirm-button-text="确认重置"
|
||||||
|
:before-hide="resetForgotPasswordForm"
|
||||||
|
@confirm="handleResetPassword"
|
||||||
|
>
|
||||||
|
<div class="forgot-password-content">
|
||||||
|
<p class="forgot-password-tip">请用企业微信扫码验证身份</p>
|
||||||
|
<div class="wecom-qr-placeholder">
|
||||||
|
<span class="qr-icon">📱</span>
|
||||||
|
<span class="qr-text">企微二维码</span>
|
||||||
|
</div>
|
||||||
|
<van-field
|
||||||
|
v-model="forgotPasswordForm.newPassword"
|
||||||
|
type="password"
|
||||||
|
label="新密码"
|
||||||
|
placeholder="请输入新密码(6-128位)"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="forgotPasswordForm.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
label="确认密码"
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</van-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -63,7 +97,7 @@
|
|||||||
* 测试阶段绕过企微 OAuth2,通过后端 mock-login 获取真实 Bearer Token
|
* 测试阶段绕过企微 OAuth2,通过后端 mock-login 获取真实 Bearer Token
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref } from 'vue'
|
import { ref, reactive } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useEmployeeStore } from '@/stores/employee'
|
import { useEmployeeStore } from '@/stores/employee'
|
||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
@@ -80,6 +114,41 @@ const employeeName = ref<string>('')
|
|||||||
/** 是否正在登录 */
|
/** 是否正在登录 */
|
||||||
const loading = ref<boolean>(false)
|
const loading = ref<boolean>(false)
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// 忘记密码功能
|
||||||
|
// ==========================================================================
|
||||||
|
const showForgotPasswordDialog = ref<boolean>(false)
|
||||||
|
const forgotPasswordForm = reactive({
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 打开忘记密码对话框 */
|
||||||
|
function handleForgotPassword(): void {
|
||||||
|
showForgotPasswordDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置忘记密码表单 */
|
||||||
|
function resetForgotPasswordForm(): void {
|
||||||
|
forgotPasswordForm.newPassword = ''
|
||||||
|
forgotPasswordForm.confirmPassword = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理密码重置(占位,实际需要企微扫码验证) */
|
||||||
|
function handleResetPassword(): void {
|
||||||
|
if (!forgotPasswordForm.newPassword || forgotPasswordForm.newPassword.length < 6) {
|
||||||
|
showToast('密码长度不能少于6位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (forgotPasswordForm.newPassword !== forgotPasswordForm.confirmPassword) {
|
||||||
|
showToast('两次输入的密码不一致')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO: 实际实现需要通过企微扫码验证后调用后端API重置密码
|
||||||
|
showToast('该功能需要企微扫码验证支持')
|
||||||
|
showForgotPasswordDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理登录
|
* 处理登录
|
||||||
* 调用后端 mock-login 接口获取真实 Bearer Token
|
* 调用后端 mock-login 接口获取真实 Bearer Token
|
||||||
@@ -179,4 +248,49 @@ async function handleLogin(): Promise<void> {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 8px 0 0 0;
|
margin: 8px 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 忘记密码链接 */
|
||||||
|
.forgot-password {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password a {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent, #07C160);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 忘记密码弹窗内容 */
|
||||||
|
.forgot-password-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password-tip {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wecom-qr-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user