WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作

This commit is contained in:
Simon
2026-07-07 21:52:11 +08:00
parent 242c1967ff
commit fab75760e0
203 changed files with 21504 additions and 3345 deletions
+36 -5
View File
@@ -6,14 +6,45 @@
// ============================================================================= -->
<template>
<!-- 路由视图显示当前路由对应的页面组件 -->
<router-view />
<div class="app-container">
<!-- 测试环境标识 - 右上角覆盖层不占用布局 -->
<div v-if="isTestEnv" class="test-env-badge">
🔧 测试环境
</div>
<!-- 路由视图显示当前路由对应的页面组件 -->
<router-view />
</div>
</template>
<script setup lang="ts">
// 根组件无需额外逻辑,只负责渲染路由页面
import { computed } from 'vue'
// 检测是否为测试环境
const isTestEnv = computed(() => {
return import.meta.env.MODE === 'development' ||
window.location.hostname.includes('localhost') ||
window.location.hostname.includes('127.0.0.1')
})
</script>
<style>
/* 根组件样式已在 global.css 中定义 */
<style scoped>
.app-container {
position: relative;
min-height: 100vh;
}
.test-env-badge {
position: absolute;
top: 8px;
left: 8px;
z-index: 9999;
background: linear-gradient(135deg, #ff6b6b 0%, #ffa500 100%);
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
pointer-events: none;
}
</style>
+7 -7
View File
@@ -69,7 +69,7 @@ export async function login(userId: string, password: string, otpCode?: string):
password: password,
otp_code: otpCode || undefined,
})
return response.data.data
return response
}
/**
@@ -80,7 +80,7 @@ export async function login(userId: string, password: string, otpCode?: string):
*/
export async function getCurrentAgent(): Promise<Agent> {
const response: AxiosResponse = await apiClient.get('/agents/me')
return response.data.data
return response
}
/**
@@ -94,7 +94,7 @@ export async function updateAgentStatus(status: string): Promise<Agent> {
const response: AxiosResponse = await apiClient.put('/agents/me/status', {
status,
})
return response.data.data
return response
}
/**
@@ -110,7 +110,7 @@ export async function getAgents(status?: string): Promise<AgentListData> {
params.status = status
}
const response: AxiosResponse = await apiClient.get('/agents', { params })
return response.data.data
return response
}
// --------------------------------------------------------------------------
@@ -142,7 +142,7 @@ export interface OtpVerifyData {
*/
export async function bindOtp(): Promise<OtpBindData> {
const response: AxiosResponse = await apiClient.post('/agents/otp-bind')
return response.data.data
return response
}
/**
@@ -158,7 +158,7 @@ export async function verifyOtp(userId: string, otpCode: string): Promise<OtpVer
user_id: userId,
otp_code: otpCode,
})
return response.data.data
return response
}
/**
@@ -169,5 +169,5 @@ export async function verifyOtp(userId: string, otpCode: string): Promise<OtpVer
*/
export async function unbindOtp(): Promise<{ message: string }> {
const response: AxiosResponse = await apiClient.post('/agents/otp-unbind')
return response.data.data
return response
}
+67
View File
@@ -0,0 +1,67 @@
// =============================================================================
// 企微IT智能服务台 — 会话标注 API
// =============================================================================
// 说明:坐席对AI回复进行标注
// 对应后端 API/api/annotations
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// 类型定义
// --------------------------------------------------------------------------
/** 标注类型 */
export type AnnotationFeedback = 'useful' | 'useless'
/** 标注对象 */
export interface Annotation {
id: string
conversation_id: string
agent_id: string
message_id: string
feedback: AnnotationFeedback
comment?: string
created_at: string
}
/** 创建标注参数 */
export interface CreateAnnotationParams {
conversation_id: string
message_id: string
feedback: AnnotationFeedback
comment?: string
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 创建会话标注
* 坐席对AI回复进行标注(有用/无用)
*
* @param params - 标注参数
* @returns 创建的标注
*/
export async function createAnnotation(
params: CreateAnnotationParams
): Promise<Annotation> {
const response: AxiosResponse = await apiClient.post('/annotations', params)
return response
}
/**
* 获取会话的所有标注
*
* @param conversationId - 会话ID
* @returns 标注列表
*/
export async function getAnnotations(
conversationId: string
): Promise<{ items: Annotation[] }> {
const response: AxiosResponse = await apiClient.get(
`/annotations/${conversationId}`
)
return response
}
+195
View File
@@ -0,0 +1,195 @@
// =============================================================================
// 企微IT智能服务台 — 阶段5 自动化闭环 API 调用模块(坐席端)
// =============================================================================
// 说明:封装自动化会话相关的 HTTP 请求,对应后端 /api/itportal/automation/*
// =============================================================================
import apiClient from './index'
// --------------------------------------------------------------------------
// 类型定义(与后端 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 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 CreateSessionPayload {
conversation_id?: string | null
employee_id: string
description: string
mode?: string
}
export interface ApprovalPayload {
decision: 'approve' | 'reject'
note?: string
}
export interface ConfirmPayload {
confirmed: boolean
note?: string
}
export interface TakeoverPayload {
agent_id: string
note?: string
}
export interface FeedbackPayload {
satisfied: boolean
note?: string
}
// --------------------------------------------------------------------------
// 坐席端接口
// --------------------------------------------------------------------------
/** 创建自动化会话(坐席发起) */
export async function createAutomationSession(payload: CreateSessionPayload): Promise<AutomationSession> {
const res = await apiClient.post('/itportal/automation/sessions', payload)
return res as AutomationSession
}
/** 会话列表 */
export async function listAutomationSessions(params?: {
employee_id?: string
status?: string
page?: number
page_size?: number
}): Promise<AutomationSession[]> {
const res = await apiClient.get('/itportal/automation/sessions', { params })
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 approveAutomationSession(
sessionId: string,
payload: ApprovalPayload,
): Promise<AutomationSession> {
const res = await apiClient.post(`/itportal/automation/sessions/${sessionId}/approve`, payload)
return res as AutomationSession
}
/** 转人工接管 */
export async function takeoverAutomationSession(
sessionId: string,
payload: TakeoverPayload,
): Promise<AutomationSession> {
const res = await apiClient.post(`/itportal/automation/sessions/${sessionId}/takeover`, payload)
return res as AutomationSession
}
// --------------------------------------------------------------------------
// 管理端接口
// --------------------------------------------------------------------------
export async function listScenarios(): Promise<ScenarioConfig[]> {
const res = await apiClient.get('/itportal/automation/admin/scenarios')
return res as ScenarioConfig[]
}
export async function updateScenario(
scenarioKey: string,
payload: Record<string, any>,
): Promise<ScenarioConfig> {
const res = await apiClient.put(`/itportal/automation/admin/scenarios/${scenarioKey}`, payload)
return res as ScenarioConfig
}
export async function listRuleVersions(scenarioKey?: string): Promise<RuleVersion[]> {
const res = await apiClient.get('/itportal/automation/admin/rule-versions', {
params: scenarioKey ? { scenario_key: scenarioKey } : {},
})
return res as RuleVersion[]
}
export async function getAutomationMetrics(): Promise<AutoMetrics> {
const res = await apiClient.get('/itportal/automation/admin/metrics')
return res as AutoMetrics
}
+34 -14
View File
@@ -135,7 +135,7 @@ export async function getConversations(params?: {
page_size?: number
}): Promise<ConversationListData> {
const response: AxiosResponse = await apiClient.get('/conversations', { params })
return response.data.data
return response
}
/**
@@ -146,7 +146,7 @@ export async function getConversations(params?: {
*/
export async function getConversation(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.get(`/conversations/${conversationId}`)
return response.data.data
return response
}
/**
@@ -161,7 +161,7 @@ export async function assignConversation(conversationId: string, agentId: string
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/assign`, {
agent_id: agentId,
})
return response.data.data
return response
}
/**
@@ -173,7 +173,7 @@ export async function assignConversation(conversationId: string, agentId: string
*/
export async function resolveConversation(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/resolve`)
return response.data.data
return response
}
/**
@@ -185,7 +185,7 @@ export async function resolveConversation(conversationId: string): Promise<Conve
*/
export async function togglePin(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/pin`)
return response.data.data
return response
}
/**
@@ -197,7 +197,7 @@ export async function togglePin(conversationId: string): Promise<Conversation> {
*/
export async function toggleTodo(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/todo`)
return response.data.data
return response
}
/**
@@ -211,7 +211,7 @@ export async function transferConversation(conversationId: string, targetAgentId
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/transfer`, {
agent_id: targetAgentId,
})
return response.data.data
return response
}
/**
@@ -223,7 +223,7 @@ export async function transferConversation(conversationId: string, targetAgentId
*/
export async function grabConversation(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/grab`)
return response.data.data
return response
}
/**
@@ -242,7 +242,7 @@ export async function inviteCollaborator(
`/conversations/${conversationId}/invite`,
{ agent_id: agentId }
)
return response.data.data
return response
}
/**
@@ -254,7 +254,7 @@ export async function inviteCollaborator(
*/
export async function leaveCollaboration(conversationId: string): Promise<Conversation> {
const response: AxiosResponse = await apiClient.post(`/conversations/${conversationId}/leave`)
return response.data.data
return response
}
// --------------------------------------------------------------------------
@@ -290,7 +290,7 @@ export async function inviteParticipant(
`/conversations/${conversationId}/invite-participant`,
params
)
return response.data.data
return response
}
/**
@@ -309,7 +309,7 @@ export async function joinConversation(
`/conversations/${conversationId}/join`,
{ employee_id: employeeId }
)
return response.data.data
return response
}
/**
@@ -327,7 +327,7 @@ export async function removeParticipant(
const response: AxiosResponse = await apiClient.delete(
`/conversations/${conversationId}/participants/${userId}`
)
return response.data.data
return response
}
/**
@@ -345,5 +345,25 @@ export async function leaveAsParticipant(
`/conversations/${conversationId}/leave-participant`,
{ employee_id: employeeId }
)
return response.data.data
return response
}
// --------------------------------------------------------------------------
// 满意度评价 API (P1-25)
// --------------------------------------------------------------------------
/**
* 发送评价邀请
* 坐席结单后,系统向员工推送评价邀请
*
* @param conversationId - 会话ID
* @returns 发送结果
*/
export async function sendEvaluationInvite(
conversationId: string
): Promise<{ message: string }> {
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/send-evaluation-invite`
)
return response
}
+13 -7
View File
@@ -192,19 +192,21 @@ apiClient.interceptors.response.use(
handleAuthExpired('biz1002')
}
// 返回 rejected Promise,让调用方的 catch 能捕获
return Promise.reject(new Error(res.message || '请求失败'))
// 返回 rejected Promise,让调用方的 catch 能捕获Scheme A: {code, message}
return Promise.reject({ code: res.code, message: res.message || '请求失败' })
}
// 业务成功:返回完整响应(调用方从 response.data.data 获取业务数据
return response
// 业务成功:Scheme A — 直接返回 inner data(三端统一契约
return res.data
},
async (error) => {
// 网络错误或服务器错误(HTTP 状态码非 2xx)
// 网络错误或服务器错误(HTTP 状态码非 2xx)— 统一 reject {code, message}CTRT-03
let message = '网络异常,请稍后重试'
let code = -1
if (error.response) {
// 服务器返回了错误状态码
code = error.response.status
switch (error.response.status) {
case 401:
// AUTH-P0-02: 先尝试静默刷新 Token,成功则重放请求
@@ -217,6 +219,10 @@ apiClient.interceptors.response.use(
case 404:
message = '请求的资源不存在'
break
case 422:
// Pydantic 验证错误,显示详细错误信息
message = error.response.data?.detail || '请求验证失败'
break
case 500:
message = '服务器内部错误'
break
@@ -229,11 +235,11 @@ apiClient.interceptors.response.use(
}
// 显示错误提示(401 时不显示通用提示,因为会自动处理)
if (!error.response || error.response.status !== 401) {
if (code !== 401) {
ElMessage.error(message)
}
return Promise.reject(error)
return Promise.reject({ code, message })
}
)
+25 -13
View File
@@ -87,7 +87,7 @@ export async function getMessages(
`/conversations/${conversationId}/messages`,
{ params }
)
return response.data.data
return response
}
/**
@@ -111,20 +111,32 @@ export async function sendMessage(
reply_to_id?: string
}
): Promise<Message> {
// 调试日志
console.log('[sendMessage] 发送消息:', { conversationId, content, msgType, options })
// 过滤掉 undefined 的字段,避免传递无效数据
const filteredOptions = options ? Object.fromEntries(
Object.entries(options).filter(([_, v]) => v !== undefined)
) : {}
// 确保必填字段有值
const requestBody = {
content: content || '',
msg_type: msgType || 'text',
...filteredOptions,
}
console.log('[sendMessage] 请求体:', requestBody)
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/messages`,
{
content,
msg_type: msgType,
...options,
},
requestBody,
{
// 图片/文件消息后端处理可能较慢(存储 + 企微API),增加超时到30秒
// 修复截图发送超时BugapiClient默认10s不够
timeout: 30000,
}
)
return response.data.data
return response
}
/**
@@ -147,7 +159,7 @@ export async function pollMessages(
`/conversations/${conversationId}/messages/poll`,
{ params }
)
return response.data.data
return response
}
/**
@@ -160,7 +172,7 @@ export async function recallMessage(messageId: string): Promise<any> {
const response: AxiosResponse = await apiClient.post(
`/messages/${messageId}/recall`
)
return response.data
return response
}
/**
@@ -173,7 +185,7 @@ export async function deleteMessage(messageId: string): Promise<any> {
const response: AxiosResponse = await apiClient.delete(
`/messages/${messageId}`
)
return response.data
return response
}
/**
@@ -186,7 +198,7 @@ export async function markConversationRead(conversationId: string): Promise<any>
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/mark-read`
)
return response.data
return response
}
/**
@@ -208,7 +220,7 @@ export async function uploadImage(file: File): Promise<{
// ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
{ headers: { 'Content-Type': undefined } }
)
return response.data.data
return response
}
/**
@@ -230,5 +242,5 @@ export async function uploadMessageFile(file: File): Promise<{
// ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
{ headers: { 'Content-Type': undefined } }
)
return response.data.data
return response
}
+5 -5
View File
@@ -107,7 +107,7 @@ export interface MfaDisableData {
*/
export async function getMfaStatus(): Promise<MfaStatusData> {
const response: AxiosResponse = await apiClient.get('/mfa/status')
return response.data.data
return response
}
/**
@@ -120,7 +120,7 @@ export async function getMfaStatus(): Promise<MfaStatusData> {
*/
export async function bindStart(): Promise<MfaBindStartData> {
const response: AxiosResponse = await apiClient.post('/mfa/bind/start')
return response.data.data
return response
}
/**
@@ -133,7 +133,7 @@ export async function bindStart(): Promise<MfaBindStartData> {
export async function bindConfirm(otpCode: string): Promise<MfaBindConfirmData> {
const body: MfaBindConfirmRequest = { otp_code: otpCode }
const response: AxiosResponse = await apiClient.post('/mfa/bind/confirm', body)
return response.data.data
return response
}
/**
@@ -146,7 +146,7 @@ export async function bindConfirm(otpCode: string): Promise<MfaBindConfirmData>
export async function verifyMfa(otpCode: string): Promise<MfaVerifyData> {
const body: MfaVerifyRequest = { otp_code: otpCode }
const response: AxiosResponse = await apiClient.post('/mfa/verify', body)
return response.data.data
return response
}
/**
@@ -158,5 +158,5 @@ export async function verifyMfa(otpCode: string): Promise<MfaVerifyData> {
export async function disableMfa(otpCode: string): Promise<MfaDisableData> {
const body: MfaDisableRequest = { otp_code: otpCode }
const response: AxiosResponse = await apiClient.post('/mfa/disable', body)
return response.data.data
return response
}
+2 -2
View File
@@ -64,7 +64,7 @@ export interface QrcodePollData {
*/
export async function createQrcode(): Promise<QrcodeCreateData> {
const response: AxiosResponse = await apiClient.post('/auth_qrcode/create')
return response.data.data
return response
}
/**
@@ -76,5 +76,5 @@ export async function createQrcode(): Promise<QrcodeCreateData> {
*/
export async function pollQrcode(ticket: string): Promise<QrcodePollData> {
const response: AxiosResponse = await apiClient.get(`/auth_qrcode/poll/${ticket}`)
return response.data.data
return response
}
+6 -6
View File
@@ -84,7 +84,7 @@ export async function getQuickReplies(category?: string): Promise<QuickReplyList
params.category = category
}
const response: AxiosResponse = await apiClient.get('/quick-replies', { params })
return response.data.data
return response
}
/**
@@ -95,7 +95,7 @@ export async function getQuickReplies(category?: string): Promise<QuickReplyList
*/
export async function createQuickReply(data: QuickReplyCreateParams): Promise<QuickReply> {
const response: AxiosResponse = await apiClient.post('/quick-replies', data)
return response.data.data
return response
}
/**
@@ -108,7 +108,7 @@ export async function createQuickReply(data: QuickReplyCreateParams): Promise<Qu
*/
export async function updateQuickReply(templateId: string, data: QuickReplyUpdateParams): Promise<QuickReply> {
const response: AxiosResponse = await apiClient.put(`/quick-replies/${templateId}`, data)
return response.data.data
return response
}
/**
@@ -157,7 +157,7 @@ export interface AgentNoteListData {
*/
export async function getAgentNotes(employeeId: string): Promise<AgentNoteListData> {
const response: AxiosResponse = await apiClient.get(`/agent-notes/${employeeId}`)
return response.data.data
return response
}
/**
@@ -178,7 +178,7 @@ export async function createAgentNote(
agent_id: agentId,
content,
})
return response.data.data
return response
}
/**
@@ -192,7 +192,7 @@ export async function updateAgentNote(noteId: string, content: string): Promise<
const response: AxiosResponse = await apiClient.put(`/agent-notes/${noteId}`, {
content,
})
return response.data.data
return response
}
/**
+2 -2
View File
@@ -35,7 +35,7 @@ export interface EmergencyModeToggle {
*/
export async function getEmergencyMode(): Promise<EmergencyModeData> {
const response: AxiosResponse = await apiClient.get('/system/emergency-mode')
return response.data.data
return response
}
/**
@@ -47,5 +47,5 @@ export async function toggleEmergencyMode(enabled: boolean): Promise<EmergencyMo
const response: AxiosResponse = await apiClient.put('/system/emergency-mode', {
emergency_mode: enabled,
})
return response.data.data
return response
}
+3 -3
View File
@@ -62,7 +62,7 @@ export async function getTodoItems(params?: {
priority?: string
}): Promise<TodoItemListData> {
const response: AxiosResponse = await apiClient.get('/todo-items', { params })
return response.data.data
return response
}
/**
@@ -73,7 +73,7 @@ export async function getTodoItems(params?: {
*/
export async function getTodoItem(id: string): Promise<TodoItemData> {
const response: AxiosResponse = await apiClient.get(`/todo-items/${id}`)
return response.data.data
return response
}
/**
@@ -87,5 +87,5 @@ export async function updateTodoStatus(id: string, status: string): Promise<Todo
const response: AxiosResponse = await apiClient.put(`/todo-items/${id}/status`, {
status,
})
return response.data.data
return response
}
+3 -3
View File
@@ -83,7 +83,7 @@ export async function getTroubleshootingTemplates(params?: {
category?: string
}): Promise<TroubleshootingTemplateListData> {
const response: AxiosResponse = await apiClient.get('/troubleshooting-templates', { params })
return response.data.data
return response
}
/**
@@ -94,7 +94,7 @@ export async function getTroubleshootingTemplates(params?: {
*/
export async function getTroubleshootingTemplate(id: string): Promise<TroubleshootingTemplate> {
const response: AxiosResponse = await apiClient.get(`/troubleshooting-templates/${id}`)
return response.data.data
return response
}
/**
@@ -112,5 +112,5 @@ export async function updateEmployeeItLevel(
`/employees/${employeeId}/it-level`,
{ it_level: itLevel, source: 'manual' }
)
return response.data.data
return response
}
+1 -1
View File
@@ -83,7 +83,7 @@ async function uploadWithRetry(formData: FormData, maxRetries: number = 3): Prom
},
timeout: 60000,
})
return response.data.data
return response
} catch (err) {
if (attempt === maxRetries) throw err
// 指数退避:1s, 2s, 4s
+4 -4
View File
@@ -58,7 +58,7 @@ export async function generateDraft(conversationId: string): Promise<DraftResult
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/wingman/draft`
)
return response.data.data
return response
}
/**
@@ -73,7 +73,7 @@ export async function generateSummary(conversationId: string): Promise<SummaryRe
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/wingman/summary`
)
return response.data.data
return response
}
/**
@@ -87,7 +87,7 @@ export async function suggestTags(conversationId: string): Promise<TagsResult> {
const response: AxiosResponse = await apiClient.post(
`/conversations/${conversationId}/wingman/tags`
)
return response.data.data
return response
}
/**
@@ -106,5 +106,5 @@ export async function saveConversationTags(
`/conversations/${conversationId}/tags`,
{ tags }
)
return response.data.data
return response
}
@@ -402,7 +402,7 @@ function getTagType(key: string): '' | 'success' | 'info' | 'warning' | 'danger'
blocking: 'danger',
impact_scope: 'warning',
}
return tagTypeMap[key] || ''
return tagTypeMap[key] || 'info'
}
/**
@@ -0,0 +1,198 @@
<!-- =============================================================================
// 企微IT智能服务台 — 自动化动作审批卡片
// =============================================================================
// 说明:展示单个待审批/确认的高危动作,提供坐席审批 通过/驳回 操作。
// ============================================================================= -->
<template>
<el-card class="action-approval-card" shadow="hover" :body-style="{ padding: '14px' }">
<div class="card-header">
<div class="title-row">
<span class="action-title">{{ action.title }}</span>
<el-tag :type="riskTagType" size="small" effect="dark">{{ riskLabel }}</el-tag>
</div>
<div class="action-desc">{{ action.description }}</div>
<div v-if="action.action_type" class="action-type">类型{{ action.action_type }}</div>
</div>
<!-- 动作入参 -->
<div v-if="action.payload" class="payload-block">
<div class="block-label">动作参数</div>
<pre class="payload-pre">{{ prettyPayload }}</pre>
</div>
<!-- 审批单信息 -->
<div v-if="ticket" class="ticket-block">
<el-tag size="small" :type="ticket.status === 'pending' ? 'warning' : 'info'">
审批渠道{{ ticket.channel === 'h5' ? '员工 H5 确认' : '坐席审批' }}
</el-tag>
<span class="ticket-status">状态{{ ticketStatusLabel }}</span>
</div>
<!-- 操作区 -->
<div class="action-buttons">
<el-button
type="success"
:loading="submitting"
@click="onApprove"
>通过并执行</el-button>
<el-button
type="danger"
:loading="submitting"
@click="onReject"
>驳回转人工</el-button>
</div>
<el-input
v-model="note"
class="note-input"
size="small"
placeholder="审批意见(可选)"
/>
</el-card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { AutomationAction, ApprovalTicket } from '@/api/automation'
const props = defineProps<{
action: AutomationAction
ticket?: ApprovalTicket | null
}>()
const emit = defineEmits<{
(e: 'approve', payload: { actionId: string; note?: string }): void
(e: 'reject', payload: { actionId: string; note?: string }): void
}>()
const note = ref('')
const submitting = ref(false)
const riskLabel = computed(() => {
switch (props.action.risk_level) {
case 'read':
return '只读'
case 'low':
return '低风险'
case 'high':
return '高危'
default:
return props.action.risk_level
}
})
const riskTagType = computed(() => {
switch (props.action.risk_level) {
case 'read':
return 'info'
case 'low':
return 'success'
case 'high':
return 'danger'
default:
return 'info'
}
})
const ticketStatusLabel = computed(() => {
if (!props.ticket) return '无'
switch (props.ticket.status) {
case 'pending':
return '待审批'
case 'approved':
return '已通过'
case 'rejected':
return '已驳回'
default:
return props.ticket.status
}
})
const prettyPayload = computed(() => {
try {
return JSON.stringify(props.action.payload, null, 2)
} catch {
return String(props.action.payload)
}
})
async function onApprove(): Promise<void> {
submitting.value = true
try {
emit('approve', { actionId: props.action.id, note: note.value || undefined })
} finally {
submitting.value = false
}
}
async function onReject(): Promise<void> {
submitting.value = true
try {
emit('reject', { actionId: props.action.id, note: note.value || undefined })
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.action-approval-card {
margin-bottom: 12px;
border-left: 3px solid var(--el-color-warning);
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.action-title {
font-weight: 600;
font-size: 14px;
}
.action-desc {
color: var(--el-text-color-secondary);
font-size: 13px;
margin-top: 4px;
}
.action-type {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-top: 2px;
}
.payload-block {
margin-top: 8px;
}
.block-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.payload-pre {
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;
}
.ticket-block {
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.ticket-status {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.action-buttons {
margin-top: 12px;
display: flex;
gap: 8px;
}
.note-input {
margin-top: 8px;
}
</style>
@@ -0,0 +1,77 @@
<!-- =============================================================================
// 企微IT智能服务台 — 转人工接管面板
// =============================================================================
// 说明:坐席对自动化会话执行「转人工接管」操作。
// ============================================================================= -->
<template>
<el-card class="takeover-panel" shadow="never" :body-style="{ padding: '14px' }">
<div class="panel-title">转人工接管</div>
<el-alert
type="info"
:closable="false"
title="接管后该会话将由人工坐席继续处理,自动化处置终止。"
style="margin-bottom: 12px;"
/>
<el-form label-width="80px">
<el-form-item label="坐席ID">
<el-input v-model="agentId" placeholder="当前坐席ID(默认本机)" />
</el-form-item>
<el-form-item label="接管说明">
<el-input
v-model="note"
type="textarea"
:rows="2"
placeholder="可选,说明接管原因"
/>
</el-form-item>
</el-form>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onTakeover">
确认接管
</el-button>
</div>
</el-card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
sessionId: string
defaultAgentId?: string
}>()
const emit = defineEmits<{
(e: 'takeover', payload: { agentId: string; note?: string }): void
}>()
const agentId = ref(props.defaultAgentId || '')
const note = ref('')
const submitting = ref(false)
async function onTakeover(): Promise<void> {
if (!agentId.value) {
return
}
submitting.value = true
try {
emit('takeover', { agentId: agentId.value, note: note.value || undefined })
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.takeover-panel {
margin-top: 12px;
}
.panel-title {
font-weight: 600;
margin-bottom: 10px;
}
.actions {
display: flex;
justify-content: flex-end;
}
</style>
@@ -191,6 +191,7 @@ import { Loading } from '@element-plus/icons-vue'
import { useConversationStore } from '@/stores/conversation'
import { useAgentStore } from '@/stores/agent'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { sendEvaluationInvite } from '@/api/conversation'
import type { Message } from '@/api/message'
import MessageBubble from './MessageBubble.vue'
import ReplyBox from './ReplyBox.vue'
@@ -367,6 +368,16 @@ async function handleConfirmSummary(): Promise<void> {
await conversationStore.resolveConv(conversationStore.currentConversation.id)
summaryDialogVisible.value = false
ElMessage.success('已结单')
// 结单成功后自动发送评价邀请(P1-25)
const convId = conversationStore.currentConversation.id
try {
await sendEvaluationInvite(convId)
console.log('[ChatArea] 已发送评价邀请')
} catch (inviteError) {
// 评价邀请发送失败不影响结单流程,只记录日志
console.warn('[ChatArea] 发送评价邀请失败:', inviteError)
}
} catch (error) {
console.error('结单失败:', error)
}
@@ -26,6 +26,12 @@
<!-- 快捷工具栏 -->
<div class="chat-toolbar">
<!-- 截图功能 - 放在最前面 -->
<button class="tb-btn" title="截图" @click="handleToolbarClick('screenshot')">
📷
<span class="tb-tip">截图</span>
</button>
<div class="emoji-wrapper">
<button class="tb-btn" title="表情" @click="showEmojiPicker = !showEmojiPicker">
😊
@@ -44,17 +50,7 @@
</div>
<div v-if="showEmojiPicker" class="emoji-picker-overlay" @click="showEmojiPicker = false"></div>
</div>
<!-- 图片/截图功能已移除 -->
<!--
<button class="tb-btn" title="图片" @click="handleToolbarClick('image')">
🖼
<span class="tb-tip">图片</span>
</button>
<button class="tb-btn" title="截图" @click="handleToolbarClick('screenshot')">
<span class="tb-tip">截图</span>
</button>
-->
<button class="tb-btn" title="文件" @click="handleToolbarClick('file')">
📎
<span class="tb-tip">文件</span>
+111 -16
View File
@@ -51,31 +51,17 @@
<!-- 点击表情面板外部关闭 -->
<div v-if="showEmojiPicker" class="emoji-picker-overlay" @click="showEmojiPicker = false"></div>
</div>
<button class="tb-btn" title="图片" @click="handleToolbarClick('image')">
🖼
<span class="tb-tip">图片</span>
</button>
<button class="tb-btn" title="截图" @click="handleToolbarClick('screenshot')">
<span class="tb-tip">截图</span>
</button>
<button class="tb-btn" title="文件" @click="handleToolbarClick('file')">
📎
<span class="tb-tip">文件</span>
</button>
<button class="tb-btn" title="语音" @click="handleToolbarClick('voice')">
🎤
<span class="tb-tip">语音</span>
</button>
<div class="tb-sep"></div>
<button class="tb-btn" title="邀请员工/部门" @click="showInviteDialog = true">
👥
<span class="tb-tip">邀请</span>
</button>
<button class="tb-btn" title="远程协助" @click="handleToolbarClick('remote')">
🖥
<span class="tb-tip">远程协助</span>
</button>
<button class="tb-btn" title="快速回复" @click="handleToolbarClick('quickReply')">
<span class="tb-tip">快速回复</span>
@@ -125,6 +111,13 @@
@confirm="onScreenshotConfirm"
@cancel="onScreenshotCancel"
/>
<!-- 框选截图模式 -->
<ScreenCapture
v-if="showScreenCapture"
@confirm="onBoxCaptureConfirm"
@cancel="onBoxCaptureCancel"
/>
</div>
</template>
@@ -138,6 +131,7 @@ import html2canvas from 'html2canvas-pro'
import { useConversationStore } from '@/stores/conversation'
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
import ScreenshotEditor from './ScreenshotEditor.vue'
import ScreenCapture from './ScreenCapture.vue'
import { uploadFile } from '@/api/upload'
import { sendMessage } from '@/api/message'
import type { Message } from '@/api/message'
@@ -221,6 +215,9 @@ const showInviteDialog = ref(false)
/** 截图编辑器是否可见 */
const showScreenshotEditor = ref(false)
/** 框选截图模式是否可见 */
const showScreenCapture = ref(false)
/** html2canvas 生成的完整页面截图 Canvas 对象(传给 ScreenshotEditor */
let screenshotCanvas: HTMLCanvasElement | null = null
const showEmojiPicker = ref(false)
@@ -668,6 +665,104 @@ async function handleScreenshot(): Promise<void> {
}
}
/**
* 框选截图 - 直接在屏幕上框选区域
*/
function startBoxCapture(): void {
const convId = conversationStore.currentConversation?.id
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
showScreenCapture.value = true
}
/**
* 框选截图确认
*/
async function onBoxCaptureConfirm(blob: Blob): Promise<void> {
showScreenCapture.value = false
const convId = conversationStore.currentConversation?.id
if (!convId) return
try {
ElMessage.info('截图上传中...')
const result = await uploadFile(blob)
const newMsg = await sendMessage(convId, '[截图]', 'image', {
media_url: result.url,
file_name: result.filename,
file_size: result.file_size,
})
conversationStore.messages.push(newMsg)
ElMessage.success('截图发送成功')
} catch (error: any) {
console.error('[ReplyBox] 框选截图发送失败:', error)
ElMessage.error(`截图发送失败:${error?.message || '未知错误'}`)
}
}
function onBoxCaptureCancel(): void {
showScreenCapture.value = false
}
/**
* 跨屏截图 - 使用系统屏幕捕获API
*/
async function handleScreenCapture(): Promise<void> {
const convId = conversationStore.currentConversation?.id
if (!convId) {
ElMessage.warning('请先选择一个会话')
return
}
try {
ElMessage.info('选择要截取的屏幕...')
// 使用系统屏幕捕获API
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'monitor', // 优先捕获整个屏幕
},
audio: false,
})
// 获取视频轨道
const videoTrack = stream.getVideoTracks()[0]
const settings = videoTrack.getSettings()
// 创建视频元素来捕获帧
const video = document.createElement('video')
video.srcObject = new MediaStream([videoTrack])
await video.play()
// 创建 canvas 捕获帧
const canvas = document.createElement('canvas')
canvas.width = settings.width || video.videoWidth
canvas.height = settings.height || video.videoHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
}
// 停止屏幕共享
videoTrack.stop()
stream.getTracks().forEach(track => track.stop())
// 转换为图片
screenshotCanvas = canvas
showScreenshotEditor.value = true
ElMessage.success('截取成功')
} catch (error: any) {
if (error.name === 'NotAllowedError') {
ElMessage.info('已取消跨屏截图')
} else {
console.error('跨屏截图失败:', error)
ElMessage.error('跨屏截图失败,请重试')
}
}
}
/**
* 确认发送截图
* 上传截图并发送图片消息
@@ -0,0 +1,401 @@
<template>
<!-- 微信风格截图 -->
<div class="wechat-capture">
<div class="capture-bg">
<canvas ref="maskCanvas" class="mask-canvas" @mousedown="startSelect" @mousemove="onMove" @mouseup="endSelect"></canvas>
<!-- 选区 -->
<div v-if="box.w > 0" class="selection-box" :style="boxStyle">
<div class="resize-handles">
<span class="handle nw" @mousedown.stop="startResize('nw', $event)"></span>
<span class="handle n" @mousedown.stop="startResize('n', $event)"></span>
<span class="handle ne" @mousedown.stop="startResize('ne', $event)"></span>
<span class="handle e" @mousedown.stop="startResize('e', $event)"></span>
<span class="handle se" @mousedown.stop="startResize('se', $event)"></span>
<span class="handle s" @mousedown.stop="startResize('s', $event)"></span>
<span class="handle sw" @mousedown.stop="startResize('sw', $event)"></span>
<span class="handle w" @mousedown.stop="startResize('w', $event)"></span>
</div>
<!-- 编辑工具栏 - 正常大小 -->
<div class="edit-toolbar" @mousedown.stop>
<div class="toolbar-inner">
<div class="tools">
<button v-for="t in tools" :key="t.name" class="tool-btn" :class="{ active: currentTool === t.name }" @click="currentTool = t.name">{{ t.icon }}</button>
</div>
<div class="colors">
<span v-for="c in colors" :key="c" class="color-dot" :class="{ active: currentColor === c }" :style="{ background: c }" @click="currentColor = c"></span>
</div>
<div class="actions">
<el-button size="small" @click="cancelBox">取消</el-button>
<el-button size="small" @click="saveImage">保存</el-button>
<el-button type="primary" size="small" @click="sendImage">发送</el-button>
</div>
</div>
</div>
</div>
<div v-if="!box.w" class="hint">点击拖拽框选区域 ESC 退出</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import html2canvas from 'html2canvas-pro'
const emit = defineEmits<{
(e: 'confirm', blob: Blob): void
(e: 'cancel'): void
}>()
const maskCanvas = ref<HTMLCanvasElement | null>(null)
// 全局鼠标松开处理(必须在 onMounted 之前定义)
function onMouseUp(): void {
if (isSelecting.value) {
isSelecting.value = false
if (box.value.w < 20 || box.value.h < 20) {
box.value = { x: 0, y: 0, w: 0, h: 0 }
drawMask()
}
}
}
// 选区数据
const box = ref({ x: 0, y: 0, w: 0, h: 0 })
const isSelecting = ref(false)
const isResizing = ref(false)
const resizeDir = ref('')
const startBox = ref({ x: 0, y: 0, w: 0, h: 0 })
const startMouse = ref({ x: 0, y: 0 })
// 编辑状态
const currentTool = ref('brush')
const currentColor = ref('#ff3b30')
let fullScreenImage: HTMLImageElement | null = null
// 工具和颜色
const tools = [
{ name: 'brush', icon: '✎' },
{ name: 'rect', icon: '□' },
{ name: 'arrow', icon: '➜' },
{ name: 'text', icon: 'T' },
]
const colors = ['#ff3b30', '#ff9500', '#ffcc00', '#4cd964', '#5ac8fa', '#007aff', '#5856d6', '#000000']
// 选区样式
const boxStyle = computed(() => ({
left: box.value.x + 'px',
top: box.value.y + 'px',
width: box.value.w + 'px',
height: box.value.h + 'px',
}))
// 初始化
onMounted(async () => {
await captureScreen()
document.addEventListener('keydown', onKeyDown)
// 全局监听鼠标松开,确保拖拽结束后能正确停止
document.addEventListener('mouseup', onMouseUp)
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('mouseup', onMouseUp)
})
// 截取屏幕
async function captureScreen(): Promise<void> {
try {
const container = document.querySelector('.wechat-capture') as HTMLElement
if (container) container.style.display = 'none'
await new Promise(r => setTimeout(r, 150))
const canvas = await html2canvas(document.body, {
useCORS: true,
scale: 1,
logging: false,
backgroundColor: '#ffffff',
})
if (container) container.style.display = 'block'
const img = new Image()
img.src = canvas.toDataURL('image/png')
await new Promise(r => { img.onload = r })
fullScreenImage = img
drawMask()
} catch (e) {
console.error('截图失败:', e)
ElMessage.error('截图失败')
emit('cancel')
}
}
// 绘制蒙版
function drawMask(): void {
const canvas = maskCanvas.value
if (!canvas || !fullScreenImage) return
const w = window.innerWidth
const h = window.innerHeight
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return
// 绘制原图
ctx.drawImage(fullScreenImage, 0, 0, w, h)
// 蒙版
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
ctx.fillRect(0, 0, w, h)
// 清除选区
if (box.value.w > 0) {
ctx.clearRect(box.value.x, box.value.y, box.value.w, box.value.h)
ctx.drawImage(fullScreenImage,
box.value.x, box.value.y, box.value.w, box.value.h,
box.value.x, box.value.y, box.value.w, box.value.h
)
}
}
// 开始选择
function startSelect(e: MouseEvent): void {
if (box.value.w > 0) return
isSelecting.value = true
box.value = { x: e.clientX, y: e.clientY, w: 0, h: 0 }
}
// 移动中
function onMove(e: MouseEvent): void {
if (isSelecting.value) {
const x = Math.min(box.value.x, e.clientX)
const y = Math.min(box.value.y, e.clientY)
const w = Math.abs(e.clientX - box.value.x)
const h = Math.abs(e.clientY - box.value.y)
box.value = { x, y, w, h }
drawMask()
} else if (isResizing.value) {
doResize(e)
}
}
// 结束选择
function endSelect(): void {
isSelecting.value = false
if (box.value.w < 20 || box.value.h < 20) {
box.value = { x: 0, y: 0, w: 0, h: 0 }
drawMask()
}
}
// 调整大小
function startResize(dir: string, e: MouseEvent): void {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
resizeDir.value = dir
startBox.value = { ...box.value }
startMouse.value = { x: e.clientX, y: e.clientY }
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onResizeEnd)
}
function doResize(e: MouseEvent): void {
const dx = e.clientX - startMouse.value.x
const dy = e.clientY - startMouse.value.y
const b = startBox.value
let nb = { ...b }
switch (resizeDir.value) {
case 'nw': nb = { x: b.x + dx, y: b.y + dy, w: b.w - dx, h: b.h - dy }; break
case 'n': nb = { x: b.x, y: b.y + dy, w: b.w, h: b.h - dy }; break
case 'ne': nb = { x: b.x, y: b.y + dy, w: b.w + dx, h: b.h - dy }; break
case 'e': nb = { x: b.x, y: b.y, w: b.w + dx, h: b.h }; break
case 'se': nb = { x: b.x, y: b.y, w: b.w + dx, h: b.h + dy }; break
case 's': nb = { x: b.x, y: b.y, w: b.w, h: b.h + dy }; break
case 'sw': nb = { x: b.x + dx, y: b.y, w: b.w - dx, h: b.h + dy }; break
case 'w': nb = { x: b.x + dx, y: b.y, w: b.w - dx, h: b.h }; break
}
if (nb.w > 20 && nb.h > 20) {
box.value = nb
drawMask()
}
}
function onResizeEnd(): void {
isResizing.value = false
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onResizeEnd)
drawMask()
}
// 取消选区
function cancelBox(): void {
box.value = { x: 0, y: 0, w: 0, h: 0 }
drawMask()
}
// 发送
function sendImage(): void {
if (!fullScreenImage || box.value.w === 0) return
const canvas = document.createElement('canvas')
canvas.width = box.value.w
canvas.height = box.value.h
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(fullScreenImage,
box.value.x, box.value.y, box.value.w, box.value.h,
0, 0, box.value.w, box.value.h
)
canvas.toBlob(blob => {
if (blob) {
emit('confirm', blob)
}
}, 'image/png')
}
// 保存
function saveImage(): void {
if (!fullScreenImage || box.value.w === 0) return
const canvas = document.createElement('canvas')
canvas.width = box.value.w
canvas.height = box.value.h
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(fullScreenImage,
box.value.x, box.value.y, box.value.w, box.value.h,
0, 0, box.value.w, box.value.h
)
const link = document.createElement('a')
link.download = `screenshot-${Date.now()}.png`
link.href = canvas.toDataURL('image/png')
link.click()
ElMessage.success('图片已保存')
}
// ESC退出
function onKeyDown(e: KeyboardEvent): void {
if (e.key === 'Escape') {
emit('cancel')
}
}
</script>
<style scoped>
.wechat-capture {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999999;
cursor: crosshair;
}
.capture-bg { width: 100%; height: 100%; position: relative; }
.mask-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.selection-box {
position: absolute;
border: 1px solid #07C160;
cursor: move;
}
.resize-handles { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; }
.handle {
position: absolute;
width: 10px;
height: 10px;
background: #07C160;
border: 1px solid #fff;
pointer-events: auto;
z-index: 10;
}
.handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
.handle.n { top: -5px; left: 50%; margin-left: -5px; cursor: n-resize; }
.handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
.handle.e { top: 50%; right: -5px; margin-top: -5px; cursor: e-resize; }
.handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
.handle.s { bottom: -5px; left: 50%; margin-left: -5px; cursor: s-resize; }
.handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
.handle.w { top: 50%; left: -5px; margin-top: -5px; cursor: w-resize; }
/* 工具栏 - 正常大小 */
.edit-toolbar {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 8px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
padding: 12px;
}
.toolbar-inner { display: flex; align-items: center; gap: 16px; }
.tools { display: flex; gap: 4px; }
.tool-btn {
width: 32px;
height: 32px;
border: none;
background: #f5f5f5;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
}
.tool-btn:hover { background: #eee; }
.tool-btn.active { background: #07C160; color: #fff; }
.colors { display: flex; gap: 4px; }
.color-dot {
width: 18px;
height: 18px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
}
.color-dot.active { border-color: #333; }
.actions { display: flex; gap: 8px; }
.hint {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.7);
color: #fff;
padding: 8px 16px;
border-radius: 4px;
font-size: 12px;
}
</style>
@@ -1,114 +1,108 @@
<template>
<!--
ScreenshotEditor - 区域选择截图组件
对标微信/企微截图体验
1. 全屏遮罩背景显示页面截图变暗
2. 鼠标变成十字拖拽框选区域
3. 释放后显示选区和工具栏确认/取消/重新选择
4. 确认后裁剪选中区域并 emit 回去
-->
<div class="screenshot-editor-overlay">
<!-- 背景页面截图变暗 -->
<canvas
ref="canvasBgRef"
class="screenshot-editor-bg"
></canvas>
<!-- 选区绘制层跟随鼠标拖拽 -->
<div
class="screenshot-selection-layer"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
>
<!-- 暗色遮罩挖空选区 -->
<div class="screenshot-dark-overlay" :style="darkOverlayStyle"></div>
<!-- 选区边框 -->
<div
v-if="selecting || selectionComplete"
class="screenshot-selection-box"
:style="selectionBoxStyle"
>
<!-- 选区尺寸提示 -->
<div class="screenshot-size-tip" v-if="selectionComplete">
{{ selectionWidth }} × {{ selectionHeight }}
</div>
<!-- 8个拖拽手柄调整后发送 -->
<div
v-if="selectionComplete"
class="screenshot-handle screenshot-handle--tl"
@mousedown.stop="onHandleMouseDown($event, 'tl')"
></div>
<div
v-if="selectionComplete"
class="screenshot-handle screenshot-handle--tr"
@mousedown.stop="onHandleMouseDown($event, 'tr')"
></div>
<div
v-if="selectionComplete"
class="screenshot-handle screenshot-handle--bl"
@mousedown.stop="onHandleMouseDown($event, 'bl')"
></div>
<div
v-if="selectionComplete"
class="screenshot-handle screenshot-handle--br"
@mousedown.stop="onHandleMouseDown($event, 'br')"
<!-- 截图编辑器 -->
<div class="screenshot-editor">
<!-- 裁剪模式 -->
<div v-if="mode === 'crop'" class="crop-mode">
<div class="crop-header">
<span>截图裁剪 - 拖拽选择区域</span>
<el-button text @click="emit('cancel')"> 关闭</el-button>
</div>
<div class="crop-content" ref="cropContainerRef">
<img
ref="imgRef"
:src="imageSrc"
class="crop-image"
@load="initCropSelection"
@mousedown="startCropSelection"
@mousemove="updateCropSelection"
@mouseup="endCropSelection"
@mouseleave="endCropSelection"
/>
<!-- 选框 -->
<div
v-if="cropBox.show"
class="crop-box"
:style="{
left: cropBox.x + 'px',
top: cropBox.y + 'px',
width: cropBox.width + 'px',
height: cropBox.height + 'px'
}"
></div>
</div>
<div class="crop-footer">
<el-button-group>
<el-button @click="rotate(-90)"> 逆时针</el-button>
<el-button @click="rotate(90)"> 顺时针</el-button>
<el-button @click="resetCrop">重置选区</el-button>
</el-button-group>
<el-button type="primary" @click="confirmCrop" :disabled="!cropBox.show">
确认裁剪
</el-button>
</div>
</div>
<!-- 工具栏选区完成后显示 -->
<div v-if="selectionComplete" class="screenshot-toolbar">
<button class="screenshot-toolbar-btn" @click="handleCancel" title="取消截图">
取消
</button>
<button class="screenshot-toolbar-btn" @click="handleReselect" title="重新选择">
重选
</button>
<button class="screenshot-toolbar-btn screenshot-toolbar-btn--primary" @click="handleConfirm" title="确认截图">
确认
</button>
</div>
<!-- 编辑模式 -->
<div v-else class="edit-mode">
<div class="edit-toolbar">
<!-- 工具 -->
<div class="tool-group">
<button
v-for="t in tools"
:key="t.name"
:class="['tool-btn', { active: currentTool === t.name }]"
@click="currentTool = t.name"
:title="t.label"
>
{{ t.icon }}
</button>
</div>
<!-- 颜色 -->
<div class="color-picker">
<button
v-for="c in colors"
:key="c"
:class="['color-btn', { active: strokeColor === c }]"
:style="{ backgroundColor: c }"
@click="strokeColor = c"
></button>
</div>
<!-- 操作 -->
<div class="action-group">
<el-button size="small" @click="undo" :disabled="historyIndex <= 0"></el-button>
<el-button size="small" @click="redo" :disabled="historyIndex >= history.length - 1"></el-button>
<el-button size="small" type="danger" @click="clearCanvas">🗑</el-button>
</div>
</div>
<!-- 提示文字未选区时显示 -->
<div v-if="!selecting && !selectionComplete" class="screenshot-tip">
按住鼠标拖拽选择截图区域ESC 取消
<!-- 画布 -->
<div class="canvas-wrapper" ref="wrapperRef">
<canvas
ref="canvasRef"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
></canvas>
</div>
<!-- 底部 -->
<div class="edit-footer">
<el-button @click="mode = 'crop'"> 重裁</el-button>
<el-button type="primary" @click="confirmEdit">完成发送 </el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
/**
* ScreenshotEditor 组件
* 做什么:实现微信/企微风格的区域截图功能
* 为什么:用户反馈截图不好用,需要对标微信/企微的截图体验
*
* 交互流程:
* 1. 传入页面截图的 canvas/image
* 2. 用户拖拽选择区域
* 3. 确认后裁剪选中区域,通过 emit 返回 Blob
* 4. 取消则关闭编辑器
*
* Props:
* - visible: 是否显示编辑器
* - screenshotCanvas: html2canvas 生成的完整页面截图(Canvas)
*
* Emits:
* - confirm: 确认截图,参数是裁剪后图片的 Blob
* - cancel: 取消截图
*/
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { ref, onMounted, watch, nextTick, reactive } from 'vue'
// ========== Props & Emits ==========
interface Props {
/** html2canvas 生成的完整页面截图 Canvas 对象 */
screenshotCanvas: HTMLCanvasElement | null
screenshotCanvas?: HTMLCanvasElement | null
}
const props = withDefaults(defineProps<Props>(), {
screenshotCanvas: null,
})
@@ -118,439 +112,320 @@ const emit = defineEmits<{
(e: 'cancel'): void
}>()
// ========== Refs ==========
const canvasBgRef = ref<HTMLCanvasElement | null>(null)
// 模式
const mode = ref<'crop' | 'edit'>('crop')
const imageSrc = ref('')
// 选区状态
const selecting = ref(false) // 是否正在拖拽选区
const selectionComplete = ref(false) // 选区是否完成
const startX = ref(0) // 拖拽起点 X
const startY = ref(0) // 拖拽起点 Y
const endX = ref(0) // 拖拽终点 X
const endY = ref(0) // 拖拽终点 Y
// 裁剪相关
const imgRef = ref<HTMLImageElement | null>(null)
const cropContainerRef = ref<HTMLDivElement | null>(null)
const cropBox = reactive({ x: 0, y: 0, width: 0, height: 0, show: false, startX: 0, startY: 0 })
let rotation = 0
let originalWidth = 0
let originalHeight = 0
// 调整手柄状态
let resizingHandle = '' // 正在拖拽的手柄:tl/tr/bl/br
let resizeStartX = 0
let resizeStartY = 0
let resizeStartStartX = 0
let resizeStartStartY = 0
let resizeStartEndX = 0
let resizeStartEndY = 0
// 工具
const tools = [
{ name: 'brush', icon: '✎', label: '画笔' },
{ name: 'highlighter', icon: '🖍', label: '高亮' },
{ name: 'rect', icon: '□', label: '矩形' },
{ name: 'arrow', icon: '➜', label: '箭头' },
{ name: 'text', icon: 'T', label: '文字' },
]
// ========== 计算属性 ==========
const colors = ['#f44336', '#e91e63', '#2196f3', '#4caf50', '#ffeb3b', '#000000']
/** 选区左坐标(取 start/end 最小值) */
const selectionLeft = computed(() => Math.min(startX.value, endX.value))
/** 选区上坐标 */
const selectionTop = computed(() => Math.min(startY.value, endY.value))
/** 选区宽度 */
const selectionWidth = computed(() => Math.abs(endX.value - startX.value))
/** 选区高度 */
const selectionHeight = computed(() => Math.abs(endY.value - startY.value))
const currentTool = ref('brush')
const strokeColor = ref('#f44336')
/** 选区盒模型样式 */
const selectionBoxStyle = computed(() => ({
left: `${selectionLeft.value}px`,
top: `${selectionTop.value}px`,
width: `${selectionWidth.value}px`,
height: `${selectionHeight.value}px`,
}))
// 画布
const canvasRef = ref<HTMLCanvasElement | null>(null)
const wrapperRef = ref<HTMLDivElement | null>(null)
let ctx: CanvasRenderingContext2D | null = null
let imgWidth = 0
let imgHeight = 0
/** 暗色遮罩样式(挖空选区位置) */
const darkOverlayStyle = computed(() => {
if (!selectionComplete.value && !selecting.value) {
return {} // 未选区时全暗
}
// 使用 box-shadow 实现挖空效果(外围暗色,选区明亮)
const left = selectionLeft.value
const top = selectionTop.value
const width = selectionWidth.value
const height = selectionHeight.value
return {
boxShadow: `0 0 0 9999px rgba(0, 0, 0, 0.5)`,
// 选区位置用透明
background: selecting.value
? 'rgba(0, 0, 0, 0.5)'
: 'transparent',
// 用 clip-path 挖空选区
clipPath: selecting.value
? 'none'
: `polygon(0% 0%, 0% 100%, ${left}px ${top}px, ${left}px ${top + height}px, ${left + width}px ${top + height}px, ${left + width}px ${top}px, 0% 100%, 100% 100%, 100% 0%)`,
}
})
// 绘制
const isDrawing = ref(false)
const startX = ref(0)
const startY = ref(0)
const pathPoints = ref<{x: number, y: number}[]>([])
// ========== 生命周期 ==========
onMounted(() => {
// 将截图绘制到背景 canvas
nextTick(() => {
drawBackground()
})
// 历史
const history = ref<ImageData[]>([])
const historyIndex = ref(-1)
// 监听 ESC 取消
document.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
})
// 监听 screenshotCanvas 变化,重新绘制背景
watch(
() => props.screenshotCanvas,
() => {
nextTick(() => drawBackground())
}
)
// ========== 方法 ==========
/** 将截图绘制到背景 canvas */
function drawBackground(): void {
const canvas = canvasBgRef.value
if (!canvas || !props.screenshotCanvas) return
// 设置 canvas 尺寸为窗口大小
canvas.width = window.innerWidth
canvas.height = window.innerHeight
const ctx = canvas.getContext('2d')
if (!ctx) return
// 将截图绘制到 canvas(覆盖整个窗口)
ctx.drawImage(props.screenshotCanvas, 0, 0, canvas.width, canvas.height)
}
/** 键盘事件:ESC 取消 */
function onKeyDown(e: KeyboardEvent): void {
if (e.key === 'Escape') {
handleCancel()
}
}
/** 鼠标按下:开始选区 */
function onMouseDown(e: MouseEvent): void {
// 如果选区已完成,不重新开始(除非点在了选区外)
if (selectionComplete.value) {
// 判断是否点击在选区外
const rect = getSelectionRect()
if (
e.clientX < rect.left ||
e.clientX > rect.right ||
e.clientY < rect.top ||
e.clientY > rect.bottom
) {
// 点击在选区外,重新开始选区
resetSelection()
} else {
// 点击在选区内,不处理(可能是拖拽移动选区)
return
}
}
selecting.value = true
startX.value = e.clientX
startY.value = e.clientY
endX.value = e.clientX
endY.value = e.clientY
}
/** 鼠标移动:更新选区 */
function onMouseMove(e: MouseEvent): void {
if (selecting.value) {
endX.value = e.clientX
endY.value = e.clientY
}
}
/** 鼠标释放:完成选区 */
function onMouseUp(): void {
if (selecting.value) {
selecting.value = false
// 判断选区是否有效(最小 10x10)
if (selectionWidth.value < 10 || selectionHeight.value < 10) {
// 选区太小,忽略
resetSelection()
return
}
selectionComplete.value = true
}
}
/** 调整手柄:鼠标按下 */
function onHandleMouseDown(e: MouseEvent, handle: string): void {
resizingHandle = handle
resizeStartX = e.clientX
resizeStartY = e.clientY
resizeStartStartX = startX.value
resizeStartStartY = startY.value
resizeStartEndX = endX.value
resizeStartEndY = endY.value
document.addEventListener('mousemove', onHandleMouseMove)
document.addEventListener('mouseup', onHandleMouseUp)
}
/** 调整手柄:鼠标移动 */
function onHandleMouseMove(e: MouseEvent): void {
const dx = e.clientX - resizeStartX
const dy = e.clientY - resizeStartY
switch (resizingHandle) {
case 'tl':
startX.value = resizeStartStartX + dx
startY.value = resizeStartStartY + dy
break
case 'tr':
endX.value = resizeStartEndX + dx
startY.value = resizeStartStartY + dy
break
case 'bl':
startX.value = resizeStartStartX + dx
endY.value = resizeStartEndY + dy
break
case 'br':
endX.value = resizeStartEndX + dx
endY.value = resizeStartEndY + dy
break
}
}
/** 调整手柄:鼠标释放 */
function onHandleMouseUp(): void {
resizingHandle = ''
document.removeEventListener('mousemove', onHandleMouseMove)
document.removeEventListener('mouseup', onHandleMouseUp)
}
/** 重置选区 */
function resetSelection(): void {
selecting.value = false
selectionComplete.value = false
startX.value = 0
startY.value = 0
endX.value = 0
endY.value = 0
}
/** 获取选区矩形(绝对坐标) */
function getSelectionRect() {
return {
left: selectionLeft.value,
top: selectionTop.value,
right: selectionLeft.value + selectionWidth.value,
bottom: selectionTop.value + selectionHeight.value,
width: selectionWidth.value,
height: selectionHeight.value,
}
}
/** 取消截图 */
function handleCancel(): void {
resetSelection()
emit('cancel')
}
/** 重新选择 */
function handleReselect(): void {
resetSelection()
}
/** 确认截图:裁剪选中区域并 emit Blob */
async function handleConfirm(): Promise<void> {
if (!props.screenshotCanvas) {
ElMessage.error('截图数据丢失,请重试')
return
}
try {
// 计算缩放因子(screenshotCanvas 可能包含 devicePixelRatio
const scaleX = props.screenshotCanvas.width / window.innerWidth
const scaleY = props.screenshotCanvas.height / window.innerHeight
// 用 canvas 裁剪选中区域
const cropCanvas = document.createElement('canvas')
cropCanvas.width = selectionWidth.value
cropCanvas.height = selectionHeight.value
const ctx = cropCanvas.getContext('2d')
if (!ctx) {
ElMessage.error('截图裁剪失败(无法创建画布)')
return
}
// 从完整截图中裁剪选中区域(使用缩放后的坐标)
ctx.drawImage(
props.screenshotCanvas,
selectionLeft.value * scaleX,
selectionTop.value * scaleY,
selectionWidth.value * scaleX,
selectionHeight.value * scaleY,
0,
0,
selectionWidth.value,
selectionHeight.value
)
// 转为 Blob
const blob = await new Promise<Blob | null>((resolve) => {
cropCanvas.toBlob((b) => resolve(b), 'image/png')
// 监听
watch(() => props.screenshotCanvas, (canvas) => {
if (canvas) {
imageSrc.value = canvas.toDataURL('image/png')
nextTick(() => {
if (imgRef.value) {
imgRef.value.onload = () => {
resetCrop()
}
}
})
}
}, { immediate: true })
if (!blob || blob.size === 0) {
ElMessage.error('截图生成失败(裁剪结果为空),请重试')
return
}
// 初始化裁剪选择
function initCropSelection(): void {
if (!imgRef.value || !cropContainerRef.value) return
const img = imgRef.value
originalWidth = img.naturalWidth
originalHeight = img.naturalHeight
}
emit('confirm', blob)
resetSelection()
} catch (err) {
console.error('[ScreenshotEditor] 确认截图失败:', err)
ElMessage.error('截图确认失败,请重试')
// 裁剪交互
function startCropSelection(e: MouseEvent): void {
if (!imgRef.value || !cropContainerRef.value) return
const rect = imgRef.value.getBoundingClientRect()
cropBox.startX = e.clientX - rect.left
cropBox.startY = e.clientY - rect.top
cropBox.show = true
}
function updateCropSelection(e: MouseEvent): void {
if (!cropBox.show || !imgRef.value) return
const rect = imgRef.value.getBoundingClientRect()
const currentX = e.clientX - rect.left
const currentY = e.clientY - rect.top
cropBox.x = Math.min(cropBox.startX, currentX)
cropBox.y = Math.min(cropBox.startY, currentY)
cropBox.width = Math.abs(currentX - cropBox.startX)
cropBox.height = Math.abs(currentY - cropBox.startY)
}
function endCropSelection(): void {
// 可以在这里添加边界检查
}
function resetCrop(): void {
if (!imgRef.value || !cropContainerRef.value) return
// 默认选中整个图片
cropBox.x = 0
cropBox.y = 0
cropBox.width = imgRef.value.offsetWidth
cropBox.height = imgRef.value.offsetHeight
cropBox.show = true
}
function rotate(deg: number): void {
rotation += deg
if (imgRef.value) {
imgRef.value.style.transform = `rotate(${rotation}deg)`
}
}
/** 暴露方法给父组件 */
defineExpose({
resetSelection,
})
// 确认裁剪
function confirmCrop(): void {
if (!imgRef.value || !cropBox.show) return
const img = imgRef.value
const scaleX = img.naturalWidth / img.offsetWidth
const scaleY = img.naturalHeight / img.offsetHeight
// 计算实际裁剪区域
const sx = cropBox.x * scaleX
const sy = cropBox.y * scaleY
const sw = cropBox.width * scaleX
const sh = cropBox.height * scaleY
// 创建裁剪后的画布
const canvas = document.createElement('canvas')
canvas.width = sw
canvas.height = sh
const tempCtx = canvas.getContext('2d')
if (!tempCtx) return
// 应用旋转
tempCtx.translate(canvas.width / 2, canvas.height / 2)
tempCtx.rotate((rotation * Math.PI) / 180)
tempCtx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2)
imageSrc.value = canvas.toDataURL('image/png')
mode.value = 'edit'
nextTick(initCanvas)
}
// 初始化画布
function initCanvas(): void {
const canvas = canvasRef.value
const wrapper = wrapperRef.value
if (!canvas || !wrapper || !imageSrc.value) return
const img = new Image()
img.onload = () => {
// 适应容器
const maxW = wrapper.clientWidth - 40
const maxH = wrapper.clientHeight - 40
let w = img.width
let h = img.height
if (w > maxW) { h = (maxW / w) * h; w = maxW }
if (h > maxH) { w = (maxH / h) * w; h = maxH }
imgWidth = w
imgHeight = h
canvas.width = w
canvas.height = h
ctx = canvas.getContext('2d')
ctx?.drawImage(img, 0, 0, w, h)
saveHistory()
}
img.src = imageSrc.value
}
// 历史
function saveHistory(): void {
if (!ctx || !imgWidth || !imgHeight) return
const imageData = ctx.getImageData(0, 0, imgWidth, imgHeight)
history.value = history.value.slice(0, historyIndex.value + 1)
history.value.push(imageData)
historyIndex.value = history.value.length - 1
}
function undo(): void {
if (historyIndex.value <= 0) return
historyIndex.value--
ctx?.putImageData(history.value[historyIndex.value], 0, 0)
}
function redo(): void {
if (historyIndex.value >= history.value.length - 1) return
historyIndex.value++
ctx?.putImageData(history.value[historyIndex.value], 0, 0)
}
function clearCanvas(): void {
if (!ctx || !imgWidth || !imgHeight) return
ctx.clearRect(0, 0, imgWidth, imgHeight)
saveHistory()
}
// 鼠标事件
function handleMouseDown(e: MouseEvent): void {
if (!canvasRef.value || !ctx) return
const rect = canvasRef.value.getBoundingClientRect()
isDrawing.value = true
startX.value = e.clientX - rect.left
startY.value = e.clientY - rect.top
pathPoints.value = [{ x: startX.value, y: startY.value }]
if (currentTool.value === 'text') {
const text = prompt('请输入文字:', '')
if (text) {
ctx.font = '20px Microsoft YaHei'
ctx.fillStyle = strokeColor.value
ctx.fillText(text, startX.value, startY.value)
saveHistory()
}
isDrawing.value = false
}
}
function handleMouseMove(e: MouseEvent): void {
if (!isDrawing.value || !ctx || !canvasRef.value) return
const rect = canvasRef.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
if (currentTool.value === 'brush' || currentTool.value === 'highlighter') {
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.lineWidth = currentTool.value === 'highlighter' ? 20 : 4
ctx.strokeStyle = currentTool.value === 'highlighter' ? 'rgba(255,255,0,0.5)' : strokeColor.value
ctx.beginPath()
const last = pathPoints.value[pathPoints.value.length - 1]
ctx.moveTo(last.x, last.y)
ctx.lineTo(x, y)
ctx.stroke()
pathPoints.value.push({ x, y })
}
}
function handleMouseUp(e: MouseEvent): void {
if (!isDrawing.value || !ctx || !canvasRef.value) return
const rect = canvasRef.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
if (currentTool.value === 'rect') {
ctx.strokeStyle = strokeColor.value
ctx.lineWidth = 3
ctx.strokeRect(startX.value, startY.value, x - startX.value, y - startY.value)
} else if (currentTool.value === 'arrow') {
drawArrow(ctx, startX.value, startY.value, x, y, strokeColor.value)
}
isDrawing.value = false
pathPoints.value = []
saveHistory()
}
function drawArrow(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, color: string): void {
const headLen = 15
const angle = Math.atan2(y2 - y1, x2 - x1)
ctx.strokeStyle = color
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
ctx.fillStyle = color
ctx.beginPath()
ctx.moveTo(x2, y2)
ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6))
ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6))
ctx.closePath()
ctx.fill()
}
async function confirmEdit(): Promise<void> {
if (!canvasRef.value) return
canvasRef.value.toBlob((blob) => {
if (blob) emit('confirm', blob)
}, 'image/png')
}
</script>
<style scoped>
/* 全屏遮罩 */
.screenshot-editor-overlay {
.screenshot-editor {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
cursor: crosshair;
user-select: none;
}
/* 背景 canvas(显示页面截图) */
.screenshot-editor-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
/* 选区绘制层 */
.screenshot-selection-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
}
/* 暗色遮罩(未选区时全暗,选区后挖空)
pointer-events: none — 让鼠标事件穿透遮罩,到达选区层 */
.screenshot-dark-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
pointer-events: none;
}
/* 选区边框 */
.screenshot-selection-box {
position: absolute;
border: 2px solid #1989fa;
background: rgba(25, 137, 250, 0.05);
z-index: 20;
box-sizing: border-box;
}
/* 选区尺寸提示 */
.screenshot-size-tip {
position: absolute;
bottom: -24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
}
/* 调整手柄 */
.screenshot-handle {
position: absolute;
width: 10px;
height: 10px;
background: #1989fa;
border: 2px solid #fff;
border-radius: 50%;
z-index: 30;
}
.screenshot-handle--tl { top: -5px; left: -5px; cursor: nw-resize; }
.screenshot-handle--tr { top: -5px; right: -5px; cursor: ne-resize; }
.screenshot-handle--bl { bottom: -5px; left: -5px; cursor: sw-resize; }
.screenshot-handle--br { bottom: -5px; right: -5px; cursor: se-resize; }
/* 工具栏 */
.screenshot-toolbar {
position: fixed;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
background: #1a1a1a;
display: flex;
gap: 12px;
padding: 10px 20px;
background: rgba(0, 0, 0, 0.8);
border-radius: 8px;
z-index: 100;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
flex-direction: column;
}
.screenshot-toolbar-btn {
padding: 6px 16px;
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: #fff;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.screenshot-toolbar-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.screenshot-toolbar-btn--primary {
background: #1989fa;
border-color: #1989fa;
}
.screenshot-toolbar-btn--primary:hover {
background: #0570db;
}
/* 裁剪模式 */
.crop-mode { display: flex; flex-direction: column; height: 100%; }
.crop-header { display: flex; justify-content: space-between; padding: 12px 20px; background: #2a2a2a; color: #fff; }
.crop-content { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 20px; }
.crop-image { max-width: 100%; max-height: 100%; transition: transform 0.3s; }
.crop-box { position: absolute; border: 2px dashed #07C160; background: rgba(7, 193, 96, 0.1); cursor: move; }
.crop-footer { display: flex; justify-content: space-between; padding: 16px 20px; background: #2a2a2a; }
/* 提示文字 */
.screenshot-tip {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 13px;
padding: 8px 16px;
border-radius: 6px;
pointer-events: none;
z-index: 100;
}
/* 编辑模式 */
.edit-mode { display: flex; flex-direction: column; height: 100%; }
.edit-toolbar { display: flex; align-items: center; gap: 16px; padding: 12px 16px; background: #2a2a2a; flex-wrap: wrap; }
.tool-group { display: flex; gap: 4px; }
.tool-btn { width: 36px; height: 36px; border: none; background: #3a3a3a; color: #fff; border-radius: 6px; cursor: pointer; font-size: 16px; }
.tool-btn.active { background: #07C160; }
.color-picker { display: flex; gap: 4px; }
.color-btn { width: 24px; height: 24px; border: 2px solid transparent; border-radius: 50%; cursor: pointer; }
.color-btn.active { border-color: #fff; }
.action-group { margin-left: auto; display: flex; gap: 8px; }
.canvas-wrapper { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 20px; }
.canvas-wrapper canvas { box-shadow: 0 4px 20px rgba(0,0,0,0.5); cursor: crosshair; }
.edit-footer { display: flex; justify-content: center; gap: 16px; padding: 16px; background: #2a2a2a; }
</style>
@@ -18,10 +18,10 @@
<div class="user-info-bar__left">
<!-- 头像有头像显示图片无头像显示文字 -->
<img
v-if="hasAvatar"
v-if="hasAvatar && !avatarFailed"
:src="conversation?.avatar"
class="user-info-bar__avatar-img"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
@error="avatarFailed = true"
/>
<div v-else class="user-info-bar__avatar">
{{ avatarText }}
@@ -366,6 +366,9 @@ const hasAvatar = computed(() => {
return !!props.conversation?.avatar
})
/** 头像加载失败标记:URL 过期/网络异常时降级显示首字(要求 B 前端兜底) */
const avatarFailed = ref(false)
/** 头像文字(取姓名前两个字) */
const avatarText = computed(() => {
const name = props.conversation?.employee_name || '?'
@@ -22,10 +22,10 @@
<div class="conv-avatar-wrap">
<!-- 有头像时显示图片 -->
<img
v-if="hasAvatar"
v-if="hasAvatar && !avatarFailed"
:src="conversation.avatar"
class="conversation-avatar-img"
@error="(e) => (e.target as HTMLImageElement).style.display = 'none'"
@error="avatarFailed = true"
/>
<!-- 无头像时显示文字头像 -->
<div v-else class="conversation-avatar" :class="avatarColorClass">
@@ -144,7 +144,7 @@
// ============================================================================
// 导入
// ============================================================================
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { Conversation } from '@/api/conversation'
// ============================================================================
@@ -244,6 +244,9 @@ const hasAvatar = computed(() => {
return !!props.conversation.avatar
})
/** 头像加载失败标记:URL 过期/网络异常时降级显示首字(要求 B 前端兜底) */
const avatarFailed = ref(false)
/** 头像文字(取姓名最后一个字) */
const avatarText = computed(() => {
const name = props.conversation.employee_name
@@ -45,11 +45,11 @@
>
<div class="person-avatar">
<img
v-if="person.avatar"
v-if="person.avatar && !failedIds[person.id]"
:src="person.avatar"
:alt="person.name"
class="avatar-img"
@error="onAvatarError($event)"
@error="failedIds[person.id] = true"
/>
<span v-else class="avatar-letter">{{ getAvatar(person.name) }}</span>
</div>
@@ -156,6 +156,9 @@ const emit = defineEmits<{
/** 弹窗可见性 */
const visible = ref(props.modelValue)
/** 头像加载失败标记:按员工 id 记录(各自 URL 独立,可能单独过期,要求 B 兜底) */
const failedIds = ref<Record<string, boolean>>({})
/** 搜索关键词 */
const searchText = ref('')
@@ -216,11 +219,6 @@ function getAvatar(name: string): string {
}
/** 头像加载失败时隐藏 img,降级显示首字母 */
function onAvatarError(event: Event): void {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
/** 搜索员工(阶段一用 Mock 数据,阶段二替换为企微通讯录API) */
function handleSearch(): void {
const keyword = searchText.value.trim().toLowerCase()
@@ -48,11 +48,11 @@
<!-- 头像 avatar img无则首字母降级 -->
<div class="participant-avatar" :class="p.joined ? '' : 'participant-avatar--pending'">
<img
v-if="p.avatar"
v-if="p.avatar && !failedIds[p.id]"
:src="p.avatar"
:alt="p.name"
class="avatar-img"
@error="onAvatarError($event)"
@error="failedIds[p.id] = true"
/>
<span v-else class="avatar-letter">{{ p.name.charAt(p.name.length - 1) }}</span>
</div>
@@ -91,7 +91,7 @@
// ============================================================================
// 导入
// ============================================================================
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { Close } from '@element-plus/icons-vue'
import type { ParticipantInfo } from '@/api/conversation'
@@ -124,6 +124,9 @@ const emit = defineEmits<{
'remove': [userId: string]
}>()
/** 头像加载失败标记:按参与者 id 记录(各自 URL 独立,可能单独过期,要求 B 兜底) */
const failedIds = ref<Record<string, boolean>>({})
// ============================================================================
// 计算属性
// ============================================================================
@@ -153,12 +156,6 @@ function handleRemove(userId: string): void {
if (!props.isPrimaryAgent) return
emit('remove', userId)
}
/** 头像加载失败时隐藏 img,降级显示首字母 */
function onAvatarError(event: Event): void {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
</script>
<style scoped>
+23 -4
View File
@@ -77,11 +77,30 @@ export function useWebSocket() {
*/
function connect(): void {
const agentStore = useAgentStore()
const agentId = agentStore.userId
// 优先从 store 获取,其次从 localStorage 获取(兼容 QR 码登录场景)
let agentId = agentStore.userId
let token = agentStore.token
// 如果 store 中没有,从 localStorage 读取(解决 QR 码登录时 store 未及时刷新的问题)
if (!agentId) {
agentId = localStorage.getItem('agent_user_id') || ''
}
if (!token) {
token = localStorage.getItem('agent_token') || localStorage.getItem('portal_token') || ''
}
// 调试日志
console.log('[WebSocket] 连接参数:', {
userId: agentId,
token: token ? token.substring(0, 20) + '...' : null,
localStorage_agent_token: localStorage.getItem('agent_token'),
localStorage_agent_user_id: localStorage.getItem('agent_user_id'),
})
// 如果没有坐席ID,说明未登录,不建立连接
if (!agentId) {
console.warn('[WebSocket] 未登录,跳过连接')
console.warn('[WebSocket] 未登录,跳过连接 - agentId为空')
return
}
@@ -94,7 +113,7 @@ export function useWebSocket() {
intentionalDisconnect = false
// 构建 WebSocket URL
// 开发环境:直接连后端 8000 端口(避免 Vite WS 代理兼容性问题
// 开发环境:直接连后端 8000 端口(已修改 CSP 允许
// 生产环境:通过同源 wss:// 连接(nginx 统一代理)
const isDev = import.meta.env.DEV
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -106,7 +125,7 @@ export function useWebSocket() {
// P0-#4 修复: 用 Sec-WebSocket-Protocol (subprotocols) 传递 token
// 浏览器原生 WebSocket API 第2参数是 protocols (字符串数组),不是 headers
// 服务端从 sec-websocket-protocol 头读取 bearer.{token}
ws = new WebSocket(wsUrl, [`bearer.${agentStore.token}`])
ws = new WebSocket(wsUrl, [`bearer.${token}`])
// ----------------------------------------------------------------------
// 连接成功
+14
View File
@@ -15,6 +15,7 @@ import type { Agent } from '@/api/agent'
import { login as apiLogin, getCurrentAgent, updateAgentStatus, getAgents } from '@/api/agent'
import { mockLoginData, mockCurrentAgent, mockAgentListData } from '@/mock/data'
import router from '@/router'
import { useWebSocket } from '@/composables/useWebSocket'
// --------------------------------------------------------------------------
// Token 存储 key
@@ -80,6 +81,15 @@ export const useAgentStore = defineStore('agent', () => {
async function login(inputUserId: string, password: string, otpCode?: string): Promise<any> {
try {
logging.value = true
// 登录前先清除旧数据,避免切换账号时显示旧用户信息
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(PORTAL_TOKEN_KEY)
localStorage.removeItem(AGENT_USER_ID_KEY)
token.value = null
agentUserId.value = null
agentInfo.value = null
const data = await apiLogin(inputUserId, password, otpCode)
// 检查是否需要 OTP 验证
@@ -135,6 +145,10 @@ export const useAgentStore = defineStore('agent', () => {
* 清除本地存储的登录信息,跳转到登录页
*/
function logout(): void {
// 断开 WebSocket 连接(坐席登出时主动断开,避免后台重连)
const { disconnect: disconnectWebSocket } = useWebSocket()
disconnectWebSocket()
// 清除状态
token.value = null
agentUserId.value = null
+172
View File
@@ -0,0 +1,172 @@
// =============================================================================
// 企微IT智能服务台 — 阶段5 自动化闭环 状态管理(Pinia Store,坐席端)
// =============================================================================
// 说明:管理自动化会话列表、当前会话详情,以及专用 WebSocket 实时推送。
// =============================================================================
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type {
AutomationSession,
CreateSessionPayload,
ApprovalPayload,
TakeoverPayload,
} from '@/api/automation'
import {
createAutomationSession,
listAutomationSessions,
getAutomationSession,
approveAutomationSession,
takeoverAutomationSession,
} from '@/api/automation'
// --------------------------------------------------------------------------
// WebSocket 辅助
// --------------------------------------------------------------------------
function getAgentToken(): string {
return (
localStorage.getItem('agent_token') ||
localStorage.getItem('portal_token') ||
''
)
}
function buildWsUrl(sessionId: string): string {
const token = getAgentToken()
const isDev = import.meta.env.DEV
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = isDev ? 'localhost:8000' : window.location.host
return `${proto}//${host}/ws/automation/${sessionId}?token=${encodeURIComponent(token)}`
}
export const useAutomationStore = defineStore('automation', () => {
// ------------------------------------------------------------------------
// 状态
// ------------------------------------------------------------------------
const sessions = ref<AutomationSession[]>([])
const currentSession = ref<AutomationSession | null>(null)
const loading = ref(false)
const ws = ref<WebSocket | null>(null)
const wsSessionId = ref<string | null>(null)
const wsConnected = ref(false)
// ------------------------------------------------------------------------
// 计算属性
// ------------------------------------------------------------------------
const pendingSessions = computed(() =>
sessions.value.filter((s) => ['created', 'running', 'paused'].includes(s.status)),
)
// ------------------------------------------------------------------------
// 数据加载
// ------------------------------------------------------------------------
async function fetchSessions(): Promise<void> {
loading.value = true
try {
sessions.value = await listAutomationSessions()
} finally {
loading.value = false
}
}
async function fetchSession(sessionId: string): Promise<void> {
loading.value = true
try {
currentSession.value = await getAutomationSession(sessionId)
} finally {
loading.value = false
}
}
async function createSession(payload: CreateSessionPayload): Promise<AutomationSession> {
const session = await createAutomationSession(payload)
currentSession.value = session
await fetchSessions()
return session
}
async function approve(sessionId: string, decision: 'approve' | 'reject', note?: string): Promise<void> {
const payload: ApprovalPayload = { decision, note }
currentSession.value = await approveAutomationSession(sessionId, payload)
}
async function takeover(sessionId: string, agentId: string, note?: string): Promise<void> {
const payload: TakeoverPayload = { agent_id: agentId, note }
currentSession.value = await takeoverAutomationSession(sessionId, payload)
}
// ------------------------------------------------------------------------
// WebSocket
// ------------------------------------------------------------------------
function connectWs(sessionId: string): void {
disconnectWs()
const url = buildWsUrl(sessionId)
const token = getAgentToken()
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 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':
case 'automation.action_required':
case 'automation.resolved':
case 'automation.takeover':
case 'automation.error':
// 有事件即刷新详情(保持简单可靠)
if (wsSessionId.value) {
fetchSession(wsSessionId.value)
}
break
default:
break
}
}
return {
sessions,
currentSession,
loading,
wsConnected,
pendingSessions,
fetchSessions,
fetchSession,
createSession,
approve,
takeover,
connectWs,
disconnectWs,
}
})
+149 -52
View File
@@ -1,11 +1,25 @@
<!-- =============================================================================
// IT智能服务台 — 坐席登录页 (v1.7, 2026-07-05)
// IT智能服务台 — 坐席登录页 (三端认证重构 AUTH-09 + CTRT)
// =============================================================================
// 说明: 简化版 - 默认显示二维码,可切换账号密码登录
// 说明: 坐席工作台登录,仅保留两种受控登录方式(决策见 system_design.md):
// - ① 企微扫码登录(/auth_qrcode/create + /auth_qrcode/poll 轮询)
// - ② 账号密码 + OTP 二次验证(POST /agents/login,所有登录强制 OTP
//
// 已移除(AUTH-09):
// - 企微 JS-SDK "免密登录" 分支(checkWecomClient / handleWecomQuickLogin / autoLoginWithWecomUser
// - "智能检测自动跳转" 分支(isInWecom 自动跳转 sso/init
// 理由:统一安全水位,所有登录均需经过扫码或账密+OTP,不再有免密直入通道。
//
// 响应契约(CTRT-01/02):apiClient 拦截器已统一返回 inner data
// 故此页面直接读取 response 字段,不再访问 response.data / response.data.data。
// ============================================================================= -->
<template>
<div class="login-page">
<!-- 测试环境标识 - 右上角覆盖层 -->
<div v-if="isTestEnv" class="test-env-badge">
🔧 测试环境
</div>
<div class="login-card">
<!-- 标题区 -->
<div class="login-title">
@@ -13,8 +27,8 @@
<p>坐席工作台</p>
</div>
<!-- 企微扫码登录二维码 -->
<div v-if="!showPasswordLogin" class="qr-login">
<!-- 扫码登录面板默认展示 -->
<div v-if="showQrLoginPanel" class="qr-login">
<div class="qr-container">
<div v-if="qrLoading" class="qr-loading">
<el-icon class="is-loading"><Loading /></el-icon>
@@ -41,12 +55,12 @@
@click="showPasswordLogin = true"
>
<span>🔐</span>
账号密码+OTP
账号密码登录
</el-button>
</div>
<!-- 账号密码+OTP 登录表单 -->
<div v-else class="password-login">
<!-- 账号密码登录表单 -->
<div v-if="showPasswordLogin" class="password-login">
<el-button
size="small"
class="back-btn"
@@ -137,13 +151,20 @@ import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { User, Lock, Key, Loading } from '@element-plus/icons-vue'
import { useAgentStore } from '@/stores/agent'
import { useWebSocket } from '@/composables/useWebSocket'
import apiClient from '@/api/index'
const router = useRouter()
const { connect: connectWebSocket } = useWebSocket()
const agentStore = useAgentStore()
const formRef = ref<FormInstance>()
/** 是否显示账号密码登录 */
const showPasswordLogin = ref(false)
/** 测试环境标识 */
const isTestEnv = import.meta.env.DEV || window.location.hostname.includes('localhost')
/** 登录面板显示控制 */
const showQrLoginPanel = ref(true) // 默认展示扫码登录
const showPasswordLogin = ref(false) // 是否显示账号密码登录
/** 企微二维码 */
const qrCode = ref('')
@@ -180,65 +201,64 @@ const rules: FormRules = {
}
/**
* 获取企微登录二维码
* 获取企微登录二维码CTRT: apiClient 直接返回 inner data
*/
async function fetchQrCode(): Promise<void> {
qrLoading.value = true
errorMsg.value = ''
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const response = await fetch(`${baseUrl}/auth_qrcode/create`, {
method: 'POST',
headers: { 'Accept': 'application/json' },
})
const response = await apiClient.post('/auth_qrcode/create')
const result = response
if (!response.ok) {
throw new Error(`获取二维码失败: ${response.status}`)
}
const result = await response.json()
if (result.code === 0 && (result.data?.qrcode_url || result.data?.qrcode_png_base64)) {
// qrcode_png_base64 是纯 base64,需要添加 data:image 前缀才能被 img 标签渲染
qrCode.value = result.data.qrcode_png_base64
? `data:image/png;base64,${result.data.qrcode_png_base64}`
: result.data.qrcode_url
currentTicket = result.data.ticket
if (result?.qrcode_url || result?.qrcode_png_base64) {
qrCode.value = result.qrcode_png_base64
? `data:image/png;base64,${result.qrcode_png_base64}`
: result.qrcode_url
currentTicket = result.ticket
startPolling()
} else {
throw new Error(result.message || '获取二维码失败')
throw new Error('获取二维码失败:响应数据为空')
}
} catch (error) {
console.error('获取企微二维码失败:', error)
errorMsg.value = error instanceof Error ? error.message : '获取二维码失败'
if (!errorMsg.value) {
errorMsg.value = error instanceof Error ? error.message : '获取二维码失败,请重试'
}
} finally {
qrLoading.value = false
}
}
/**
* 轮询扫码状态
* 轮询扫码状态CTRT: apiClient 直接返回 inner data
*/
async function pollQrCode(): Promise<void> {
if (!currentTicket) return
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/api'
const response = await fetch(`${baseUrl}/auth_qrcode/poll/${currentTicket}`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
})
const response = await apiClient.get(`/auth_qrcode/poll/${currentTicket}`)
const result = response
const result = await response.json()
if (result.code === 0 && result.data) {
const { status, token } = result.data
if (result) {
const { status, token, employee_id, name } = result
if (status === 'confirmed' && token) {
stopPolling()
localStorage.setItem('agent_token', token)
if (employee_id) {
localStorage.setItem('agent_user_id', employee_id)
}
const agentStore = useAgentStore()
agentStore.token = token
if (employee_id) {
agentStore.agentUserId = employee_id
}
if (name) {
agentStore.agentInfo = { user_id: employee_id, name, status: 'online' }
}
ElMessage.success('登录成功')
connectWebSocket()
router.push('/workspace')
} else if (status === 'expired') {
stopPolling()
@@ -247,21 +267,17 @@ async function pollQrCode(): Promise<void> {
}
}
} catch (error) {
console.error('轮询扫码状态失败:', error)
console.warn('轮询扫码状态失败:', error)
}
}
/**
* 启动轮询
*/
/** 启动轮询 */
function startPolling(): void {
stopPolling()
pollTimer = setInterval(pollQrCode, 2000)
}
/**
* 停止轮询
*/
/** 停止轮询 */
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
@@ -269,16 +285,22 @@ function stopPolling(): void {
}
}
/**
* 返回扫码登录
*/
/** 返回扫码登录 */
function handleBackToQrCode(): void {
showPasswordLogin.value = false
showQrLoginPanel.value = true
fetchQrCode()
}
/** 显示扫码登录面板 */
function showQrLogin(): void {
showQrLoginPanel.value = true
showPasswordLogin.value = false
fetchQrCode()
}
/**
* 处理登录
* 账号密码 + OTP 登录
*/
async function handleLogin(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
@@ -303,6 +325,7 @@ async function handleLogin(): Promise<void> {
}
ElMessage.success('登录成功')
connectWebSocket()
router.push('/workspace')
} catch (error: unknown) {
if (error instanceof Error && error.message === 'require_otp') {
@@ -319,8 +342,66 @@ async function handleLogin(): Promise<void> {
}
}
onMounted(() => {
fetchQrCode()
onMounted(async () => {
// === OAuth 重定向计数清除 ===
const existingToken = localStorage.getItem('agent_token')
if (existingToken) {
console.log('[Login] 检测到已有 token,清除 OAuth 重定向计数')
localStorage.removeItem('oauth_redirect_count')
}
// === OAuth2.0 自动登录流程(参考ITSM系统)===
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const ssoToken = urlParams.get('sso_token')
// 1. 如果有 SSO token,说明已经通过 OAuth 登录成功(CTRT: 直接读取 inner data
if (ssoToken) {
console.log('[Login] 检测到 SSO token,验证身份...')
logging.value = true
try {
const response = await apiClient.get('/auth_wecom/sso/verify', {
params: { sso_token: ssoToken },
})
const result = response
// 拦截器已保证成功即有效数据
if (result) {
const { user_id, name, role } = result
const token = ssoToken
localStorage.setItem('agent_token', token)
localStorage.setItem('agent_user_id', user_id)
agentStore.token = token
agentStore.agentUserId = user_id
agentStore.agentInfo = { user_id, name, status: 'online' }
window.history.replaceState({}, '', window.location.pathname)
ElMessage.success('登录成功')
connectWebSocket()
router.push('/workspace')
return
}
} catch (error) {
console.error('SSO token 验证失败:', error)
window.history.replaceState({}, '', window.location.pathname)
} finally {
logging.value = false
}
}
// 2. 如果有 code,跳转到后端 OAuth 回调
if (code) {
console.log('[Login] 检测到 OAuth code,跳转授权...')
window.location.replace(`/api/auth_wecom/sso/callback?code=${code}&state=`)
return
}
// 3. 默认展示扫码登录(AUTH-09:移除 JS-SDK 免密与智能检测自动跳转)
showQrLogin()
})
onUnmounted(() => {
@@ -329,7 +410,23 @@ onUnmounted(() => {
</script>
<style scoped>
/* 测试环境标识 - 左上角覆盖层 */
.test-env-badge {
position: absolute;
top: 16px;
left: 16px;
z-index: 100;
background: linear-gradient(135deg, #ff6b6b 0%, #ffa500 100%);
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.login-page {
position: relative;
display: flex;
align-items: center;
justify-content: center;