v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化
This commit is contained in:
@@ -25,16 +25,20 @@ export interface DirectoryEmployee {
|
||||
|
||||
/** 组织架构树节点 */
|
||||
export interface OrgTreeNode {
|
||||
/** 节点 ID(部门名为部门节点 ID,员工 UserID 为叶子节点 ID) */
|
||||
/** 节点 ID(部门为 dept_{id} 前缀,员工 UserID 为叶子节点 ID) */
|
||||
id: string
|
||||
/** 节点显示名称(部门名或员工姓名) */
|
||||
label: string
|
||||
/** 子节点列表(仅部门节点有,员工叶子节点无此字段) */
|
||||
/** 子节点列表(仅部门节点有,混合子部门+员工;员工叶子节点无此字段) */
|
||||
children?: OrgTreeNode[]
|
||||
/** 是否为叶子节点(员工节点为 true,部门节点不设置) */
|
||||
isLeaf?: boolean
|
||||
/** 部门名称(仅员工叶子节点携带,用于前端显示) */
|
||||
department?: string
|
||||
/** 企微原始部门ID(仅部门节点有) */
|
||||
dept_id?: number | null
|
||||
/** 父部门ID(仅部门节点有) */
|
||||
parentid?: number
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// =============================================================================
|
||||
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import type { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig, AxiosRequestConfig } from 'axios'
|
||||
// ElementPlus 消息提示
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
@@ -261,5 +261,22 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
)
|
||||
|
||||
// 导出 Axios 实例,供 API 模块使用
|
||||
export default apiClient
|
||||
// --------------------------------------------------------------------------
|
||||
// 类型修正:响应拦截器已解包 AxiosResponse,重写实例类型使调用方获得正确推断
|
||||
// --------------------------------------------------------------------------
|
||||
// 问题:响应拦截器在运行时返回 res.data(已解包的内部数据),但 TypeScript
|
||||
// 仍认为 apiClient.get() 返回 Promise<AxiosResponse<T>>,导致调用方
|
||||
// const response: AxiosResponse = await apiClient.get(...) 的类型与
|
||||
// 函数声明的返回类型不匹配,产生大量编译错误。
|
||||
// 修复:导出带正确返回类型的接口,使 get/post/put/delete 返回 Promise<T>。
|
||||
interface UnwrappedApiClient
|
||||
extends Omit<AxiosInstance, 'get' | 'post' | 'put' | 'delete' | 'patch'> {
|
||||
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
|
||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>
|
||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>
|
||||
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
|
||||
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T>
|
||||
}
|
||||
|
||||
// 导出 Axios 实例(带修正后的类型),供 API 模块使用
|
||||
export default apiClient as unknown as UnwrappedApiClient
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TypeScript 类型定义 — 与后端 Schema 保持一致
|
||||
@@ -63,6 +62,16 @@ export interface MessageListData {
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
/** 历史消息列表响应(跨会话聚合,对应后端 HistoryMessageListResponse) */
|
||||
export interface HistoryMessageListData {
|
||||
/** 消息列表(按时间倒序,最新在前) */
|
||||
items: Message[]
|
||||
/** 是否还有更多历史消息 */
|
||||
has_more: boolean
|
||||
/** 会话ID → 首条消息摘要(前20字) */
|
||||
conversation_summaries: Record<string, string>
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API 函数
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -85,7 +94,7 @@ export async function getMessages(
|
||||
before?: string
|
||||
}
|
||||
): Promise<MessageListData> {
|
||||
const response: AxiosResponse = await apiClient.get(
|
||||
const response = await apiClient.get(
|
||||
`/conversations/${conversationId}/messages`,
|
||||
{ params }
|
||||
)
|
||||
@@ -129,7 +138,7 @@ export async function sendMessage(
|
||||
}
|
||||
console.log('[sendMessage] 请求体:', requestBody)
|
||||
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
const response = await apiClient.post(
|
||||
`/conversations/${conversationId}/messages`,
|
||||
requestBody,
|
||||
{
|
||||
@@ -157,7 +166,7 @@ export async function pollMessages(
|
||||
if (afterMessageId) {
|
||||
params.after_message_id = afterMessageId
|
||||
}
|
||||
const response: AxiosResponse = await apiClient.get(
|
||||
const response = await apiClient.get(
|
||||
`/conversations/${conversationId}/messages/poll`,
|
||||
{ params }
|
||||
)
|
||||
@@ -171,7 +180,7 @@ export async function pollMessages(
|
||||
* @returns 撤回结果
|
||||
*/
|
||||
export async function recallMessage(messageId: string): Promise<any> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
const response = await apiClient.post(
|
||||
`/messages/${messageId}/recall`
|
||||
)
|
||||
return response
|
||||
@@ -184,7 +193,7 @@ export async function recallMessage(messageId: string): Promise<any> {
|
||||
* @returns 删除结果
|
||||
*/
|
||||
export async function deleteMessage(messageId: string): Promise<any> {
|
||||
const response: AxiosResponse = await apiClient.delete(
|
||||
const response = await apiClient.delete(
|
||||
`/messages/${messageId}`
|
||||
)
|
||||
return response
|
||||
@@ -197,7 +206,7 @@ export async function deleteMessage(messageId: string): Promise<any> {
|
||||
* @returns 标记结果
|
||||
*/
|
||||
export async function markConversationRead(conversationId: string): Promise<any> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
const response = await apiClient.post(
|
||||
`/conversations/${conversationId}/mark-read`
|
||||
)
|
||||
return response
|
||||
@@ -216,7 +225,7 @@ export async function uploadImage(file: File): Promise<{
|
||||
}> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
const response = await apiClient.post(
|
||||
'/messages/image',
|
||||
formData,
|
||||
// ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
|
||||
@@ -238,7 +247,7 @@ export async function uploadMessageFile(file: File): Promise<{
|
||||
}> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
const response = await apiClient.post(
|
||||
'/messages/file',
|
||||
formData,
|
||||
// ISS-B4 修复:不显式设置 Content-Type,让浏览器自动生成带 boundary 的 multipart/form-data
|
||||
@@ -246,3 +255,29 @@ export async function uploadMessageFile(file: File): Promise<{
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取员工历史消息(跨会话聚合)
|
||||
* 将同一员工的所有会话消息合并为一条时间线,按时间排序
|
||||
*
|
||||
* @param employeeId - 员工企微 UserID
|
||||
* @param params - 查询参数
|
||||
* @param params.limit - 每页消息数量(默认50)
|
||||
* @param params.before - 游标消息ID,加载此消息之前的消息(向上翻页)
|
||||
* @param params.current_conversation_id - 当前会话ID(用于标记当前会话)
|
||||
* @returns 历史消息列表数据
|
||||
*/
|
||||
export async function getHistoryMessages(
|
||||
employeeId: string,
|
||||
params?: {
|
||||
limit?: number
|
||||
before?: string
|
||||
current_conversation_id?: string
|
||||
}
|
||||
): Promise<HistoryMessageListData> {
|
||||
const response = await apiClient.get(
|
||||
`/employees/${employeeId}/history-messages`,
|
||||
{ params }
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -4,11 +4,22 @@
|
||||
// 说明:封装与 AI Wingman(坐席智能副驾驶)相关的 HTTP 请求
|
||||
// 对应后端 API:/api/conversations/{id}/wingman
|
||||
// 包括:生成草稿回复、生成会话摘要、生成标签建议
|
||||
// 以及 AI 辅助消息框:自动补齐、语气调整、文字润色、智能改写
|
||||
// =============================================================================
|
||||
|
||||
import apiClient from './index'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
// AI 辅助消息框类型导入
|
||||
import type {
|
||||
AutocompleteResult,
|
||||
ToneType,
|
||||
ToneAdjustResult,
|
||||
PolishAction,
|
||||
PolishResult,
|
||||
RewriteResult,
|
||||
} from '@/types/ai-assist'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// TypeScript 类型定义 — 与后端 Wingman API 响应格式保持一致
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -108,3 +119,120 @@ export async function saveConversationTags(
|
||||
)
|
||||
return response
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// AI 辅助消息框 API(4 个功能)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 自动补齐 — 根据当前输入和上下文生成下一句补齐建议。
|
||||
*
|
||||
* 前端在输入停顿 800ms 后调用,通过 AbortController 支持请求取消
|
||||
* (快速连续输入时 abort 旧请求,只保留最新)。
|
||||
*
|
||||
* @param convId - 会话ID
|
||||
* @param currentText - 当前输入文本
|
||||
* @param cursorPosition - 光标位置(默认 0)
|
||||
* @param maxLength - 补齐最大长度(默认 80)
|
||||
* @param signal - AbortSignal(可选,用于请求取消)
|
||||
* @returns 补齐结果(completion + confidence)
|
||||
*/
|
||||
export async function autocomplete(
|
||||
convId: string,
|
||||
currentText: string,
|
||||
cursorPosition: number = 0,
|
||||
maxLength: number = 80,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AutocompleteResult> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/conversations/${convId}/wingman/autocomplete`,
|
||||
{
|
||||
current_text: currentText,
|
||||
cursor_position: cursorPosition,
|
||||
max_length: maxLength,
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
return response as unknown as AutocompleteResult
|
||||
}
|
||||
|
||||
/**
|
||||
* 语气调整 — 将选中的文字改写为指定语气风格。
|
||||
*
|
||||
* @param convId - 会话ID
|
||||
* @param selectedText - 选中的文字
|
||||
* @param fullText - 输入框完整内容(供 AI 理解上下文)
|
||||
* @param tone - 目标语气:professional/friendly/concise
|
||||
* @param signal - AbortSignal(可选)
|
||||
* @returns 语气调整结果(rewritten_text + changes_summary)
|
||||
*/
|
||||
export async function adjustTone(
|
||||
convId: string,
|
||||
selectedText: string,
|
||||
fullText: string,
|
||||
tone: ToneType,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ToneAdjustResult> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/conversations/${convId}/wingman/tone-adjust`,
|
||||
{
|
||||
selected_text: selectedText,
|
||||
full_text: fullText,
|
||||
tone,
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
return response as unknown as ToneAdjustResult
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字润色 — 对输入文字进行扩写/压缩/纠错处理。
|
||||
*
|
||||
* @param convId - 会话ID
|
||||
* @param text - 待润色文字
|
||||
* @param action - 润色操作:expand/compress/correct
|
||||
* @param signal - AbortSignal(可选)
|
||||
* @returns 润色结果(polished_text + changes_summary)
|
||||
*/
|
||||
export async function polishText(
|
||||
convId: string,
|
||||
text: string,
|
||||
action: PolishAction,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PolishResult> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/conversations/${convId}/wingman/polish`,
|
||||
{
|
||||
text,
|
||||
action,
|
||||
conversation_context: true,
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
return response as unknown as PolishResult
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能改写 — 基于对话上下文和知识库生成多个备选回复。
|
||||
*
|
||||
* @param convId - 会话ID
|
||||
* @param currentText - 当前输入文本(可为空,此时仅基于上下文生成)
|
||||
* @param signal - AbortSignal(可选)
|
||||
* @returns 改写结果(versions 数组,通常 3 个)
|
||||
*/
|
||||
export async function rewriteVersions(
|
||||
convId: string,
|
||||
currentText: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RewriteResult> {
|
||||
const response: AxiosResponse = await apiClient.post(
|
||||
`/conversations/${convId}/wingman/rewrite`,
|
||||
{
|
||||
current_text: currentText,
|
||||
generate_count: 3,
|
||||
include_knowledge: true,
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
return response as unknown as RewriteResult
|
||||
}
|
||||
|
||||
@@ -20,12 +20,15 @@
|
||||
:conversation="conversationStore.currentConversation"
|
||||
:available-agents="agentStore.availableAgents"
|
||||
:can-invite-collaborator="canInviteCollaborator"
|
||||
:history-mode="conversationStore.historyMode"
|
||||
:history-loading="conversationStore.historyLoading"
|
||||
@assign="handleAssign"
|
||||
@resolve="handleResolve"
|
||||
@toggle-pin="handleTogglePin"
|
||||
@toggle-todo="handleToggleTodo"
|
||||
@transfer="handleTransfer"
|
||||
@invite="inviteDialogVisible = true"
|
||||
@toggle-history="handleToggleHistory"
|
||||
/>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
@@ -72,27 +75,53 @@
|
||||
<!-- ================================================================== -->
|
||||
<!-- 消息列表(flex: 1 占满剩余空间) -->
|
||||
<!-- ================================================================== -->
|
||||
<div ref="messageListRef" class="message-list-scroll">
|
||||
<div ref="messageListRef" class="message-list-scroll" @scroll="handleScroll">
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="conversationStore.loadingMessages" style="text-align: center; padding: 20px;">
|
||||
<!-- 正常模式加载中 -->
|
||||
<div v-if="!conversationStore.historyMode && conversationStore.loadingMessages" style="text-align: center; padding: 20px;">
|
||||
<el-icon class="is-loading" :size="20"><Loading /></el-icon>
|
||||
<div style="margin-top: 8px; color: var(--text-tertiary); font-size: 12px;">加载消息中...</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<template v-else>
|
||||
<MessageBubble
|
||||
v-for="msg in conversationStore.messages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
@reply="handleReplyTo"
|
||||
@scroll-to-message="scrollToMessage"
|
||||
/>
|
||||
<!-- 历史模式加载中(首次加载,尚无数据) -->
|
||||
<div v-else-if="conversationStore.historyMode && conversationStore.historyLoading && conversationStore.historyMessages.length === 0" style="text-align: center; padding: 20px;">
|
||||
<el-icon class="is-loading" :size="20"><Loading /></el-icon>
|
||||
<div style="margin-top: 8px; color: var(--text-tertiary); font-size: 12px;">加载历史消息中...</div>
|
||||
</div>
|
||||
|
||||
<!-- 空消息 -->
|
||||
<!-- 历史模式空状态 -->
|
||||
<div
|
||||
v-else-if="conversationStore.historyMode && conversationStore.historyMessages.length === 0"
|
||||
style="text-align: center; padding: 40px; color: var(--text-tertiary);"
|
||||
>
|
||||
暂无历史会话
|
||||
</div>
|
||||
|
||||
<!-- 消息列表(正常模式 + 历史模式共用,含分隔条) -->
|
||||
<template v-else>
|
||||
<!-- 历史模式加载更多(顶部 loading,已有数据时显示) -->
|
||||
<div v-if="conversationStore.historyMode && conversationStore.historyLoading" style="text-align: center; padding: 10px;">
|
||||
<el-icon class="is-loading" :size="16"><Loading /></el-icon>
|
||||
<span style="margin-left: 4px; color: var(--text-tertiary); font-size: 12px;">加载更多...</span>
|
||||
</div>
|
||||
|
||||
<template v-for="item in renderedItems" :key="item.key">
|
||||
<ConversationSeparator
|
||||
v-if="item.type === 'separator'"
|
||||
:summary="item.summary"
|
||||
:is-current="item.isCurrent"
|
||||
/>
|
||||
<MessageBubble
|
||||
v-else
|
||||
:message="item.data"
|
||||
@reply="handleReplyTo"
|
||||
@scroll-to-message="scrollToMessage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 正常模式空消息 -->
|
||||
<div
|
||||
v-if="conversationStore.messages.length === 0"
|
||||
v-if="!conversationStore.historyMode && conversationStore.displayMessages.length === 0"
|
||||
style="text-align: center; padding: 40px; color: var(--text-tertiary);"
|
||||
>
|
||||
暂无消息
|
||||
@@ -128,7 +157,7 @@
|
||||
<!-- 回复建议区(布局优化v2 新增:消息列表与 ReplyBox 之间) -->
|
||||
<!-- ================================================================== -->
|
||||
<ReplySuggestArea
|
||||
v-if="conversationStore.currentConversation?.status !== 'resolved' && conversationStore.currentConversation?.status !== 'pending_close' && conversationStore.currentConversationId"
|
||||
v-if="!conversationStore.historyMode && conversationStore.currentConversation?.status !== 'resolved' && conversationStore.currentConversation?.status !== 'pending_close' && conversationStore.currentConversationId"
|
||||
:conversation-id="conversationStore.currentConversationId"
|
||||
@select="handleSuggestSelect"
|
||||
@send="handleShortcutSend"
|
||||
@@ -150,12 +179,26 @@
|
||||
<!-- 回复输入框(在消息列表下方、排查步骤上方) -->
|
||||
<!-- ================================================================== -->
|
||||
<ReplyBox
|
||||
v-if="conversationStore.currentConversation?.status !== 'resolved'"
|
||||
v-if="!conversationStore.historyMode && conversationStore.currentConversation?.status !== 'resolved'"
|
||||
ref="replyBoxRef"
|
||||
:reply-to-message="replyToMessage"
|
||||
@send="handleSend"
|
||||
@cancel-reply="replyToMessage = null"
|
||||
/>
|
||||
<!-- 历史模式只读提示 -->
|
||||
<div
|
||||
v-else-if="conversationStore.historyMode"
|
||||
style="
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
background-color: var(--bg-accent-soft);
|
||||
"
|
||||
>
|
||||
🕐 查看历史会话中 · 消息只读
|
||||
</div>
|
||||
<!-- 已结单提示 -->
|
||||
<div
|
||||
v-else
|
||||
@@ -234,6 +277,7 @@ import type { Message } from '@/api/message'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
import ReplyBox from './ReplyBox.vue'
|
||||
import ReplySuggestArea from './ReplySuggestArea.vue'
|
||||
import ConversationSeparator from './ConversationSeparator.vue'
|
||||
import InviteDialog from '@/components/conversation/InviteDialog.vue'
|
||||
import InviteParticipantDialog from '@/components/conversation/InviteParticipantDialog.vue'
|
||||
import ParticipantBar from '@/components/conversation/ParticipantBar.vue'
|
||||
@@ -349,6 +393,39 @@ const aiThinkingText = computed(() => {
|
||||
return conversationStore.getAiThinkingText(convId)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 历史模式 — 渲染逻辑
|
||||
// ============================================================================
|
||||
|
||||
/** 渲染项类型:分隔条或消息 */
|
||||
type RenderedItem =
|
||||
| { type: 'separator'; key: string; summary: string; isCurrent: boolean }
|
||||
| { type: 'message'; key: string; data: Message }
|
||||
|
||||
/**
|
||||
* 渲染项列表(消息 + 分隔条混合)
|
||||
* 遍历 displayMessages,当检测到相邻两条消息的 conversation_id 不同时,
|
||||
* 在它们之间插入一个 ConversationSeparator 组件。
|
||||
*/
|
||||
const renderedItems = computed<RenderedItem[]>(() => {
|
||||
const msgs = conversationStore.displayMessages
|
||||
const result: RenderedItem[] = []
|
||||
let lastConvId = ''
|
||||
for (const msg of msgs) {
|
||||
if (msg.conversation_id !== lastConvId) {
|
||||
result.push({
|
||||
type: 'separator',
|
||||
key: `sep-${msg.conversation_id}`,
|
||||
summary: conversationStore.historyConversationSummaries[msg.conversation_id] || '未知会话',
|
||||
isCurrent: msg.conversation_id === conversationStore.currentConversationId,
|
||||
})
|
||||
lastConvId = msg.conversation_id
|
||||
}
|
||||
result.push({ type: 'message', key: msg.id, data: msg })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// 方法
|
||||
// ============================================================================
|
||||
@@ -571,7 +648,8 @@ async function handleSend(content: string): Promise<void> {
|
||||
* 滚动到指定消息(点击引用回复摘要时触发)
|
||||
*/
|
||||
function scrollToMessage(messageId: string): void {
|
||||
const msgIndex = conversationStore.messages.findIndex(m => m.id === messageId)
|
||||
const msgs = conversationStore.displayMessages
|
||||
const msgIndex = msgs.findIndex(m => m.id === messageId)
|
||||
if (msgIndex >= 0 && messageListRef.value) {
|
||||
const messageEls = messageListRef.value.querySelectorAll('.message-row')
|
||||
const targetEl = messageEls[msgIndex] as HTMLElement
|
||||
@@ -597,6 +675,32 @@ function scrollToBottom(): void {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换历史模式
|
||||
* 打开时加载历史消息并滚动到底部,关闭时恢复正常消息列表
|
||||
*/
|
||||
async function handleToggleHistory(): Promise<void> {
|
||||
if (conversationStore.historyMode) {
|
||||
conversationStore.disableHistoryMode()
|
||||
} else {
|
||||
await conversationStore.enableHistoryMode()
|
||||
// 历史模式加载完成后滚动到底部(显示最新消息)
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息列表滚动事件
|
||||
* 历史模式下滚动到顶部时触发加载更多历史消息
|
||||
*/
|
||||
function handleScroll(e: Event): void {
|
||||
if (!conversationStore.historyMode || !conversationStore.historyHasMore || conversationStore.historyLoading) return
|
||||
const el = e.target as HTMLElement
|
||||
if (el.scrollTop < 50) {
|
||||
conversationStore.loadMoreHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 监听
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — 会话分隔条组件
|
||||
// =============================================================================
|
||||
// 说明:历史模式下,在不同会话的消息之间插入分隔条
|
||||
// 功能:
|
||||
// 1. 显示会话首条消息摘要(前20字)
|
||||
// 2. 当前会话高亮显示,并标记"当前会话"
|
||||
// 3. 纯展示组件,无 emit
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="conversation-separator" :class="{ 'is-current': isCurrent }">
|
||||
<span class="conversation-separator__line"></span>
|
||||
<span class="conversation-separator__text">
|
||||
{{ summary }}
|
||||
<span v-if="isCurrent" class="conversation-separator__badge">当前会话</span>
|
||||
</span>
|
||||
<span class="conversation-separator__line"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 分隔条显示文本(首条消息摘要前20字) */
|
||||
summary: string
|
||||
/** 是否为当前会话(当前会话高亮显示) */
|
||||
isCurrent: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 分隔条容器 */
|
||||
.conversation-separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 左右横线 */
|
||||
.conversation-separator__line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-light);
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
/* 中间文本 */
|
||||
.conversation-separator__text {
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 当前会话高亮 */
|
||||
.conversation-separator.is-current {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.conversation-separator.is-current .conversation-separator__line {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* 当前会话标记徽章 */
|
||||
.conversation-separator__badge {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -451,9 +451,31 @@ async function handleSend(): Promise<void> {
|
||||
// ============================================================================
|
||||
// 粘贴事件
|
||||
// ============================================================================
|
||||
/**
|
||||
* 处理粘贴事件
|
||||
* 做什么:检测剪贴板中的图片或文件,自动上传并发送消息
|
||||
* 支持:
|
||||
* 1. 粘贴图片(截图工具 Ctrl+V / 复制图片)
|
||||
* 2. 粘贴文件(从文件管理器/文档复制的文件)
|
||||
* 3. 纯文本粘贴(默认行为,不拦截)
|
||||
*
|
||||
* 修复记录:
|
||||
* - v1.x: 修复纯文本粘贴不生效问题 - 确保 clipboardData 为空时也允许默认行为
|
||||
*/
|
||||
async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
// 获取剪贴板数据,如果不存在则允许浏览器默认处理
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) {
|
||||
// clipboardData 为空时,不阻止默认行为,让浏览器处理纯文本粘贴
|
||||
return
|
||||
}
|
||||
|
||||
const items = clipboardData.items
|
||||
|
||||
// 如果 items 为空(某些浏览器安全限制),允许默认处理
|
||||
if (!items || items.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
@@ -469,6 +491,9 @@ async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 循环结束且没有拦截:说明是纯文本或其他不支持的类型
|
||||
// 不调用 preventDefault,让浏览器执行默认粘贴行为(插入文本到输入框)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -51,10 +51,10 @@
|
||||
|
||||
<!-- 图片消息:显示缩略图(可点击查看大图) -->
|
||||
<template v-else-if="message.msg_type === 'image'">
|
||||
<div class="image-message" @click="previewImage">
|
||||
<div class="image-message message-image" @click="previewImage">
|
||||
<img
|
||||
v-if="message.media_url || message.extra_data?.pic_url"
|
||||
:src="message.media_url || message.extra_data?.pic_url"
|
||||
v-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
:alt="message.file_name || '图片'"
|
||||
class="image-thumbnail"
|
||||
loading="lazy"
|
||||
@@ -126,6 +126,16 @@
|
||||
<span class="ai-structured-action__text">
|
||||
已推送{{ actionTypeLabel }}:{{ message.extra_data.action.title || '智能推荐' }}
|
||||
</span>
|
||||
<!-- 审批卡片:显示跳转到运维平台按钮 -->
|
||||
<el-button
|
||||
v-if="message.extra_data.action.type === 'approval_card'"
|
||||
type="primary"
|
||||
size="small"
|
||||
class="ai-structured-action__jump-btn"
|
||||
@click.stop="openApprovalUrl(message.extra_data.action.url || '')"
|
||||
>
|
||||
打开{{ message.extra_data.action.location || '运维平台' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -319,17 +329,39 @@ function formatFileSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片消息的显示URL(优先级:本地URL > 媒体URL > 企微临时URL)
|
||||
*/
|
||||
const imageUrl = computed(() => {
|
||||
return (
|
||||
props.message.extra_data?.local_media_url ||
|
||||
props.message.media_url ||
|
||||
props.message.extra_data?.pic_url ||
|
||||
""
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 图片预览:点击图片缩略图时,在新标签页打开大图。
|
||||
* 使用浏览器原生能力,无需引入图片预览组件。
|
||||
*/
|
||||
function previewImage(): void {
|
||||
const url = props.message.media_url || props.message.extra_data?.pic_url
|
||||
const url = imageUrl.value
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批卡片跳转:打开运维平台(ITSM)工单创建页面
|
||||
* 同一 corpid 下已实现企微免登录
|
||||
*/
|
||||
function openApprovalUrl(url: string): void {
|
||||
// 优先使用传入的URL,否则使用兜底URL(企微审批IT资产升级)
|
||||
const targetUrl = url || 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?template_id=Bs7ucTGsPuFhxfk8pn8EydxrWxkVetB4JR8Pb6PHS&sp_id=&from=template_list'
|
||||
window.open(targetUrl, '_blank')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 引用回复相关计算属性
|
||||
// ============================================================================
|
||||
@@ -408,6 +440,12 @@ const replyToSender = computed(() => {
|
||||
|
||||
.ai-structured-action__text {
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-structured-action__jump-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* BYOD 补贴卡片摘要样式 */
|
||||
@@ -533,6 +571,12 @@ const replyToSender = computed(() => {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* 图片消息气泡宽度调整 */
|
||||
.message-image {
|
||||
width: auto;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.image-thumbnail {
|
||||
display: block;
|
||||
max-width: 280px;
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
<template v-else-if="message.msg_type === 'image'">
|
||||
<div class="image-message" @click="previewImage">
|
||||
<img
|
||||
v-if="message.media_url || message.extra_data?.pic_url"
|
||||
:src="message.media_url || message.extra_data?.pic_url"
|
||||
v-if="message.media_url || message.extra_data?.pic_url || message.extra_data?.local_media_url"
|
||||
:src="message.media_url || message.extra_data?.local_media_url || message.extra_data?.pic_url"
|
||||
:alt="message.file_name || '图片'"
|
||||
class="image-thumbnail"
|
||||
loading="lazy"
|
||||
@@ -363,7 +363,8 @@ function formatFileSize(bytes: number): string {
|
||||
}
|
||||
|
||||
function previewImage(): void {
|
||||
const url = props.message.media_url || props.message.extra_data?.pic_url
|
||||
// 优先使用本地下载的图片 URL,其次是 media_url,最后是 pic_url
|
||||
const url = props.message.media_url || props.message.extra_data?.local_media_url || props.message.extra_data?.pic_url
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
@@ -75,7 +75,8 @@ defineEmits<{
|
||||
height: 60px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border, #e5e5e5);
|
||||
/* 用户要求:边框宽度减少到三分之一(从1px减少到0.5px) */
|
||||
border: 0.5px solid var(--border, #e5e5e5);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,24 +87,15 @@
|
||||
</div>
|
||||
|
||||
<!-- 右侧:AI 工具(4个:自动补齐/语气/润色/改写) -->
|
||||
<div class="toolbar-right">
|
||||
<button class="tb-btn tb-btn--ai" title="AI自动补齐" @click="handleAiTool('autocomplete')">
|
||||
✨
|
||||
<span class="tb-tip">自动补齐</span>
|
||||
</button>
|
||||
<button class="tb-btn tb-btn--ai" title="AI语气调整" @click="handleAiTool('tone')">
|
||||
🎭
|
||||
<span class="tb-tip">语气</span>
|
||||
</button>
|
||||
<button class="tb-btn tb-btn--ai" title="AI润色" @click="handleAiTool('polish')">
|
||||
✏️
|
||||
<span class="tb-tip">润色</span>
|
||||
</button>
|
||||
<button class="tb-btn tb-btn--ai" title="AI改写" @click="handleAiTool('rewrite')">
|
||||
🔄
|
||||
<span class="tb-tip">改写</span>
|
||||
</button>
|
||||
</div>
|
||||
<AiAssistToolbar
|
||||
:autocomplete-enabled="aiAssist.autocompleteEnabled.value"
|
||||
:has-selection="hasTextSelection"
|
||||
:has-text="hasInputText"
|
||||
@toggle-autocomplete="aiAssist.autocompleteEnabled.value = !aiAssist.autocompleteEnabled.value"
|
||||
@tone-adjust="handleToneButton"
|
||||
@polish="aiAssist.openPolishPanel()"
|
||||
@rewrite="aiAssist.openRewritePanel()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 输入行(圆角卡片:textarea + 语音按钮 + 发送按钮) -->
|
||||
@@ -114,9 +105,10 @@
|
||||
v-model="inputText"
|
||||
class="chat-input"
|
||||
:class="{ 'chat-input--listening': speechState.isListening }"
|
||||
placeholder="输入回复内容... (Enter或Shift+Space发送,Shift+Enter换行)"
|
||||
placeholder="输入回复内容... (Enter或Ctrl+Enter发送,Shift+Enter换行)"
|
||||
:style="{ height: textareaHeight + 'px' }"
|
||||
@keydown="handleKeydown"
|
||||
@input="aiAssist.triggerAutocomplete()"
|
||||
@paste="handlePaste"
|
||||
></textarea>
|
||||
|
||||
@@ -142,6 +134,17 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- AI 补齐建议条 -->
|
||||
<CompletionBar
|
||||
:ghost-text="aiAssist.ghostText.value"
|
||||
:loading="aiAssist.isCompletLoading.value"
|
||||
:confidence="aiAssist.completionConfidence.value"
|
||||
:visible="aiAssist.autocompleteEnabled.value"
|
||||
@accept="aiAssist.acceptCompletion()"
|
||||
@accept-word="aiAssist.acceptCompletionWord()"
|
||||
@dismiss="aiAssist.clearCompletion()"
|
||||
/>
|
||||
|
||||
<!-- 邀请员工/部门弹窗 -->
|
||||
<InviteParticipantDialog
|
||||
v-model="showInviteDialog"
|
||||
@@ -180,6 +183,38 @@
|
||||
@confirm="onCameraConfirm"
|
||||
@cancel="onCameraCancel"
|
||||
/>
|
||||
|
||||
<!-- 语气调整浮层 -->
|
||||
<ToneAdjustPopover
|
||||
:visible="aiAssist.tonePopoverVisible.value"
|
||||
:loading="aiAssist.toneLoading.value"
|
||||
:result="aiAssist.toneResult.value"
|
||||
:original-text="aiAssist.toneOriginalText.value"
|
||||
@select="(tone: any) => aiAssist.adjustTone(aiAssist.toneOriginalText.value, inputText, tone)"
|
||||
@replace="(text: string) => { replaceSelectedText(text); aiAssist.closeTonePopover() }"
|
||||
@cancel="aiAssist.closeTonePopover()"
|
||||
/>
|
||||
|
||||
<!-- 文字润色面板 -->
|
||||
<PolishPanel
|
||||
:visible="aiAssist.polishPanelVisible.value"
|
||||
:loading="aiAssist.polishLoading.value"
|
||||
:result="aiAssist.polishResult.value"
|
||||
:original-text="aiAssist.polishOriginalText.value"
|
||||
@select-action="(action: string) => aiAssist.polishText(action as any)"
|
||||
@replace="(text: string) => handlePolishReplace(text)"
|
||||
@cancel="aiAssist.closePolishPanel()"
|
||||
/>
|
||||
|
||||
<!-- 智能改写面板 -->
|
||||
<RewritePanel
|
||||
:visible="aiAssist.rewritePanelVisible.value"
|
||||
:loading="aiAssist.rewriteLoading.value"
|
||||
:result="aiAssist.rewriteResult.value"
|
||||
@replace="(text: string) => handleRewriteReplace(text)"
|
||||
@append="(text: string) => handleRewriteAppend(text)"
|
||||
@cancel="aiAssist.closeRewritePanel()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -195,6 +230,12 @@ import ScreenshotEditor from './ScreenshotEditor.vue'
|
||||
import ScreenCapture from './ScreenCapture.vue'
|
||||
import CameraCapture from './CameraCapture.vue'
|
||||
import PendingImagePreview from './PendingImagePreview.vue'
|
||||
import CompletionBar from './ai-assist/CompletionBar.vue'
|
||||
import AiAssistToolbar from './ai-assist/AiAssistToolbar.vue'
|
||||
import ToneAdjustPopover from './ai-assist/ToneAdjustPopover.vue'
|
||||
import PolishPanel from './ai-assist/PolishPanel.vue'
|
||||
import RewritePanel from './ai-assist/RewritePanel.vue'
|
||||
import { useAiAssist } from '@/composables/useAiAssist'
|
||||
import { captureScreenAsCanvas } from '@/composables/useScreenCapture'
|
||||
import { usePendingImages } from '@/composables/usePendingImages'
|
||||
import { uploadFile } from '@/api/upload'
|
||||
@@ -317,6 +358,26 @@ const {
|
||||
reset: resetSpeech,
|
||||
} = useSpeechRecognition()
|
||||
|
||||
// ============================================================================
|
||||
// AI 辅助消息框 composable
|
||||
// ============================================================================
|
||||
/**
|
||||
* AI 辅助功能状态管理和请求调度
|
||||
*
|
||||
* 使用 useAiAssist 管理 4 个功能的所有状态:
|
||||
* 自动补齐(CompletionBar)、语气调整(ToneAdjustPopover)、
|
||||
* 文字润色(PolishPanel)、智能改写(RewritePanel)
|
||||
*
|
||||
* Phase 3 集成:补齐 + 语气浮层
|
||||
* Phase 4 集成:润色面板 + 改写面板
|
||||
*/
|
||||
const aiAssist = useAiAssist({
|
||||
getConversationId: () => conversationStore.currentConversationId,
|
||||
getInputText: () => inputText.value,
|
||||
setInputText: (text: string) => { inputText.value = text },
|
||||
getTextareaRef: () => inputRef.value,
|
||||
})
|
||||
|
||||
/**
|
||||
* 开始语音识别前输入框中已有的文字
|
||||
*
|
||||
@@ -360,6 +421,16 @@ const existingParticipantIds = computed(() => {
|
||||
return ids
|
||||
})
|
||||
|
||||
/** 是否有选中文字(语气按钮启用条件) */
|
||||
const hasTextSelection = computed(() => {
|
||||
const el = inputRef.value
|
||||
if (!el) return false
|
||||
return el.selectionStart !== el.selectionEnd && (el.selectionEnd - el.selectionStart) >= 5
|
||||
})
|
||||
|
||||
/** 输入框是否有文字(润色/改写按钮启用条件) */
|
||||
const hasInputText = computed(() => inputText.value.trim().length > 0)
|
||||
|
||||
/** 邀请成功回调 */
|
||||
function onInviteSuccess(): void {
|
||||
// 刷新会话列表(重新拉取最新数据)
|
||||
@@ -571,18 +642,34 @@ function onResizeEnd(): void {
|
||||
* Shift+Space 发送消息(左手快捷键,⚠️ IME冲突时用Enter fallback)
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
// Shift+Space → 发送消息(左手快捷键)
|
||||
// 注意:useKeyboardShortcuts 的 document 级监听器也会处理 Shift+Space
|
||||
// 但当 textarea 聚焦时,这里先触发,stopPropagation 阻止 document 监听器重复触发
|
||||
// ⚠️ 用 event.code === 'Space' 替代 event.key === ' ':
|
||||
// 中文输入法下 Shift+Space 可能被 IME 转为全角空格,event.key 值不确定,
|
||||
// 但 event.code 始终是 'Space'(物理键码不受输入法影响)
|
||||
if ((event.key === ' ' || event.code === 'Space') && event.shiftKey && !event.ctrlKey && !event.altKey) {
|
||||
if (!event.isComposing) {
|
||||
// ===== AI 补齐键盘交互 =====
|
||||
// 仅当有 ghostText 时拦截 Tab / Shift+Tab / Esc
|
||||
if (aiAssist.ghostText.value) {
|
||||
// Tab → 接受全部补齐
|
||||
if (event.key === 'Tab' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleSend()
|
||||
aiAssist.acceptCompletion()
|
||||
return
|
||||
}
|
||||
// Shift+Tab → 接受第一个词
|
||||
if (event.key === 'Tab' && event.shiftKey) {
|
||||
event.preventDefault()
|
||||
aiAssist.acceptCompletionWord()
|
||||
return
|
||||
}
|
||||
// Esc → 清除补齐
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
aiAssist.clearCompletion()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Enter → 发送消息(不被输入法占用)
|
||||
if (event.key === 'Enter' && event.ctrlKey && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
handleSend()
|
||||
return
|
||||
}
|
||||
// Enter 且没有按 Shift → 发送
|
||||
@@ -691,14 +778,27 @@ async function sendPendingImages(convId: string): Promise<void> {
|
||||
* 1. 粘贴图片(截图工具 Ctrl+V / 复制图片)
|
||||
* 2. 粘贴文件(从文件管理器/文档复制的文件)
|
||||
* 3. 纯文本粘贴(默认行为,不拦截)
|
||||
*
|
||||
*
|
||||
* 修复记录:
|
||||
* - v5.4: 扩展支持文档复制的图片(application/octet-stream等非标准MIME类型)
|
||||
* - v5.5: 修复纯文本粘贴不生效问题 - 确保 clipboardData 为空时也允许默认行为
|
||||
*/
|
||||
async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
// 获取剪贴板数据,如果不存在则允许浏览器默认处理
|
||||
const clipboardData = event.clipboardData
|
||||
if (!clipboardData) {
|
||||
// clipboardData 为空时,不阻止默认行为,让浏览器处理纯文本粘贴
|
||||
return
|
||||
}
|
||||
|
||||
const items = clipboardData.items
|
||||
|
||||
// 如果 items 为空(某些浏览器安全限制),允许默认处理
|
||||
if (!items || items.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 遍历剪贴板项,查找文件或图片
|
||||
for (const item of Array.from(items)) {
|
||||
// 情况1:剪贴板包含文件(图片/任意文件)
|
||||
if (item.kind === 'file') {
|
||||
@@ -708,9 +808,9 @@ async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
|
||||
// 根据文件类型选择上传方式
|
||||
// 支持:标准image/*、文档复制的图片(可能是application/octet-stream或空类型但有文件扩展名)
|
||||
const isImage = file.type.startsWith('image/') ||
|
||||
const isImage = file.type.startsWith('image/') ||
|
||||
file.name.match(/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff?)$/i)
|
||||
|
||||
|
||||
if (isImage) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
@@ -721,8 +821,6 @@ async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
|
||||
// 情况2:剪贴板包含图片类型数据(如截图工具复制的PNG)
|
||||
if (item.type.startsWith('image/') && item.kind === 'string') {
|
||||
// 某些浏览器将截图放在 item.getAsFile() 中,上面 'file' 分支已覆盖
|
||||
// 这里仅作防御性检查
|
||||
event.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
@@ -730,27 +828,24 @@ async function handlePaste(event: ClipboardEvent): Promise<void> {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 情况3:文档/文件夹复制的项目(kind='string' 或 MIME类型未知但有文件)
|
||||
|
||||
// 情况3:文档/文件夹复制的项目(kind='string' 且MIME类型是图片)
|
||||
// 从Word/Excel/PDF等复制的图片,MIME可能是空的或非标准类型
|
||||
if (item.kind === 'string') {
|
||||
if (item.kind === 'string' && item.type.startsWith('image/')) {
|
||||
event.preventDefault()
|
||||
// 尝试作为文件获取
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
// 检查是否是图片(按扩展名或MIME)
|
||||
const isImage = file.type.startsWith('image/') ||
|
||||
file.name.match(/\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff?)$/i)
|
||||
if (isImage) {
|
||||
await handleImageUpload(file)
|
||||
} else {
|
||||
await handleFileUpload(file)
|
||||
}
|
||||
await handleImageUpload(file)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 情况4:纯文本 - 不阻止默认行为,让浏览器处理
|
||||
// text/plain 等类型的 string 项让浏览器默认处理
|
||||
}
|
||||
// 纯文本:不拦截,浏览器默认行为(插入文本到输入框)
|
||||
|
||||
// 循环结束且没有拦截:说明是纯文本或其他不支持的类型
|
||||
// 不调用 preventDefault,让浏览器执行默认粘贴行为(插入文本到输入框)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -892,11 +987,87 @@ async function handleFileSelect(event: Event): Promise<void> {
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 语气按钮点击处理
|
||||
*
|
||||
* 获取当前 textarea 中的选中文字,打开语气调整浮层。
|
||||
* 如果未选中文字或选中文字过短(< 5 字符),提示用户先选中。
|
||||
*/
|
||||
function handleToneButton(): void {
|
||||
const el = inputRef.value
|
||||
if (!el) return
|
||||
|
||||
const selectedText = el.value.substring(el.selectionStart, el.selectionEnd)
|
||||
if (!selectedText || selectedText.length < 5) {
|
||||
ElMessage.info('请先选中至少 5 个字符再使用语气调整功能')
|
||||
return
|
||||
}
|
||||
|
||||
aiAssist.openTonePopover(selectedText, inputText.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换 textarea 中的选中文字
|
||||
*
|
||||
* 用于语气调整结果替换:将 textarea 中当前选中的文字替换为新文字。
|
||||
* 保持光标位置不变。
|
||||
*/
|
||||
function replaceSelectedText(newText: string): void {
|
||||
const el = inputRef.value
|
||||
if (!el) return
|
||||
|
||||
const start = el.selectionStart
|
||||
const end = el.selectionEnd
|
||||
const text = inputText.value
|
||||
|
||||
// 替换选中部分
|
||||
inputText.value = text.substring(0, start) + newText + text.substring(end)
|
||||
|
||||
// 光标移动到替换文字之后
|
||||
nextTick(() => {
|
||||
if (el) {
|
||||
el.selectionStart = start + newText.length
|
||||
el.selectionEnd = start + newText.length
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 润色面板 — 替换输入框全部文字。
|
||||
*
|
||||
* 润色是全文级别的操作,直接用结果替换 inputText。
|
||||
*/
|
||||
function handlePolishReplace(newText: string): void {
|
||||
inputText.value = newText
|
||||
aiAssist.closePolishPanel()
|
||||
}
|
||||
|
||||
/**
|
||||
* 改写面板 — 替换输入框全部文字。
|
||||
*/
|
||||
function handleRewriteReplace(newText: string): void {
|
||||
inputText.value = newText
|
||||
aiAssist.closeRewritePanel()
|
||||
}
|
||||
|
||||
/**
|
||||
* 改写面板 — 追加到输入框末尾。
|
||||
*
|
||||
* 如果输入框已有文字,先在末尾补一个空格再追加。
|
||||
*/
|
||||
function handleRewriteAppend(newText: string): void {
|
||||
const current = inputText.value
|
||||
const separator = current.length > 0 && !current.endsWith(' ') ? ' ' : ''
|
||||
inputText.value = current + separator + newText
|
||||
aiAssist.closeRewritePanel()
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 工具按钮点击处理(布局优化v2 新增)
|
||||
*
|
||||
* Q1 确认:AI 工具按钮的后端 API 属于"AI辅助消息框"项目范围,
|
||||
* 本期先预留位置,点击时用 ElMessage.info 提示"功能开发中"
|
||||
* Phase 3 更新:现在由 AiAssistToolbar 组件直接 emit 事件,
|
||||
* 此函数保留用于向后兼容,但已不再使用。
|
||||
*
|
||||
* @param action - AI 工具类型:autocomplete(自动补齐) / tone(语气) / polish(润色) / rewrite(改写)
|
||||
*/
|
||||
@@ -1098,6 +1269,8 @@ onUnmounted(() => {
|
||||
}
|
||||
// 释放待发送图片的 ObjectURL 资源
|
||||
pendingImages.clearAll()
|
||||
// 清理 AI 辅助功能(abort 所有请求 + 清除定时器)
|
||||
aiAssist.cleanup()
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -265,6 +265,15 @@ function triggerSend(): void {
|
||||
* 调用 Wingman API 生成新的草稿
|
||||
*/
|
||||
async function handleRefresh(): Promise<void> {
|
||||
await handleRefreshSilent()
|
||||
ElMessage.success('AI 推荐已刷新')
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默刷新 AI 推荐列表(不弹 toast)
|
||||
* 用于 AI 新消息到达时的自动刷新,避免频繁打扰坐席
|
||||
*/
|
||||
async function handleRefreshSilent(): Promise<void> {
|
||||
if (!props.conversationId) return
|
||||
|
||||
loading.value = true
|
||||
@@ -280,11 +289,9 @@ async function handleRefresh(): Promise<void> {
|
||||
|
||||
// 更新推荐列表
|
||||
loadRecommendations()
|
||||
|
||||
ElMessage.success('AI 推荐已刷新')
|
||||
} catch (error: any) {
|
||||
console.error('刷新 AI 推荐失败:', error)
|
||||
ElMessage.error(error?.message || '刷新 AI 推荐失败')
|
||||
// 静默刷新时仅 console.warn,不弹 toast
|
||||
console.warn('自动刷新 AI 推荐失败:', error?.message || error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -361,6 +368,31 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/**
|
||||
* 监听 AI 新消息到达信号,自动刷新推荐
|
||||
*
|
||||
* 背景:坐席端推荐面板(AiRecommendBar)的数据来自 generateDraft() API,
|
||||
* 而 WS new_message 事件不会自动更新 aiDrafts 缓存。
|
||||
* 当用户发新问题 → AI 回复后,推荐面板仍显示旧推荐,导致"AI推荐和用户问题不同步"。
|
||||
*
|
||||
* 此 watcher 监听 recommendRefreshSignal(每次 AI 消息到达时自增),
|
||||
* 自动触发 handleRefresh() 重新获取推荐,确保推荐内容与最新对话上下文一致。
|
||||
*
|
||||
* 防止震荡:跳过初始触发(immediate 会导致组件挂载时的第 0 次也被 watch 到,
|
||||
* 但因为使用的是 watch(src, fn) 而非 watchEffect,信号不变不会调用)。
|
||||
*/
|
||||
watch(
|
||||
() => conversationStore.recommendRefreshSignal,
|
||||
(newVal, oldVal) => {
|
||||
// 跳过初始值 0(组件挂载时可能触发)
|
||||
if (newVal === 0) return
|
||||
// 仅当有当前会话时才刷新
|
||||
if (!props.conversationId) return
|
||||
// 静默刷新(不弹 toast,避免频繁打扰)
|
||||
handleRefreshSilent()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -84,6 +84,19 @@
|
||||
>
|
||||
📝 备注
|
||||
</span>
|
||||
|
||||
<!-- 历史会话开关 -->
|
||||
<span
|
||||
class="info-chip info-chip--toggle"
|
||||
:class="{
|
||||
'is-active': historyMode,
|
||||
'is-loading': historyLoading,
|
||||
}"
|
||||
@click.stop="$emit('toggle-history')"
|
||||
>
|
||||
<el-icon v-if="historyLoading" class="is-loading"><Loading /></el-icon>
|
||||
<span v-else>{{ historyMode ? '🕐 返回当前' : '🕐 历史会话' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:操作按钮 -->
|
||||
@@ -294,7 +307,7 @@
|
||||
// ============================================================================
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Check, Sort, CircleClose } from '@element-plus/icons-vue'
|
||||
import { Check, Sort, CircleClose, Loading } from '@element-plus/icons-vue'
|
||||
import ItLevelBadge from './ItLevelBadge.vue'
|
||||
import { updateEmployeeItLevel } from '@/api/troubleshooting'
|
||||
import type { Conversation } from '@/api/conversation'
|
||||
@@ -317,11 +330,17 @@ interface Props {
|
||||
availableAgents: AgentInfo[]
|
||||
/** 是否可以摇人 */
|
||||
canInviteCollaborator: boolean
|
||||
/** 历史模式开关 */
|
||||
historyMode: boolean
|
||||
/** 历史模式加载中 */
|
||||
historyLoading: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
availableAgents: () => [],
|
||||
canInviteCollaborator: false,
|
||||
historyMode: false,
|
||||
historyLoading: false,
|
||||
})
|
||||
|
||||
interface Emits {
|
||||
@@ -331,6 +350,7 @@ interface Emits {
|
||||
(e: 'toggle-todo'): void
|
||||
(e: 'transfer', agentId: string): void
|
||||
(e: 'invite'): void
|
||||
(e: 'toggle-history'): void
|
||||
}
|
||||
|
||||
// emit 已声明但未使用(保留以备将来扩展)
|
||||
@@ -691,6 +711,30 @@ defineExpose({ resetForNewConversation })
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
/* 历史会话开关 chip — 三态:关闭(普通) / 打开(高亮) / 加载中(spinner) */
|
||||
.info-chip--toggle {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.info-chip--toggle:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.info-chip--toggle.is-active {
|
||||
background: var(--accent);
|
||||
color: var(--bg-secondary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.info-chip--toggle.is-loading {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* 右侧操作按钮 */
|
||||
.user-info-bar__actions {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — AI 辅助工具栏
|
||||
=============================================================================
|
||||
说明:替换 ReplyBox 中原有的 4 个 AI 占位按钮,提供实际功能入口。
|
||||
按钮状态根据上下文自动启用/禁用。
|
||||
|
||||
Props:
|
||||
autocompleteEnabled - 补齐功能开关状态
|
||||
hasSelection - 是否有选中文字(语气按钮启用条件)
|
||||
hasText - 输入框是否有文字(润色/改写按钮启用条件)
|
||||
|
||||
Emits:
|
||||
toggleAutocomplete - 切换补齐开关
|
||||
toneAdjust - 打开语气调整浮层
|
||||
polish - 打开润色面板
|
||||
rewrite - 打开改写面板
|
||||
============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div class="toolbar-right">
|
||||
<!-- 自动补齐开关 -->
|
||||
<button
|
||||
class="tb-btn tb-btn--ai"
|
||||
:class="{ 'tb-btn--active': autocompleteEnabled }"
|
||||
title="AI自动补齐"
|
||||
@click="$emit('toggleAutocomplete')"
|
||||
>
|
||||
✨
|
||||
<span class="tb-tip">自动补齐</span>
|
||||
</button>
|
||||
|
||||
<!-- 语气调整(需选中文字) -->
|
||||
<button
|
||||
class="tb-btn tb-btn--ai"
|
||||
:class="{ 'tb-btn--disabled': !hasSelection }"
|
||||
:disabled="!hasSelection"
|
||||
title="AI语气调整"
|
||||
@click="$emit('toneAdjust')"
|
||||
>
|
||||
🎭
|
||||
<span class="tb-tip">语气</span>
|
||||
</button>
|
||||
|
||||
<!-- 文字润色(需有输入内容) -->
|
||||
<button
|
||||
class="tb-btn tb-btn--ai"
|
||||
:class="{ 'tb-btn--disabled': !hasText }"
|
||||
:disabled="!hasText"
|
||||
title="AI润色"
|
||||
@click="$emit('polish')"
|
||||
>
|
||||
✏️
|
||||
<span class="tb-tip">润色</span>
|
||||
</button>
|
||||
|
||||
<!-- 智能改写 -->
|
||||
<button
|
||||
class="tb-btn tb-btn--ai"
|
||||
title="AI改写"
|
||||
@click="$emit('rewrite')"
|
||||
>
|
||||
🔄
|
||||
<span class="tb-tip">改写</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 补齐功能是否开启 */
|
||||
autocompleteEnabled: boolean
|
||||
/** 是否有选中文字 */
|
||||
hasSelection: boolean
|
||||
/** 输入框是否有文字 */
|
||||
hasText: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
autocompleteEnabled: true,
|
||||
hasSelection: false,
|
||||
hasText: false,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggleAutocomplete'): void
|
||||
(e: 'toneAdjust'): void
|
||||
(e: 'polish'): void
|
||||
(e: 'rewrite'): void
|
||||
}
|
||||
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* =========================================================================
|
||||
AI 工具栏样式 — 与 ReplyBox 现有工具栏保持一致
|
||||
以下样式由 ReplyBox 的 scoped CSS 覆盖,此处仅作 fallback 和状态定义
|
||||
========================================================================= */
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tb-btn--ai {
|
||||
/* 继承 ReplyBox .tb-btn 样式 */
|
||||
}
|
||||
|
||||
/* 激活态:补齐开关亮起 */
|
||||
.tb-btn--active {
|
||||
opacity: 1;
|
||||
color: #534AB7;
|
||||
background: rgba(83, 74, 183, 0.1);
|
||||
}
|
||||
|
||||
/* 禁用态:灰度 */
|
||||
.tb-btn--disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — AI 补齐建议条
|
||||
=============================================================================
|
||||
说明:显示在 textarea 下方的一条紧凑建议条。
|
||||
显示 AI 自动补齐的建议文字,支持 Tab 接受全部、Shift+Tab 接受首词、Esc 清除。
|
||||
|
||||
Props:
|
||||
ghostText - 补齐建议文字(空字符串时隐藏)
|
||||
loading - 是否正在加载补齐建议(显示思考指示器)
|
||||
confidence - 置信度(0-1),仅供参考
|
||||
visible - 是否可见(父级控制,用于 autocompleteEnabled 联动)
|
||||
|
||||
Emits:
|
||||
accept - Tab → 接受全部建议
|
||||
acceptWord - Shift+Tab → 接受第一个词
|
||||
dismiss - Esc → 清除建议
|
||||
============================================================================= -->
|
||||
|
||||
<template>
|
||||
<div v-if="visible && (ghostText || loading)" class="completion-bar">
|
||||
<!-- 加载指示器 -->
|
||||
<span v-if="loading" class="completion-thinking">AI 正在思考...</span>
|
||||
|
||||
<!-- 建议内容 -->
|
||||
<template v-else>
|
||||
<span class="completion-label">AI 建议:</span>
|
||||
<span class="completion-text">{{ ghostText }}</span>
|
||||
<span class="completion-hint">
|
||||
<kbd>Tab</kbd> 接受
|
||||
<span class="completion-hint__sub">| <kbd>Shift+Tab</kbd> 逐词</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
/** 补齐建议文字 */
|
||||
ghostText: string
|
||||
/** 是否正在加载 */
|
||||
loading: boolean
|
||||
/** 置信度(0-1) */
|
||||
confidence: number
|
||||
/** 是否可见 */
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
ghostText: '',
|
||||
loading: false,
|
||||
confidence: 0,
|
||||
visible: true,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Emits {
|
||||
(e: 'accept'): void
|
||||
(e: 'acceptWord'): void
|
||||
(e: 'dismiss'): void
|
||||
}
|
||||
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* =========================================================================
|
||||
补齐建议条 — 浅紫色底 + 灰色建议文字
|
||||
========================================================================= */
|
||||
.completion-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px;
|
||||
background: rgba(83, 74, 183, 0.06);
|
||||
border-top: 1px solid rgba(83, 74, 183, 0.12);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
min-height: 32px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.completion-label {
|
||||
color: #7B79A3;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.completion-text {
|
||||
flex: 1;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.completion-thinking {
|
||||
color: #7B79A3;
|
||||
font-style: italic;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.completion-hint {
|
||||
color: #B4B2A9;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.completion-hint__sub {
|
||||
color: #CCC;
|
||||
}
|
||||
|
||||
.completion-hint kbd {
|
||||
display: inline-block;
|
||||
padding: 0 4px;
|
||||
background: rgba(255,255,255,0.8);
|
||||
border: 1px solid #D0D0D0;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,470 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — 文字润色面板
|
||||
=============================================================================
|
||||
说明:坐席点击"润色"按钮后打开,支持扩写/压缩/纠错三种操作。
|
||||
左右对比(原文 vs 润色结果),结果可二次编辑后替换。
|
||||
|
||||
Props:
|
||||
visible - 是否可见
|
||||
loading - 是否正在加载
|
||||
result - AI 润色结果(null 时显示操作选择)
|
||||
originalText - 原文(用于左侧展示)
|
||||
|
||||
Emits:
|
||||
selectAction - 选择润色操作(参数:PolishAction)
|
||||
replace - 替换输入框文字(参数:润色后文字)
|
||||
cancel - 关闭面板
|
||||
============================================================================= -->
|
||||
|
||||
<template>
|
||||
<!-- 遮罩层 -->
|
||||
<div v-if="visible" class="polish-overlay" @click="$emit('cancel')"></div>
|
||||
|
||||
<!-- 面板主体 -->
|
||||
<div v-if="visible" class="polish-panel" @click.stop>
|
||||
<!-- 标题栏 -->
|
||||
<div class="polish-header">
|
||||
<span class="polish-title">✏️ 文字润色</span>
|
||||
<button class="polish-close" @click="$emit('cancel')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- 操作选择区(加载前) -->
|
||||
<div v-if="!loading && !result" class="polish-actions-select">
|
||||
<label class="polish-label">选择润色操作</label>
|
||||
<div class="polish-action-tabs">
|
||||
<button
|
||||
v-for="action in polishActions"
|
||||
:key="action.value"
|
||||
class="polish-action-tab"
|
||||
@click="$emit('selectAction', action.value)"
|
||||
>
|
||||
<span class="polish-action-tab__icon">{{ action.icon }}</span>
|
||||
<span class="polish-action-tab__name">{{ action.label }}</span>
|
||||
<span class="polish-action-tab__desc">{{ action.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="polish-loading">
|
||||
<span class="polish-loading__spinner"></span>
|
||||
<span>AI 正在润色...</span>
|
||||
</div>
|
||||
|
||||
<!-- 对比结果区(加载完成后) -->
|
||||
<div v-if="result" class="polish-body">
|
||||
<!-- 对比区 -->
|
||||
<div class="polish-compare">
|
||||
<!-- 左侧:原文 -->
|
||||
<div class="polish-side">
|
||||
<label class="polish-label">原文</label>
|
||||
<div class="polish-text-box polish-text-box--original">
|
||||
{{ originalText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:润色结果(可编辑) -->
|
||||
<div class="polish-side">
|
||||
<label class="polish-label">
|
||||
润色结果
|
||||
<span class="polish-action-badge">{{ polishActionLabel }}</span>
|
||||
</label>
|
||||
<textarea
|
||||
ref="editTextareaRef"
|
||||
v-model="editedText"
|
||||
class="polish-text-box polish-text-box--result"
|
||||
placeholder="润色结果为空"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 变更摘要 -->
|
||||
<p v-if="result.changes_summary" class="polish-summary">
|
||||
💡 {{ result.changes_summary }}
|
||||
</p>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="polish-footer">
|
||||
<button class="polish-btn polish-btn--ghost" @click="$emit('cancel')">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="polish-btn polish-btn--primary"
|
||||
:disabled="!editedText"
|
||||
@click="$emit('replace', editedText)"
|
||||
>
|
||||
替换原文
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
import type { PolishAction, PolishResult } from '@/types/ai-assist'
|
||||
import { POLISH_LABELS } from '@/types/ai-assist'
|
||||
import { computed } from 'vue'
|
||||
|
||||
// ============================================================================
|
||||
// 润色操作选项
|
||||
// ============================================================================
|
||||
|
||||
const polishActions: Array<{
|
||||
value: PolishAction
|
||||
icon: string
|
||||
label: string
|
||||
desc: string
|
||||
}> = [
|
||||
{ value: 'expand', icon: '📝', label: POLISH_LABELS.expand, desc: '补充细节,丰富内容' },
|
||||
{ value: 'compress', icon: '📋', label: POLISH_LABELS.compress, desc: '精简文字,删除冗余' },
|
||||
{ value: 'correct', icon: '✅', label: POLISH_LABELS.correct, desc: '纠正错别字和语法' },
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
loading: boolean
|
||||
result: PolishResult | null
|
||||
originalText: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
loading: false,
|
||||
result: null,
|
||||
originalText: '',
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Emits {
|
||||
(e: 'selectAction', action: PolishAction): void
|
||||
(e: 'replace', text: string): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 本地状态 — 可编辑的润色结果
|
||||
// ============================================================================
|
||||
|
||||
/** textarea DOM 引用 */
|
||||
const editTextareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
/** 可编辑的润色结果文字(初始值从 result.polished_text 填充) */
|
||||
const editedText = ref('')
|
||||
|
||||
// 当 result 变化时,重置编辑文字
|
||||
watch(
|
||||
() => props.result,
|
||||
(newResult) => {
|
||||
if (newResult) {
|
||||
editedText.value = newResult.polished_text
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 当面板关闭时,清空编辑文字
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVisible) => {
|
||||
if (!newVisible) {
|
||||
editedText.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** 当前润色操作的中文标签 */
|
||||
const polishActionLabel = computed(() => {
|
||||
if (!props.result) return ''
|
||||
return POLISH_LABELS[props.result.action] || props.result.action
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* =========================================================================
|
||||
文字润色面板样式 — 全屏遮罩 + 居中卡片式面板
|
||||
========================================================================= */
|
||||
|
||||
/* 遮罩层 */
|
||||
.polish-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* 面板主体 — 居中卡片 */
|
||||
.polish-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 720px;
|
||||
max-width: 92vw;
|
||||
max-height: 85vh;
|
||||
background: #FFF;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.16);
|
||||
z-index: 1201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.polish-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #F0F0F0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.polish-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.polish-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.polish-close:hover {
|
||||
background: #F5F5F5;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.polish-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 操作选择区 */
|
||||
.polish-actions-select {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.polish-action-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.polish-action-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: #FAFAFA;
|
||||
border: 1px solid #EBEBEB;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.polish-action-tab:hover {
|
||||
background: rgba(83, 74, 183, 0.04);
|
||||
border-color: #534AB7;
|
||||
}
|
||||
|
||||
.polish-action-tab__icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.polish-action-tab__name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.polish-action-tab__desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.polish-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 32px 20px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.polish-loading__spinner {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #EBEBEB;
|
||||
border-top-color: #534AB7;
|
||||
border-radius: 50%;
|
||||
animation: polish-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes polish-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 对比结果区 */
|
||||
.polish-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.polish-compare {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.polish-side {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.polish-text-box {
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
max-height: 360px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #303133;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.polish-text-box--original {
|
||||
background: #F8F8F8;
|
||||
border: 1px solid #EBEBEB;
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.polish-text-box--result {
|
||||
background: #FFF;
|
||||
border: 1px solid #DCDFE6;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
/* 移除默认 textarea 样式 */
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.polish-text-box--result:focus {
|
||||
border-color: #534AB7;
|
||||
box-shadow: 0 0 0 2px rgba(83, 74, 183, 0.1);
|
||||
}
|
||||
|
||||
/* 当前操作标签 */
|
||||
.polish-action-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 8px;
|
||||
background: rgba(83, 74, 183, 0.08);
|
||||
color: #534AB7;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 变更摘要 */
|
||||
.polish-summary {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
color: #B4B2A9;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 底部操作栏 */
|
||||
.polish-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
}
|
||||
|
||||
.polish-btn {
|
||||
padding: 8px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.polish-btn--ghost {
|
||||
background: #FFF;
|
||||
border-color: #DCDFE6;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.polish-btn--ghost:hover {
|
||||
border-color: #C0C4CC;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.polish-btn--primary {
|
||||
background: #534AB7;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.polish-btn--primary:hover:not(:disabled) {
|
||||
background: #4740A0;
|
||||
}
|
||||
|
||||
.polish-btn--primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,372 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — 智能改写面板
|
||||
=============================================================================
|
||||
说明:坐席点击"改写"按钮后打开,自动调用 AI 生成 3 个改写版本。
|
||||
每个版本以卡片形式展示,附风格标签和来源说明,支持替换或追加到输入框。
|
||||
|
||||
Props:
|
||||
visible - 是否可见
|
||||
loading - 是否正在加载
|
||||
result - AI 改写结果(包含 3 个版本的数组)
|
||||
|
||||
Emits:
|
||||
replace - 替换输入框文字(参数:选中的版本文字)
|
||||
append - 追加到输入框末尾(参数:选中的版本文字)
|
||||
cancel - 关闭面板
|
||||
============================================================================= -->
|
||||
|
||||
<template>
|
||||
<!-- 遮罩层 -->
|
||||
<div v-if="visible" class="rewrite-overlay" @click="$emit('cancel')"></div>
|
||||
|
||||
<!-- 面板主体 -->
|
||||
<div v-if="visible" class="rewrite-panel" @click.stop>
|
||||
<!-- 标题栏 -->
|
||||
<div class="rewrite-header">
|
||||
<span class="rewrite-title">🔄 智能改写</span>
|
||||
<button class="rewrite-close" @click="$emit('cancel')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="rewrite-loading">
|
||||
<span class="rewrite-loading__spinner"></span>
|
||||
<div class="rewrite-loading__info">
|
||||
<p class="rewrite-loading__title">正在生成改写版本</p>
|
||||
<p class="rewrite-loading__sub">AI 将基于对话上下文和知识库,为你提供多个改写建议</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 改写结果列表 -->
|
||||
<div v-if="!loading && versions.length > 0" class="rewrite-body">
|
||||
<div class="rewrite-version-list">
|
||||
<div
|
||||
v-for="(version, idx) in versions"
|
||||
:key="idx"
|
||||
class="rewrite-version-card"
|
||||
>
|
||||
<!-- 卡片头部:序号 + 风格标签 + 来源 -->
|
||||
<div class="rewrite-version-card__head">
|
||||
<span class="rewrite-version-card__index">版本 {{ idx + 1 }}</span>
|
||||
<span class="rewrite-version-card__style">{{ version.style }}</span>
|
||||
<span v-if="version.source" class="rewrite-version-card__source">
|
||||
{{ version.source }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 卡片正文 -->
|
||||
<div class="rewrite-version-card__content">
|
||||
{{ version.text }}
|
||||
</div>
|
||||
|
||||
<!-- 卡片操作 -->
|
||||
<div class="rewrite-version-card__actions">
|
||||
<button
|
||||
class="rewrite-action-btn rewrite-action-btn--primary"
|
||||
@click="$emit('replace', version.text)"
|
||||
>
|
||||
替换
|
||||
</button>
|
||||
<button
|
||||
class="rewrite-action-btn"
|
||||
@click="$emit('append', version.text)"
|
||||
>
|
||||
追加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空结果提示 -->
|
||||
<div v-if="!loading && result && versions.length === 0" class="rewrite-empty">
|
||||
<span class="rewrite-empty__icon">📭</span>
|
||||
<p class="rewrite-empty__text">暂无改写建议</p>
|
||||
<p class="rewrite-empty__hint">AI 未能生成合适的改写版本,请稍后重试</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { RewriteResult } from '@/types/ai-assist'
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
loading: boolean
|
||||
result: RewriteResult | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
loading: false,
|
||||
result: null,
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Emits {
|
||||
(e: 'replace', text: string): void
|
||||
(e: 'append', text: string): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
defineEmits<Emits>()
|
||||
|
||||
// ============================================================================
|
||||
// 计算属性
|
||||
// ============================================================================
|
||||
|
||||
/** 改写版本列表(从 result 解包,空数组兜底) */
|
||||
const versions = computed(() => {
|
||||
return props.result?.versions ?? []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* =========================================================================
|
||||
智能改写面板样式 — 全屏遮罩 + 居中卡片式面板
|
||||
========================================================================= */
|
||||
|
||||
/* 遮罩层 */
|
||||
.rewrite-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* 面板主体 — 居中卡片 */
|
||||
.rewrite-panel {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 680px;
|
||||
max-width: 92vw;
|
||||
max-height: 80vh;
|
||||
background: #FFF;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.16);
|
||||
z-index: 1201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.rewrite-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #F0F0F0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rewrite-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.rewrite-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.rewrite-close:hover {
|
||||
background: #F5F5F5;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.rewrite-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.rewrite-loading__spinner {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2.5px solid #EBEBEB;
|
||||
border-top-color: #534AB7;
|
||||
border-radius: 50%;
|
||||
animation: rewrite-spin 0.6s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes rewrite-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.rewrite-loading__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rewrite-loading__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.rewrite-loading__sub {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 改写结果区 */
|
||||
.rewrite-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.rewrite-version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 版本卡片 */
|
||||
.rewrite-version-card {
|
||||
border: 1px solid #EBEBEB;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.rewrite-version-card:hover {
|
||||
border-color: #534AB7;
|
||||
}
|
||||
|
||||
.rewrite-version-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: #FAFAFA;
|
||||
border-bottom: 1px solid #F0F0F0;
|
||||
}
|
||||
|
||||
.rewrite-version-card__index {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #534AB7;
|
||||
}
|
||||
|
||||
.rewrite-version-card__style {
|
||||
font-size: 11px;
|
||||
padding: 2px 10px;
|
||||
background: rgba(83, 74, 183, 0.08);
|
||||
color: #534AB7;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rewrite-version-card__source {
|
||||
font-size: 11px;
|
||||
color: #B4B2A9;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.rewrite-version-card__content {
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #303133;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.rewrite-version-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 14px 12px;
|
||||
}
|
||||
|
||||
.rewrite-action-btn {
|
||||
flex: 1;
|
||||
padding: 7px 16px;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 6px;
|
||||
background: #FFF;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.rewrite-action-btn:hover {
|
||||
border-color: #C0C4CC;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.rewrite-action-btn--primary {
|
||||
background: #534AB7;
|
||||
border-color: #534AB7;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.rewrite-action-btn--primary:hover {
|
||||
background: #4740A0;
|
||||
border-color: #4740A0;
|
||||
}
|
||||
|
||||
/* 空结果 */
|
||||
.rewrite-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rewrite-empty__icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rewrite-empty__text {
|
||||
margin: 0 0 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.rewrite-empty__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,341 @@
|
||||
<!-- =============================================================================
|
||||
企微IT智能服务台 — 语气调整浮层
|
||||
=============================================================================
|
||||
说明:坐席选中文字后,点击"语气"按钮打开此浮层。
|
||||
先选择目标语气(专业/友好/简洁),AI 处理后显示对比结果。
|
||||
|
||||
Props:
|
||||
visible - 是否可见
|
||||
loading - 是否正在加载
|
||||
result - AI 语气调整结果(null 时显示语气选择)
|
||||
originalText - 原文(用于对比显示)
|
||||
|
||||
Emits:
|
||||
select - 选择目标语气 → 触发 API 调用(参数:ToneType)
|
||||
replace - 替换选中文字(参数:改写后的文字)
|
||||
cancel - 关闭浮层
|
||||
============================================================================= -->
|
||||
|
||||
<template>
|
||||
<!-- 遮罩层 -->
|
||||
<div v-if="visible" class="tone-overlay" @click="$emit('cancel')"></div>
|
||||
|
||||
<!-- 浮层 -->
|
||||
<div v-if="visible" class="tone-popover" @click.stop>
|
||||
<!-- 标题栏 -->
|
||||
<div class="tone-header">
|
||||
<span class="tone-title">语气调整</span>
|
||||
<button class="tone-close" @click="$emit('cancel')">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- 原文展示 -->
|
||||
<div class="tone-original">
|
||||
<label class="tone-label">原文</label>
|
||||
<p class="tone-text">{{ originalText }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 语气选择按钮(加载前) -->
|
||||
<div v-if="!loading && !result" class="tone-select">
|
||||
<label class="tone-label">选择目标语气</label>
|
||||
<div class="tone-options">
|
||||
<button
|
||||
v-for="tone in toneOptions"
|
||||
:key="tone.value"
|
||||
class="tone-option"
|
||||
@click="$emit('select', tone.value)"
|
||||
>
|
||||
<span class="tone-option__icon">{{ tone.icon }}</span>
|
||||
<span class="tone-option__name">{{ tone.label }}</span>
|
||||
<span class="tone-option__desc">{{ tone.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="tone-loading">
|
||||
<span class="tone-loading__spinner"></span>
|
||||
<span>AI 正在改写...</span>
|
||||
</div>
|
||||
|
||||
<!-- 改写结果 -->
|
||||
<div v-if="result" class="tone-result">
|
||||
<label class="tone-label">改写结果</label>
|
||||
<p class="tone-text tone-text--highlight">{{ result.rewritten_text || 'AI 暂不可用' }}</p>
|
||||
<p v-if="result.changes_summary" class="tone-summary">{{ result.changes_summary }}</p>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="tone-actions">
|
||||
<button class="tone-btn tone-btn--primary" @click="$emit('replace', result.rewritten_text)">
|
||||
替换原文
|
||||
</button>
|
||||
<button class="tone-btn" @click="$emit('cancel')">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ============================================================================
|
||||
// 导入
|
||||
// ============================================================================
|
||||
|
||||
import type { ToneType, ToneAdjustResult } from '@/types/ai-assist'
|
||||
import { TONE_LABELS } from '@/types/ai-assist'
|
||||
|
||||
// ============================================================================
|
||||
// 语气选项
|
||||
// ============================================================================
|
||||
|
||||
const toneOptions: Array<{
|
||||
value: ToneType
|
||||
icon: string
|
||||
label: string
|
||||
desc: string
|
||||
}> = [
|
||||
{ value: 'professional', icon: '💼', label: TONE_LABELS.professional, desc: '技术术语,结构清晰' },
|
||||
{ value: 'friendly', icon: '😊', label: TONE_LABELS.friendly, desc: '亲和温暖,贴心关怀' },
|
||||
{ value: 'concise', icon: '⚡', label: TONE_LABELS.concise, desc: '开门见山,直奔主题' },
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// Props
|
||||
// ============================================================================
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
loading: boolean
|
||||
result: ToneAdjustResult | null
|
||||
originalText: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
loading: false,
|
||||
result: null,
|
||||
originalText: '',
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Emits
|
||||
// ============================================================================
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', tone: ToneType): void
|
||||
(e: 'replace', text: string): void
|
||||
(e: 'cancel'): void
|
||||
}
|
||||
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* =========================================================================
|
||||
语气调整浮层样式
|
||||
========================================================================= */
|
||||
|
||||
/* 遮罩层 */
|
||||
.tone-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 浮层 */
|
||||
.tone-popover {
|
||||
position: absolute;
|
||||
/* 定位由 ReplyBox 的 CSS 控制,此处作为 fallback */
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
background: #FFF;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
z-index: 1001;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
.tone-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid #F0F0F0;
|
||||
}
|
||||
|
||||
.tone-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.tone-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tone-close:hover {
|
||||
background: #F5F5F5;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.tone-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #909399;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 文本展示 */
|
||||
.tone-original,
|
||||
.tone-result {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.tone-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #606266;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tone-text--highlight {
|
||||
color: #303133;
|
||||
background: rgba(83, 74, 183, 0.04);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #534AB7;
|
||||
}
|
||||
|
||||
.tone-summary {
|
||||
margin: 8px 0 0;
|
||||
font-size: 11px;
|
||||
color: #B4B2A9;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 语气选择区 */
|
||||
.tone-select {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
}
|
||||
|
||||
.tone-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tone-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: #FAFAFA;
|
||||
border: 1px solid #EBEBEB;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tone-option:hover {
|
||||
background: rgba(83, 74, 183, 0.04);
|
||||
border-color: #534AB7;
|
||||
}
|
||||
|
||||
.tone-option__icon {
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tone-option__name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.tone-option__desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
.tone-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 16px;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tone-loading__spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #EBEBEB;
|
||||
border-top-color: #534AB7;
|
||||
border-radius: 50%;
|
||||
animation: tone-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes tone-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.tone-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
}
|
||||
|
||||
.tone-btn {
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #DCDFE6;
|
||||
border-radius: 6px;
|
||||
background: #FFF;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tone-btn:hover {
|
||||
border-color: #C0C4CC;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.tone-btn--primary {
|
||||
background: #534AB7;
|
||||
border-color: #534AB7;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
.tone-btn--primary:hover {
|
||||
background: #4740A0;
|
||||
border-color: #4740A0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,528 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — AI 辅助消息框 Composable
|
||||
// =============================================================================
|
||||
// 说明:封装坐席端 AI 工具栏 4 个功能的状态管理和请求调度逻辑。
|
||||
//
|
||||
// 功能:
|
||||
// 1. 自动补齐 — 输入停顿 800ms 触发,CompletionBar 显示建议
|
||||
// 2. 语气调整 — 选中文字后选择语气,ToneAdjustPopover 显示对比
|
||||
// 3. 文字润色 — 扩写/压缩/纠错,PolishPanel 左右对比
|
||||
// 4. 智能改写 — 生成 3 个版本,RewritePanel 卡片选择
|
||||
//
|
||||
// 交互协议:
|
||||
// Tab → 接受补齐全部文字 Shift+Tab → 接受补齐第一个词
|
||||
// Esc → 清除补齐 继续输入 → 清除旧建议
|
||||
//
|
||||
// 依赖:wingman.ts API 函数、ai-assist.d.ts 类型定义
|
||||
// =============================================================================
|
||||
|
||||
import { ref, type Ref } from 'vue'
|
||||
import {
|
||||
autocomplete as autocompleteApi,
|
||||
adjustTone as adjustToneApi,
|
||||
polishText as polishTextApi,
|
||||
rewriteVersions as rewriteVersionsApi,
|
||||
} from '@/api/wingman'
|
||||
import type {
|
||||
ToneType,
|
||||
PolishAction,
|
||||
ToneAdjustResult,
|
||||
PolishResult,
|
||||
RewriteResult,
|
||||
} from '@/types/ai-assist'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Options 接口 — 由 ReplyBox.vue 注入
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export interface UseAiAssistOptions {
|
||||
/** 获取当前会话ID(可能为 undefined,未选中会话时) */
|
||||
getConversationId: () => string | undefined
|
||||
/** 获取输入框当前文本 */
|
||||
getInputText: () => string
|
||||
/** 设置输入框文本 */
|
||||
setInputText: (text: string) => void
|
||||
/** 获取 textarea DOM 引用(用于获取光标位置/选中文字) */
|
||||
getTextareaRef: () => HTMLTextAreaElement | null
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 补齐分词正则 — 按空格和中文/英文标点分割
|
||||
// --------------------------------------------------------------------------
|
||||
const WORD_SPLIT_RE = /\s+|[,,。;;::!!??、()()]+/
|
||||
|
||||
// 补齐 debounce 延迟(毫秒)
|
||||
const AUTOCOMPLETE_DEBOUNCE_MS = 800
|
||||
|
||||
// 补齐前端超时(毫秒)
|
||||
const AUTOCOMPLETE_TIMEOUT_MS = 1500
|
||||
|
||||
// 补齐触发最小字符数
|
||||
const AUTOCOMPLETE_MIN_CHARS = 5
|
||||
|
||||
// 置信度阈值(低于此值不显示建议)
|
||||
const CONFIDENCE_THRESHOLD = 0.5
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Composable
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export function useAiAssist(options: UseAiAssistOptions) {
|
||||
const { getConversationId, getInputText, setInputText } = options
|
||||
|
||||
// ========================
|
||||
// 自动补齐 状态
|
||||
// ========================
|
||||
/** 补齐建议文字 */
|
||||
const ghostText: Ref<string> = ref('')
|
||||
/** 补齐请求加载中 */
|
||||
const isCompletLoading: Ref<boolean> = ref(false)
|
||||
/** 补齐功能开关(默认开启) */
|
||||
const autocompleteEnabled: Ref<boolean> = ref(true)
|
||||
/** 补齐置信度(0-1) */
|
||||
const completionConfidence: Ref<number> = ref(0)
|
||||
|
||||
// ========================
|
||||
// 语气调整 状态
|
||||
// ========================
|
||||
/** 语气选择浮层可见 */
|
||||
const tonePopoverVisible: Ref<boolean> = ref(false)
|
||||
/** 语气调整加载中 */
|
||||
const toneLoading: Ref<boolean> = ref(false)
|
||||
/** 语气调整结果 */
|
||||
const toneResult: Ref<ToneAdjustResult | null> = ref(null)
|
||||
/** 语气调整原文(用于对比显示) */
|
||||
const toneOriginalText: Ref<string> = ref('')
|
||||
|
||||
// ========================
|
||||
// 文字润色 状态
|
||||
// ========================
|
||||
/** 润色面板可见 */
|
||||
const polishPanelVisible: Ref<boolean> = ref(false)
|
||||
/** 润色加载中 */
|
||||
const polishLoading: Ref<boolean> = ref(false)
|
||||
/** 润色结果 */
|
||||
const polishResult: Ref<PolishResult | null> = ref(null)
|
||||
/** 润色原文(用于左右对比) */
|
||||
const polishOriginalText: Ref<string> = ref('')
|
||||
|
||||
// ========================
|
||||
// 智能改写 状态
|
||||
// ========================
|
||||
/** 改写面板可见 */
|
||||
const rewritePanelVisible: Ref<boolean> = ref(false)
|
||||
/** 改写加载中 */
|
||||
const rewriteLoading: Ref<boolean> = ref(false)
|
||||
/** 改写结果 */
|
||||
const rewriteResult: Ref<RewriteResult | null> = ref(null)
|
||||
|
||||
// ========================
|
||||
// 内部状态
|
||||
// ========================
|
||||
/** 当前补齐请求的 AbortController(用于请求取消) */
|
||||
let _completionController: AbortController | null = null
|
||||
/** 补齐 debounce 定时器 */
|
||||
let _debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** 语气调整 AbortController */
|
||||
let _toneController: AbortController | null = null
|
||||
/** 润色 AbortController */
|
||||
let _polishController: AbortController | null = null
|
||||
/** 改写 AbortController */
|
||||
let _rewriteController: AbortController | null = null
|
||||
|
||||
// ====================================================================
|
||||
// 自动补齐
|
||||
// ====================================================================
|
||||
|
||||
/**
|
||||
* 触发自动补齐 — 由输入事件调用,内部 debounce 800ms。
|
||||
*
|
||||
* 快速连续输入时会 abort 旧请求,只保留最新。
|
||||
*/
|
||||
function triggerAutocomplete(): void {
|
||||
// 检查前置条件
|
||||
if (!autocompleteEnabled.value) return
|
||||
|
||||
const text = getInputText()
|
||||
if (text.length < AUTOCOMPLETE_MIN_CHARS) {
|
||||
clearCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
const convId = getConversationId()
|
||||
if (!convId) return
|
||||
|
||||
// 清除旧建议
|
||||
ghostText.value = ''
|
||||
|
||||
// abort 旧请求
|
||||
if (_completionController) {
|
||||
_completionController.abort()
|
||||
_completionController = null
|
||||
}
|
||||
|
||||
// debounce 重置
|
||||
if (_debounceTimer) {
|
||||
clearTimeout(_debounceTimer)
|
||||
}
|
||||
|
||||
_debounceTimer = setTimeout(async () => {
|
||||
// 创建新 AbortController(含前端超时)
|
||||
_completionController = new AbortController()
|
||||
const signal = _completionController.signal
|
||||
|
||||
// 前端超时兜底
|
||||
const timeoutId = setTimeout(() => {
|
||||
_completionController?.abort()
|
||||
}, AUTOCOMPLETE_TIMEOUT_MS)
|
||||
|
||||
isCompletLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await autocompleteApi(
|
||||
convId,
|
||||
text,
|
||||
0, // cursorPosition(保留字段)
|
||||
80, // maxLength
|
||||
signal,
|
||||
)
|
||||
|
||||
// 请求未被 abort 且置信度足够 → 显示建议
|
||||
if (!signal.aborted && result.confidence >= CONFIDENCE_THRESHOLD) {
|
||||
ghostText.value = result.completion
|
||||
completionConfidence.value = result.confidence
|
||||
}
|
||||
} catch (e: any) {
|
||||
// AbortError 是正常行为(用户继续输入),静默处理
|
||||
if (e?.name !== 'AbortError' && e?.code !== 'ERR_CANCELED') {
|
||||
console.error('[useAiAssist] 自动补齐失败:', e)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
isCompletLoading.value = false
|
||||
_completionController = null
|
||||
}
|
||||
}, AUTOCOMPLETE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受全部补齐建议(Tab 键触发)。
|
||||
* 将 ghostText 追加到输入框末尾。
|
||||
*/
|
||||
function acceptCompletion(): void {
|
||||
if (!ghostText.value) return
|
||||
setInputText(getInputText() + ghostText.value)
|
||||
ghostText.value = ''
|
||||
completionConfidence.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受补齐第一个词(Shift+Tab 键触发)。
|
||||
* 按空格和标点分词,取第一部分追加。
|
||||
*/
|
||||
function acceptCompletionWord(): void {
|
||||
if (!ghostText.value) return
|
||||
|
||||
const parts = ghostText.value.split(WORD_SPLIT_RE).filter(Boolean)
|
||||
if (parts.length === 0) return
|
||||
|
||||
// 追加第一个词
|
||||
const text = getInputText()
|
||||
// 如果末尾不是空格且不是开头,加空格
|
||||
const needSpace = text.length > 0 && !text.endsWith(' ')
|
||||
setInputText(text + (needSpace ? ' ' : '') + parts[0])
|
||||
|
||||
// 剩余部分设为新的 ghostText
|
||||
ghostText.value = parts.length > 1 ? parts.slice(1).join(' ') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除补齐建议(Esc 键或继续输入时触发)。
|
||||
*/
|
||||
function clearCompletion(): void {
|
||||
ghostText.value = ''
|
||||
completionConfidence.value = 0
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 语气调整
|
||||
// ====================================================================
|
||||
|
||||
/**
|
||||
* 打开语气选择浮层。
|
||||
*
|
||||
* @param selectedText - 选中的文字(调用方从 textarea selection 获取)
|
||||
* @param fullText - 输入框完整内容
|
||||
*/
|
||||
function openTonePopover(selectedText: string, _fullText: string): void {
|
||||
if (!selectedText || selectedText.length < 5) return
|
||||
|
||||
toneOriginalText.value = selectedText
|
||||
toneResult.value = null
|
||||
tonePopoverVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行语气调整 API 调用。
|
||||
*
|
||||
* @param selectedText - 选中的文字
|
||||
* @param fullText - 输入框完整内容
|
||||
* @param tone - 目标语气
|
||||
*/
|
||||
async function adjustTone(
|
||||
selectedText: string,
|
||||
fullText: string,
|
||||
tone: ToneType,
|
||||
): Promise<void> {
|
||||
const convId = getConversationId()
|
||||
if (!convId) return
|
||||
|
||||
// abort 上一个语气请求
|
||||
if (_toneController) {
|
||||
_toneController.abort()
|
||||
}
|
||||
_toneController = new AbortController()
|
||||
|
||||
toneLoading.value = true
|
||||
toneResult.value = null
|
||||
|
||||
try {
|
||||
const result = await adjustToneApi(
|
||||
convId,
|
||||
selectedText,
|
||||
fullText,
|
||||
tone,
|
||||
_toneController.signal,
|
||||
)
|
||||
toneResult.value = result
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError' && e?.code !== 'ERR_CANCELED') {
|
||||
console.error('[useAiAssist] 语气调整失败:', e)
|
||||
toneResult.value = {
|
||||
rewritten_text: '',
|
||||
tone,
|
||||
changes_summary: 'AI 服务暂不可用',
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
toneLoading.value = false
|
||||
_toneController = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭语气浮层。
|
||||
*/
|
||||
function closeTonePopover(): void {
|
||||
tonePopoverVisible.value = false
|
||||
toneResult.value = null
|
||||
toneOriginalText.value = ''
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 文字润色
|
||||
// ====================================================================
|
||||
|
||||
/**
|
||||
* 打开润色面板。
|
||||
* 保存原文,清空结果,显示面板。
|
||||
*/
|
||||
function openPolishPanel(): void {
|
||||
const text = getInputText()
|
||||
if (!text) return
|
||||
|
||||
polishOriginalText.value = text
|
||||
polishResult.value = null
|
||||
polishPanelVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行文字润色 API 调用。
|
||||
*
|
||||
* @param action - 润色操作:expand/compress/correct
|
||||
*/
|
||||
async function polishText(action: PolishAction): Promise<void> {
|
||||
const convId = getConversationId()
|
||||
if (!convId) return
|
||||
|
||||
// abort 上一个润色请求
|
||||
if (_polishController) {
|
||||
_polishController.abort()
|
||||
}
|
||||
_polishController = new AbortController()
|
||||
|
||||
polishLoading.value = true
|
||||
polishResult.value = null
|
||||
|
||||
try {
|
||||
const result = await polishTextApi(
|
||||
convId,
|
||||
polishOriginalText.value,
|
||||
action,
|
||||
_polishController.signal,
|
||||
)
|
||||
polishResult.value = result
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError' && e?.code !== 'ERR_CANCELED') {
|
||||
console.error('[useAiAssist] 文字润色失败:', e)
|
||||
polishResult.value = {
|
||||
polished_text: '',
|
||||
action,
|
||||
changes_summary: 'AI 服务暂不可用',
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
polishLoading.value = false
|
||||
_polishController = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭润色面板。
|
||||
*/
|
||||
function closePolishPanel(): void {
|
||||
polishPanelVisible.value = false
|
||||
polishResult.value = null
|
||||
polishOriginalText.value = ''
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 智能改写
|
||||
// ====================================================================
|
||||
|
||||
/**
|
||||
* 打开改写面板并执行 API 调用。
|
||||
* 改写面板打开后自动请求 AI。
|
||||
*/
|
||||
async function openRewritePanel(): Promise<void> {
|
||||
const convId = getConversationId()
|
||||
if (!convId) return
|
||||
|
||||
// abort 上一个改写请求
|
||||
if (_rewriteController) {
|
||||
_rewriteController.abort()
|
||||
}
|
||||
_rewriteController = new AbortController()
|
||||
|
||||
rewritePanelVisible.value = true
|
||||
rewriteLoading.value = true
|
||||
rewriteResult.value = null
|
||||
|
||||
try {
|
||||
const result = await rewriteVersionsApi(
|
||||
convId,
|
||||
getInputText(),
|
||||
_rewriteController.signal,
|
||||
)
|
||||
rewriteResult.value = result
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError' && e?.code !== 'ERR_CANCELED') {
|
||||
console.error('[useAiAssist] 智能改写失败:', e)
|
||||
rewriteResult.value = { versions: [] }
|
||||
}
|
||||
} finally {
|
||||
rewriteLoading.value = false
|
||||
_rewriteController = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭改写面板。
|
||||
*/
|
||||
function closeRewritePanel(): void {
|
||||
rewritePanelVisible.value = false
|
||||
rewriteResult.value = null
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 生命周期
|
||||
// ====================================================================
|
||||
|
||||
/**
|
||||
* 清理所有状态和定时器。
|
||||
* 应在组件 onUnmounted 时调用。
|
||||
*/
|
||||
function cleanup(): void {
|
||||
// abort 所有进行中的请求
|
||||
if (_completionController) {
|
||||
_completionController.abort()
|
||||
_completionController = null
|
||||
}
|
||||
if (_toneController) {
|
||||
_toneController.abort()
|
||||
_toneController = null
|
||||
}
|
||||
if (_polishController) {
|
||||
_polishController.abort()
|
||||
_polishController = null
|
||||
}
|
||||
if (_rewriteController) {
|
||||
_rewriteController.abort()
|
||||
_rewriteController = null
|
||||
}
|
||||
|
||||
// 清除 debounce 定时器
|
||||
if (_debounceTimer) {
|
||||
clearTimeout(_debounceTimer)
|
||||
_debounceTimer = null
|
||||
}
|
||||
|
||||
// 重置所有状态
|
||||
ghostText.value = ''
|
||||
isCompletLoading.value = false
|
||||
completionConfidence.value = 0
|
||||
tonePopoverVisible.value = false
|
||||
toneLoading.value = false
|
||||
toneResult.value = null
|
||||
polishPanelVisible.value = false
|
||||
polishLoading.value = false
|
||||
polishResult.value = null
|
||||
rewritePanelVisible.value = false
|
||||
rewriteLoading.value = false
|
||||
rewriteResult.value = null
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 导出
|
||||
// ====================================================================
|
||||
|
||||
return {
|
||||
// 自动补齐
|
||||
ghostText,
|
||||
isCompletLoading,
|
||||
autocompleteEnabled,
|
||||
completionConfidence,
|
||||
triggerAutocomplete,
|
||||
acceptCompletion,
|
||||
acceptCompletionWord,
|
||||
clearCompletion,
|
||||
|
||||
// 语气调整
|
||||
tonePopoverVisible,
|
||||
toneLoading,
|
||||
toneResult,
|
||||
toneOriginalText,
|
||||
openTonePopover,
|
||||
adjustTone,
|
||||
closeTonePopover,
|
||||
|
||||
// 文字润色
|
||||
polishPanelVisible,
|
||||
polishLoading,
|
||||
polishResult,
|
||||
polishOriginalText,
|
||||
openPolishPanel,
|
||||
polishText,
|
||||
closePolishPanel,
|
||||
|
||||
// 智能改写
|
||||
rewritePanelVisible,
|
||||
rewriteLoading,
|
||||
rewriteResult,
|
||||
openRewritePanel,
|
||||
closeRewritePanel,
|
||||
|
||||
// 生命周期
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
@@ -152,8 +152,12 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}):
|
||||
}
|
||||
|
||||
// Shift+Space: 发送消息(textarea聚焦时也生效,用code不受IME影响)
|
||||
// 修复:同时注册多个 key 形式确保兼容性(' '、'Space'、全角空格)
|
||||
if (options.onSend) {
|
||||
shortcuts.push({ shift: true, key: ' ', code: 'Space', handler: () => options.onSend!() })
|
||||
shortcuts.push({ shift: true, key: 'Space', handler: () => options.onSend!() })
|
||||
// 全角空格(搜狗输入法可能转换)
|
||||
shortcuts.push({ shift: true, key: ' ', handler: () => options.onSend!() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +166,9 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}):
|
||||
*/
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
// ── 守卫1: IME组字中不触发任何快捷键 ──
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
// 修复:Shift+Space 强制触发发送,不管 IME 状态(用户明确按的就是发送键)
|
||||
const isShiftSpace = event.shiftKey && (event.code === 'Space' || event.key === ' ' || event.key === 'Space' || event.keyCode === 32)
|
||||
if (!isShiftSpace && (event.isComposing || event.keyCode === 229)) return
|
||||
|
||||
// ── 守卫2: ScreenCapture打开时不触发回复区域快捷键 ──
|
||||
if (document.querySelector('.wechat-capture')) return
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// =============================================================================
|
||||
|
||||
import type { Conversation, ConversationListData } from '../api/conversation'
|
||||
import type { Message, MessageListData } from '../api/message'
|
||||
import type { Message, MessageListData, HistoryMessageListData } from '../api/message'
|
||||
import type { Agent, AgentListData, LoginData } from '../api/agent'
|
||||
import type { DraftResult, SummaryResult, TagsResult } from '../api/wingman'
|
||||
|
||||
@@ -697,6 +697,135 @@ export const mockAiTags: TagsResult = {
|
||||
priority: 'high',
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 6.5 历史消息 mock — 跨会话聚合(3个会话,用于历史会话开关功能演示)
|
||||
// =========================================================================
|
||||
|
||||
/** 历史消息 mock(后端返回 DESC 排序:最新在前,Store 会 reverse 为 ASC) */
|
||||
const mockHistoryMessages: Message[] = [
|
||||
// ===== conv-001(当前会话 — VPN 问题)最新 =====
|
||||
{
|
||||
id: 'hist-msg-001',
|
||||
conversation_id: 'conv-001',
|
||||
sender_type: 'employee',
|
||||
sender_id: 'zhangwei',
|
||||
sender_name: '张伟',
|
||||
content: 'VPN连接失败了,一直提示"无法连接到服务器",已经试了3次都不行。需要访问内网OA系统处理紧急审批。',
|
||||
msg_type: 'text',
|
||||
ai_suggestion: false,
|
||||
is_read: true,
|
||||
created_at: min(55),
|
||||
},
|
||||
{
|
||||
id: 'hist-msg-002',
|
||||
conversation_id: 'conv-001',
|
||||
sender_type: 'agent',
|
||||
sender_id: 'agent-1',
|
||||
sender_name: '宋献',
|
||||
content: '您好张工,请问您使用的是 AnyConnect 客户端还是 SSL VPN 网页版?',
|
||||
msg_type: 'text',
|
||||
ai_suggestion: false,
|
||||
is_read: true,
|
||||
created_at: min(50),
|
||||
},
|
||||
{
|
||||
id: 'hist-msg-003',
|
||||
conversation_id: 'conv-001',
|
||||
sender_type: 'employee',
|
||||
sender_id: 'zhangwei',
|
||||
sender_name: '张伟',
|
||||
content: '按你说的操作了!清除SSL状态后重新连接成功了!太感谢了!',
|
||||
msg_type: 'text',
|
||||
ai_suggestion: false,
|
||||
is_read: true,
|
||||
created_at: min(25),
|
||||
},
|
||||
// ===== conv-hist-001(历史会话 — 邮箱登录问题)=====
|
||||
{
|
||||
id: 'hist-msg-004',
|
||||
conversation_id: 'conv-hist-001',
|
||||
sender_type: 'employee',
|
||||
sender_id: 'zhangwei',
|
||||
sender_name: '张伟',
|
||||
content: '企业邮箱登录失败,提示密码错误但我确定没改过密码',
|
||||
msg_type: 'text',
|
||||
ai_suggestion: false,
|
||||
is_read: true,
|
||||
created_at: hour(26),
|
||||
},
|
||||
{
|
||||
id: 'hist-msg-005',
|
||||
conversation_id: 'conv-hist-001',
|
||||
sender_type: 'agent',
|
||||
sender_id: 'agent-2',
|
||||
sender_name: '刘明',
|
||||
content: '已为您重置密码,请查收短信中的临时密码并尽快修改。',
|
||||
msg_type: 'text',
|
||||
ai_suggestion: false,
|
||||
is_read: true,
|
||||
created_at: hour(25),
|
||||
},
|
||||
{
|
||||
id: 'hist-msg-006',
|
||||
conversation_id: 'conv-hist-001',
|
||||
sender_type: 'employee',
|
||||
sender_id: 'zhangwei',
|
||||
sender_name: '张伟',
|
||||
content: '收到,已成功登录,谢谢!',
|
||||
msg_type: 'text',
|
||||
ai_suggestion: false,
|
||||
is_read: true,
|
||||
created_at: hour(24).toString(),
|
||||
},
|
||||
// ===== conv-hist-002(历史会话 — 打印机问题)最旧 =====
|
||||
{
|
||||
id: 'hist-msg-007',
|
||||
conversation_id: 'conv-hist-002',
|
||||
sender_type: 'employee',
|
||||
sender_id: 'zhangwei',
|
||||
sender_name: '张伟',
|
||||
content: '打印机卡纸了,怎么处理?已经重启过两次了',
|
||||
msg_type: 'text',
|
||||
ai_suggestion: false,
|
||||
is_read: true,
|
||||
created_at: hour(72),
|
||||
},
|
||||
{
|
||||
id: 'hist-msg-008',
|
||||
conversation_id: 'conv-hist-002',
|
||||
sender_type: 'agent',
|
||||
sender_id: 'agent-1',
|
||||
sender_name: '宋献',
|
||||
content: '请打开打印机后盖,检查是否有卡纸,缓慢取出后重新安装。',
|
||||
msg_type: 'text',
|
||||
ai_suggestion: false,
|
||||
is_read: true,
|
||||
created_at: hour(71),
|
||||
},
|
||||
{
|
||||
id: 'hist-msg-009',
|
||||
conversation_id: 'conv-hist-002',
|
||||
sender_type: 'employee',
|
||||
sender_id: 'zhangwei',
|
||||
sender_name: '张伟',
|
||||
content: '好了,取出卡纸后恢复正常了,谢谢!',
|
||||
msg_type: 'text',
|
||||
ai_suggestion: false,
|
||||
is_read: true,
|
||||
created_at: hour(70),
|
||||
},
|
||||
]
|
||||
|
||||
export const mockHistoryMessageData: HistoryMessageListData = {
|
||||
items: mockHistoryMessages,
|
||||
has_more: false,
|
||||
conversation_summaries: {
|
||||
'conv-001': 'VPN连接失败了,一直提示"无法连接',
|
||||
'conv-hist-001': '企业邮箱登录失败,提示密码错误',
|
||||
'conv-hist-002': '打印机卡纸了,怎么处理?已经重启',
|
||||
},
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 7. 导出聚合列表类型
|
||||
// =========================================================================
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
// 静态导入 Login 组件,确保其 CSS 被打包到主 bundle 中
|
||||
import Login from '@/views/Login.vue'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 路由配置
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -24,7 +27,7 @@ const routes = [
|
||||
// 登录页面
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
component: Login,
|
||||
meta: { title: '坐席登录', requiresAuth: false },
|
||||
},
|
||||
// 账号绑定页(互联企业用户)
|
||||
|
||||
@@ -28,14 +28,14 @@ import {
|
||||
leaveAsParticipant as leaveAsParticipantApi,
|
||||
} from '@/api/conversation'
|
||||
import type { InviteParticipantParams } from '@/api/conversation'
|
||||
import { getMessages, sendMessage, pollMessages } from '@/api/message'
|
||||
import { getMessages, sendMessage, pollMessages, getHistoryMessages } from '@/api/message'
|
||||
import {
|
||||
generateDraft,
|
||||
generateSummary,
|
||||
suggestTags,
|
||||
} from '@/api/wingman'
|
||||
import type { DraftResult, SummaryResult } from '@/api/wingman'
|
||||
import { mockConversationListData, mockMessageListData } from '@/mock/data'
|
||||
import { mockConversationListData, mockMessageListData, mockHistoryMessageData } from '@/mock/data'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 会话排序权重配置
|
||||
@@ -123,6 +123,28 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
/** 是否正在加载消息 */
|
||||
const loadingMessages = ref<boolean>(false)
|
||||
|
||||
// ==========================================================================
|
||||
// 历史模式状态(历史会话开关功能)
|
||||
// ==========================================================================
|
||||
|
||||
/** 历史模式开关 */
|
||||
const historyMode = ref<boolean>(false)
|
||||
|
||||
/** 历史合并时间线消息(跨会话聚合,ASC排序:最旧在前) */
|
||||
const historyMessages = ref<Message[]>([])
|
||||
|
||||
/** 历史模式加载中状态 */
|
||||
const historyLoading = ref<boolean>(false)
|
||||
|
||||
/** 是否还有更多历史消息 */
|
||||
const historyHasMore = ref<boolean>(false)
|
||||
|
||||
/** 会话ID → 首条消息摘要(前20字) */
|
||||
const historyConversationSummaries = ref<Record<string, string>>({})
|
||||
|
||||
/** 分页游标(最旧消息的ID,用于向上翻页加载更多) */
|
||||
const historyCursor = ref<string | null>(null)
|
||||
|
||||
/** 待填充到输入框的文本(由快速回复模板设置) */
|
||||
const pendingReplyText = ref<string>('')
|
||||
|
||||
@@ -195,6 +217,14 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
/** 是否正在加载标签建议 */
|
||||
const loadingTags = ref<boolean>(false)
|
||||
|
||||
/**
|
||||
* 推荐刷新信号:每次 AI 新消息到达时自增
|
||||
* 做什么:ReplySuggestArea 监听此信号,自动调用 generateDraft() 刷新推荐
|
||||
* 为什么:AI 回复通过 WS new_message 到达后,aiDrafts 不会自动更新,
|
||||
* 导致推荐面板与用户问题不同步;通过此信号触发自动刷新
|
||||
*/
|
||||
const recommendRefreshSignal = ref<number>(0)
|
||||
|
||||
/** 会话列表轮询定时器ID */
|
||||
let conversationPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -313,9 +343,19 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
/**
|
||||
* 🕙 历史会话 — 已结单
|
||||
* 对应左栏三段折叠的第三段(默认折叠)
|
||||
* 仅显示 90 天内的已结单会话,更早的会话需到管理后台查看
|
||||
*/
|
||||
const HISTORY_DAYS = 90 // 历史会话保留天数
|
||||
const historyConversations = computed(() => {
|
||||
return sortedConversations.value.filter(c => c.status === 'resolved')
|
||||
const cutoffDate = new Date()
|
||||
cutoffDate.setDate(cutoffDate.getDate() - HISTORY_DAYS)
|
||||
const cutoffTime = cutoffDate.getTime()
|
||||
return sortedConversations.value.filter(c => {
|
||||
if (c.status !== 'resolved') return false
|
||||
// 过滤掉超过 90 天的会话
|
||||
const createdTime = c.created_at ? new Date(c.created_at).getTime() : 0
|
||||
return createdTime >= cutoffTime
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -326,6 +366,23 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
return conversations.value.find(c => c.id === currentConversationId.value) || null
|
||||
})
|
||||
|
||||
/**
|
||||
* 显示用消息列表
|
||||
* 历史模式返回 historyMessages,正常模式返回 messages
|
||||
* 前端渲染时统一使用此 computed,实现模式切换无闪烁
|
||||
*/
|
||||
const displayMessages = computed(() => {
|
||||
return historyMode.value ? historyMessages.value : messages.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 是否为历史只读模式
|
||||
* 历史模式下隐藏输入框和回复建议区
|
||||
*/
|
||||
const isHistoryReadonly = computed(() => {
|
||||
return historyMode.value
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 方法
|
||||
// ==========================================================================
|
||||
@@ -371,11 +428,15 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
* @param conversationId - 要选中的会话ID
|
||||
*/
|
||||
async function selectConversation(conversationId: string): Promise<void> {
|
||||
// 重置历史模式状态(切换会话时关闭历史模式)
|
||||
resetHistoryState()
|
||||
currentConversationId.value = conversationId
|
||||
// 清空上一个会话的 Wingman 数据
|
||||
clearWingmanData()
|
||||
// 加载该会话的消息
|
||||
await fetchMessages(conversationId)
|
||||
// 确保消息轮询正在运行(历史模式可能已停止轮询)
|
||||
startMessagePoll()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -858,6 +919,134 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
suggestedPriority.value = ''
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 历史模式方法(历史会话开关功能)
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* 打开历史模式
|
||||
* 加载员工所有会话的历史消息,合并为一条时间线
|
||||
* 历史模式下暂停消息轮询,避免新消息混入历史时间线
|
||||
*/
|
||||
async function enableHistoryMode(): Promise<void> {
|
||||
if (!currentConversation.value) return
|
||||
const employeeId = currentConversation.value.employee_id
|
||||
if (!employeeId) return
|
||||
|
||||
historyMode.value = true
|
||||
historyLoading.value = true
|
||||
|
||||
// 暂停消息轮询,避免新消息混入历史时间线
|
||||
stopMessagePoll()
|
||||
|
||||
try {
|
||||
const data = await getHistoryMessages(employeeId, {
|
||||
limit: 50,
|
||||
current_conversation_id: currentConversationId.value || undefined,
|
||||
})
|
||||
|
||||
// 后端返回 DESC(最新在前),前端 reverse 为 ASC(最旧在前)
|
||||
historyMessages.value = data.items.reverse()
|
||||
historyHasMore.value = data.has_more
|
||||
historyConversationSummaries.value = data.conversation_summaries || {}
|
||||
|
||||
// 游标指向最旧消息的ID(数组第一个元素,用于下次向上翻页)
|
||||
historyCursor.value = historyMessages.value.length > 0
|
||||
? historyMessages.value[0].id
|
||||
: null
|
||||
} catch (error) {
|
||||
console.error('加载历史消息失败:', error)
|
||||
// 使用 mock 数据作为 fallback(开发/演示用)
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Mock] 使用模拟历史消息数据')
|
||||
const mockData = mockHistoryMessageData
|
||||
historyMessages.value = [...mockData.items].reverse()
|
||||
historyHasMore.value = mockData.has_more
|
||||
historyConversationSummaries.value = mockData.conversation_summaries || {}
|
||||
historyCursor.value = historyMessages.value.length > 0
|
||||
? historyMessages.value[0].id
|
||||
: null
|
||||
}
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭历史模式
|
||||
* 清空历史状态,恢复正常消息列表
|
||||
* 如果当前会话仍活跃,恢复消息轮询
|
||||
*/
|
||||
function disableHistoryMode(): void {
|
||||
historyMode.value = false
|
||||
historyMessages.value = []
|
||||
historyConversationSummaries.value = {}
|
||||
historyCursor.value = null
|
||||
historyHasMore.value = false
|
||||
|
||||
// 恢复消息轮询(如果当前会话仍活跃)
|
||||
const conv = currentConversation.value
|
||||
if (conv && conv.status !== 'resolved') {
|
||||
startMessagePoll()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向上滚动加载更多历史消息
|
||||
* 使用游标分页,获取更旧的消息并前插到列表头部
|
||||
*/
|
||||
async function loadMoreHistory(): Promise<void> {
|
||||
if (!historyHasMore.value || historyLoading.value) return
|
||||
if (!currentConversation.value) return
|
||||
const employeeId = currentConversation.value.employee_id
|
||||
if (!employeeId || !historyCursor.value) return
|
||||
|
||||
historyLoading.value = true
|
||||
|
||||
try {
|
||||
const data = await getHistoryMessages(employeeId, {
|
||||
limit: 50,
|
||||
before: historyCursor.value,
|
||||
current_conversation_id: currentConversationId.value || undefined,
|
||||
})
|
||||
|
||||
// 后端返回 DESC(最新在前),前端 reverse 为 ASC(最旧在前)
|
||||
const olderMessages = data.items.reverse()
|
||||
|
||||
// 前插到列表头部(更旧的消息排在前面)
|
||||
historyMessages.value = [...olderMessages, ...historyMessages.value]
|
||||
historyHasMore.value = data.has_more
|
||||
|
||||
// 更新游标为最旧消息的ID
|
||||
historyCursor.value = historyMessages.value.length > 0
|
||||
? historyMessages.value[0].id
|
||||
: null
|
||||
|
||||
// 合并新的会话摘要(保留已有摘要,添加新会话的摘要)
|
||||
historyConversationSummaries.value = {
|
||||
...data.conversation_summaries,
|
||||
...historyConversationSummaries.value,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载更多历史消息失败:', error)
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有历史状态
|
||||
* 切换会话时调用,确保历史模式被关闭
|
||||
*/
|
||||
function resetHistoryState(): void {
|
||||
historyMode.value = false
|
||||
historyMessages.value = []
|
||||
historyConversationSummaries.value = {}
|
||||
historyCursor.value = null
|
||||
historyHasMore.value = false
|
||||
historyLoading.value = false
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// WebSocket 事件处理方法
|
||||
// ==========================================================================
|
||||
@@ -947,9 +1136,11 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
// 记录此消息ID为"已处理"(更新到 Set 的最新位置)
|
||||
trackProcessedMessageId(data.message_id)
|
||||
|
||||
// 如果是 AI 回复消息,清除该会话的 AI 思考状态
|
||||
// 如果是 AI 回复消息,清除该会话的 AI 思考状态 + 触发推荐刷新
|
||||
if (data.sender_type === 'ai') {
|
||||
clearAiThinking(data.conversation_id)
|
||||
// 触发推荐面板自动刷新:AI 回复 = 用户问题已被处理 = 推荐需同步更新
|
||||
recommendRefreshSignal.value++
|
||||
}
|
||||
|
||||
// 如果当前正在查看这个会话,追加消息到消息列表
|
||||
@@ -1219,6 +1410,14 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
workspaceView,
|
||||
panelMode,
|
||||
|
||||
// 历史模式状态
|
||||
historyMode,
|
||||
historyMessages,
|
||||
historyLoading,
|
||||
historyHasMore,
|
||||
historyConversationSummaries,
|
||||
historyCursor,
|
||||
|
||||
// AI Wingman 状态
|
||||
aiDrafts,
|
||||
currentSummary,
|
||||
@@ -1243,6 +1442,10 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
colleagueConversations,
|
||||
historyConversations,
|
||||
|
||||
// 历史模式计算属性
|
||||
displayMessages,
|
||||
isHistoryReadonly,
|
||||
|
||||
// 方法
|
||||
fetchConversations,
|
||||
selectConversation,
|
||||
@@ -1280,6 +1483,12 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
startAllPolling,
|
||||
stopAllPolling,
|
||||
|
||||
// 历史模式方法
|
||||
enableHistoryMode,
|
||||
disableHistoryMode,
|
||||
loadMoreHistory,
|
||||
resetHistoryState,
|
||||
|
||||
// 右栏面板模式
|
||||
setPanelMode,
|
||||
|
||||
@@ -1301,6 +1510,9 @@ export const useConversationStore = defineStore('conversation', () => {
|
||||
clearAiThinking,
|
||||
aiThinkingConversations,
|
||||
|
||||
// 推荐刷新信号
|
||||
recommendRefreshSignal,
|
||||
|
||||
// 输入指示器
|
||||
typingUsers,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// =============================================================================
|
||||
// 企微IT智能服务台 — AI 辅助消息框 类型定义
|
||||
// =============================================================================
|
||||
// 说明:坐席端 AI 工具栏 4 个功能的共享类型,与后端 wingman_assist.py 对齐。
|
||||
// 包含:枚举类型、接口定义、标签映射常量。
|
||||
// =============================================================================
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 枚举类型
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 语气类型(与后端 ToneAdjustRequest.tone 对齐) */
|
||||
export type ToneType = 'professional' | 'friendly' | 'concise'
|
||||
|
||||
/** 语气标签映射(中文显示) */
|
||||
export const TONE_LABELS: Record<ToneType, string> = {
|
||||
professional: '专业',
|
||||
friendly: '友好',
|
||||
concise: '简洁',
|
||||
}
|
||||
|
||||
/** 润色操作类型(与后端 PolishRequest.action 对齐) */
|
||||
export type PolishAction = 'expand' | 'compress' | 'correct'
|
||||
|
||||
/** 润色操作标签映射(中文显示) */
|
||||
export const POLISH_LABELS: Record<PolishAction, string> = {
|
||||
expand: '扩写',
|
||||
compress: '压缩',
|
||||
correct: '纠错',
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// API 响应接口(与后端返回值对齐)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/** 自动补齐响应 */
|
||||
export interface AutocompleteResult {
|
||||
/** 补齐文字 */
|
||||
completion: string
|
||||
/** 置信度(0.0-1.0),低于 0.5 不显示 */
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/** 语气调整响应 */
|
||||
export interface ToneAdjustResult {
|
||||
/** 改写后的文字 */
|
||||
rewritten_text: string
|
||||
/** 目标语气 */
|
||||
tone: ToneType
|
||||
/** 变更摘要 */
|
||||
changes_summary: string
|
||||
}
|
||||
|
||||
/** 文字润色响应 */
|
||||
export interface PolishResult {
|
||||
/** 润色后的文字 */
|
||||
polished_text: string
|
||||
/** 润色操作类型 */
|
||||
action: PolishAction
|
||||
/** 变更摘要 */
|
||||
changes_summary: string
|
||||
}
|
||||
|
||||
/** 改写版本 */
|
||||
export interface RewriteVersion {
|
||||
/** 版本文字 */
|
||||
text: string
|
||||
/** 版本风格标签(如"简洁直接") */
|
||||
style: string
|
||||
/** 版本来源(如"对话上下文"、"RAGFlow知识库") */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** 智能改写响应 */
|
||||
export interface RewriteResult {
|
||||
/** 改写版本列表(通常 3 个) */
|
||||
versions: RewriteVersion[]
|
||||
}
|
||||
@@ -357,6 +357,8 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
/* 渐变背景 - 与企微风格统一 */
|
||||
background: linear-gradient(135deg, #07C160 0%, #06AD56 50%, #05924A 100%);
|
||||
background: linear-gradient(135deg, #07C160 0%, #06AD56 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -389,22 +391,26 @@ onUnmounted(() => {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 7px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #07C160;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
/* 品牌绿色背景,更显眼 */
|
||||
background: linear-gradient(135deg, #07C160 0%, #05924A 100%);
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
flex-shrink: 0;
|
||||
/* 添加微阴影增加立体感 */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* 渐变文字 — 复用 TopBar 样式 */
|
||||
.title-gradient {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
/* 直接使用品牌绿色,确保在白色卡片上可见(渐变方案在某些浏览器不兼容) */
|
||||
color: #07C160;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user