v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化

This commit is contained in:
Simon
2026-07-17 23:08:59 +08:00
parent 5a77a89ab1
commit 3ed86d5fb3
181 changed files with 19738 additions and 2655 deletions
+6 -2
View File
@@ -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
}
// --------------------------------------------------------------------------
+20 -3
View File
@@ -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
+44 -9
View File
@@ -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
}
+128
View File
@@ -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
}
+121 -17
View File
@@ -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;
}
+224 -51
View File
@@ -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
+130 -1
View File
@@ -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. 导出聚合列表类型
// =========================================================================
+4 -1
View File
@@ -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 },
},
// 账号绑定页(互联企业用户)
+216 -4
View File
@@ -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,
}
+78
View File
@@ -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[]
}
+12 -6
View File
@@ -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;
}