feat(ctrt): 完成 CTRT-01~03 响应契约统一 - 三端拦截器+portal_token清理+调用点适配

This commit is contained in:
Simon
2026-07-07 22:56:49 +08:00
parent d56a9a6079
commit 6f0fbbb066
48 changed files with 6183 additions and 613 deletions
+28
View File
@@ -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.
+3 -1
View File
@@ -5,9 +5,11 @@
<!-- 移动端视口设置 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 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>
<!-- 企微 JS-SDK (用于企微客户端检测和快捷登录) -->
<script src="https://res.wx.qq.com/wwopen/js/jwxwork-1.0.0.js"></script>
<!-- ElementPlus 图标 -->
<link rel="icon" type="image/svg+xml" href="/itagent/vite.svg" />
</head>
+1200
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -3,10 +3,13 @@
"version": "1.0.0",
"private": true,
"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",
"scripts": {
"dev": "vite",
"dev": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js",
"build": "vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
@@ -14,9 +17,13 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"axios": "^1.7.0",
"cropperjs": "^2.1.1",
"element-plus": "^2.7.0",
"fabric": "^7.4.0",
"html2canvas-pro": "^2.0.4",
"js-web-screen-shot": "^2.0.2",
"pinia": "^2.1.0",
"snapdom": "^0.1.2",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"vue3-emoji-picker": "^1.1.8"
+1 -2
View File
@@ -120,9 +120,8 @@ async function handleAuthExpired(source: 'http401' | 'biz1002'): Promise<void> {
// 第二步:刷新失败,清除凭证并跳转登录
console.warn('[API] Token 刷新失败,清除凭证并跳转登录')
// 清除本地 token
// 清除本地 tokenC8:移除 portal_token 遗留)
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem('portal_token')
// 跳转登录页
ElMessage.warning('登录已过期,请重新登录')
@@ -82,12 +82,12 @@ export function useWebSocket() {
let agentId = agentStore.userId
let token = agentStore.token
// 如果 store 中没有,从 localStorage 读取(解决 QR 码登录时 store 未及时刷新的问题
// 如果 store 中没有,从 localStorage 读取(C8:移除 portal_token
if (!agentId) {
agentId = localStorage.getItem('agent_user_id') || ''
}
if (!token) {
token = localStorage.getItem('agent_token') || localStorage.getItem('portal_token') || ''
token = localStorage.getItem('agent_token') || ''
}
// 调试日志
+9 -4
View File
@@ -50,6 +50,13 @@ const routes = [
// 这里依靠守卫检查 token,但不强制 MFA 已绑定(否则永远进不去)
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 读取并保存到 localStorageC8:移除 portal_token
// ========================================================================
const urlParams = new URLSearchParams(window.location.search)
const urlToken = urlParams.get('token')
if (urlToken) {
// 保存 token 到坐席端 localStorage key
// 保存 token 到坐席端 localStorage
localStorage.setItem('agent_token', urlToken)
// 同时保存到 portal_token key(方便跨端共享)
localStorage.setItem('portal_token', urlToken)
// 清除 URL 参数,避免刷新页面重复读取
const cleanUrl = window.location.pathname
window.history.replaceState({}, '', cleanUrl)
+3 -4
View File
@@ -18,10 +18,9 @@ import router from '@/router'
import { useWebSocket } from '@/composables/useWebSocket'
// --------------------------------------------------------------------------
// Token 存储 key
// Token 存储 keyC8:移除 portal_token
// --------------------------------------------------------------------------
const TOKEN_KEY = 'agent_token'
const PORTAL_TOKEN_KEY = 'portal_token'
const AGENT_USER_ID_KEY = 'agent_user_id'
// --------------------------------------------------------------------------
@@ -35,8 +34,8 @@ export const useAgentStore = defineStore('agent', () => {
/** 当前登录的坐席信息 */
const agentInfo = ref<Agent | null>(null)
/** 认证 token — 优先从 agent_token 读取,降级读取 portal_token */
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY) || localStorage.getItem(PORTAL_TOKEN_KEY))
/** 认证 token — 从 agent_token 读取C8:移除 portal_token */
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
/** 坐席用户ID */
const agentUserId = ref<string | null>(localStorage.getItem(AGENT_USER_ID_KEY))
+2 -6
View File
@@ -21,14 +21,10 @@ import {
} from '@/api/automation'
// --------------------------------------------------------------------------
// WebSocket 辅助
// WebSocket 辅助C8:移除 portal_token
// --------------------------------------------------------------------------
function getAgentToken(): string {
return (
localStorage.getItem('agent_token') ||
localStorage.getItem('portal_token') ||
''
)
return localStorage.getItem('agent_token') || ''
}
function buildWsUrl(sessionId: string): string {
+4 -5
View File
@@ -187,12 +187,11 @@ function onRightResizeEnd(): void {
onMounted(async () => {
// 修复 v0.5.1: 企微点坐席直接打开 /itagent/ 时,URL 没 ?token=
// 路由守卫虽然会跳到 /itportal/,但在这之前 axios 已经发了请求 → 弹 401
// 这里在 onMounted 第一行主动检查 token,没 token 立刻跳 portal,避免 401 弹错
// 路由守卫虽然会跳到 /login,但在这之前 axios 已经发了请求 → 弹 401
// 这里在 onMounted 第一行主动检查 token,没 token 立刻跳登录页,避免 401 弹错C8:移除 portal_token
const hasAgentToken = localStorage.getItem('agent_token')
const hasPortalToken = localStorage.getItem('portal_token')
if (!hasAgentToken && !hasPortalToken) {
window.location.href = '/itportal/'
if (!hasAgentToken) {
window.location.href = '/login'
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>
+9 -1
View File
@@ -20,8 +20,16 @@ export default defineConfig({
server: {
// 开发服务器端口(避免和H5前端冲突)
port: 5173,
// 端口被占用时自动尝试下一个端口
strictPort: false,
// 自动打开浏览器
open: true,
open: false,
// 禁用热更新,改用页面刷新(更稳定)
hmr: false,
// 增加超时时间
timeout: 30000,
// 最大并发请求数
maxConcurrency: 200,
// API 代理:将 /api 请求转发到后端,解决开发环境跨域问题
proxy: {
'/api': {