WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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秒
|
||||
// 修复截图发送超时Bug:apiClient默认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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`])
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 连接成功
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user