新增跟进提醒和待办事项相关API,优化聊天窗口状态管理,支持文本插入功能。更新组件以增强用户交互体验,确保输入框操作流畅。引入新组件以处理快捷回复和消息预览,提升整体功能性。

This commit is contained in:
乘风
2026-01-14 15:18:53 +08:00
parent 5ec8ad5737
commit 327148260c
8 changed files with 1417 additions and 91 deletions

View File

@@ -578,3 +578,77 @@ export function searchChatRecords(params: {
}) {
return request('/v1/wechat/message/search', params, 'POST')
}
// ==================== 跟进提醒相关接口 ====================
/**
* 获取跟进提醒列表
*/
export function getFollowUpList(params: {
isProcess?: string
isRemind?: string
keyword?: string
level?: string
limit?: string
page?: string
friendId?: string
}) {
return request('/v1/kefu/followUp/list', params, 'GET')
}
/**
* 添加跟进提醒
*/
export function addFollowUp(params: {
description?: string
friendId: string
reminderTime?: string
title?: string
type?: string // 0其他 1电话回访 2发送消息 3安排会议 4发送邮件
}) {
return request('/v1/kefu/followUp/add', params, 'POST')
}
/**
* 处理跟进提醒
*/
export function processFollowUp(params: { ids?: string }) {
return request('/v1/kefu/followUp/process', params, 'GET')
}
// ==================== 待办事项相关接口 ====================
/**
* 获取待办事项列表
*/
export function getTodoList(params: {
isProcess?: string
isRemind?: string
keyword?: string
level?: string
limit?: string
page?: string
friendId?: string
}) {
return request('/v1/kefu/todo/list', params, 'GET')
}
/**
* 添加待办事项
*/
export function addTodo(params: {
description?: string
friendId: string
level?: string // 0低优先级 1中优先级 2高优先级 3紧急
reminderTime?: string
title?: string
}) {
return request('/v1/kefu/todo/add', params, 'POST')
}
/**
* 处理待办事项
*/
export function processTodo(params: { ids: string }) {
return request('/v1/kefu/todo/process', params, 'GET')
}

View File

@@ -16,6 +16,7 @@ declare module 'vue' {
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElCollapseTransition: typeof import('element-plus/es')['ElCollapseTransition']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']

View File

@@ -28,6 +28,9 @@ export const useChatWindowStore = defineStore('chat-window', () => {
/** 当前输入内容 */
const inputContent = ref('')
/** 待插入的内容(用于快捷语等功能) */
const pendingInsertContent = ref('')
/** 草稿箱按会话ID存储 */
const drafts = ref<Map<number, string>>(new Map())
@@ -133,6 +136,23 @@ export const useChatWindowStore = defineStore('chat-window', () => {
// ==================== 输入框操作 ====================
/**
* 插入文本到输入框
*
* @param content 要插入的内容
*/
const insertText = (content: string) => {
pendingInsertContent.value = content
console.log(`📝 插入文本到输入框: ${content.substring(0, 50)}...`)
}
/**
* 清空待插入内容
*/
const clearPendingInsert = () => {
pendingInsertContent.value = ''
}
/**
* 保存草稿
*
@@ -232,6 +252,7 @@ export const useChatWindowStore = defineStore('chat-window', () => {
// 输入框状态
inputContent,
pendingInsertContent,
currentDraft,
// AI 配置
@@ -247,6 +268,8 @@ export const useChatWindowStore = defineStore('chat-window', () => {
closeTodoList,
// 输入框操作
insertText,
clearPendingInsert,
saveDraft,
loadDraft,
clearDraft,

View File

@@ -7,7 +7,11 @@
<UserFilled />
</el-icon>
<span v-else>
{{ currentSession?.nickname?.charAt(0) || currentSession?.conRemark?.charAt(0) || '?' }}
{{
currentSession?.nickname?.charAt(0) ||
currentSession?.conRemark?.charAt(0) ||
'?'
}}
</span>
</el-avatar>
@@ -16,9 +20,7 @@
{{ displayName }}
</div>
<div class="header-meta">
<el-tag v-if="isGroup" size="small" type="info">
群聊
</el-tag>
<el-tag v-if="isGroup" size="small" type="info"> 群聊 </el-tag>
<span v-if="isOnline" class="online-status">
<el-icon color="#67c23a"><CircleCheckFilled /></el-icon>
在线
@@ -37,62 +39,56 @@
<!-- 快捷操作按钮 -->
<el-tooltip content="跟进提醒" placement="bottom">
<el-button
:icon="Bell"
circle
@click="handleFollowUpClick"
/>
<el-button :icon="Bell" circle @click="handleFollowUpClick" />
</el-tooltip>
<el-tooltip content="待办事项" placement="bottom">
<el-button
:icon="Checked"
circle
@click="handleTodoClick"
/>
</el-tooltip>
<el-tooltip content="聊天记录" placement="bottom">
<el-button
:icon="Search"
circle
@click="handleSearchClick"
/>
<el-button :icon="Checked" circle @click="handleTodoClick" />
</el-tooltip>
<el-tooltip content="客户资料" placement="bottom">
<el-button
:icon="User"
type="primary"
@click="handleProfileClick"
/>
<el-button :icon="User" type="primary" @click="handleProfileClick" />
</el-tooltip>
</div>
<!-- 跟进提醒对话框 -->
<FollowUpReminder v-model="showFollowUpDialog" />
<!-- 待办事项对话框 -->
<TodoList v-model="showTodoDialog" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed } from 'vue'
import { storeToRefs } from 'pinia'
import {
UserFilled,
CircleCheckFilled,
Bell,
Checked,
Search,
User,
} from '@element-plus/icons-vue'
import { useSessionStore, useChatWindowStore, useUIStore } from '@/stores/modules/wechat'
import {
useSessionStore,
useChatWindowStore,
useUIStore,
} from '@/stores/modules/wechat'
import AITypeSelector from './components/AITypeSelector.vue'
import FollowUpReminder from '../FollowUpReminder/index.vue'
import TodoList from '../TodoList/index.vue'
const sessionStore = useSessionStore()
const chatWindowStore = useChatWindowStore()
const uiStore = useUIStore()
const { currentAIType } = storeToRefs(chatWindowStore)
const { currentSession } = storeToRefs(sessionStore)
// 对话框状态
const showFollowUpDialog = ref(false)
const showTodoDialog = ref(false)
// 计算属性
const isGroup = computed(() => currentSession.value?.type === 'group')
@@ -120,15 +116,11 @@ const handleAITypeChange = async (type: 0 | 1 | 2) => {
}
const handleFollowUpClick = () => {
chatWindowStore.openFollowUpReminder()
showFollowUpDialog.value = true
}
const handleTodoClick = () => {
chatWindowStore.openTodoList()
}
const handleSearchClick = () => {
chatWindowStore.openChatHistory()
showTodoDialog.value = true
}
const handleProfileClick = () => {

View File

@@ -0,0 +1,357 @@
<template>
<el-dialog
v-model="visible"
title="跟进提醒设置"
width="600px"
:close-on-click-modal="false"
>
<template #header>
<div class="dialog-header">
<div class="dialog-title">跟进提醒设置</div>
<div class="dialog-subtitle">设置客户跟进时间和方式</div>
</div>
</template>
<div class="reminder-content">
<!-- 添加新提醒区域 -->
<div class="add-section">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<div class="form-row">
<el-form-item label="跟进方式" prop="type" class="form-item">
<el-select v-model="form.type" placeholder="请选择">
<el-option
v-for="method in followupMethods"
:key="method.value"
:label="method.label"
:value="method.value"
/>
</el-select>
</el-form-item>
<el-form-item
label="提醒时间"
prop="reminderTime"
class="form-item"
>
<el-date-picker
v-model="form.reminderTime"
type="datetime"
placeholder="选择日期时间"
format="YYYY/MM/DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
</div>
<el-form-item label="提醒内容" prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="3"
placeholder="请输入提醒内容..."
/>
</el-form-item>
<el-button
type="primary"
:icon="Plus"
:loading="addLoading"
@click="handleAddReminder"
style="width: 100%"
>
添加提醒
</el-button>
</el-form>
</div>
<!-- 现有提醒列表 -->
<div class="reminder-list">
<el-scrollbar max-height="300px">
<el-skeleton :loading="loading" animated :rows="3">
<el-empty
v-if="reminders.length === 0"
description="暂无跟进提醒"
/>
<div v-else>
<div
v-for="reminder in reminders"
:key="reminder.id"
class="reminder-item"
>
<div class="reminder-header">
<el-space>
<el-tag :icon="getTypeIcon(reminder.type)" type="primary">
{{ reminder.type }}
</el-tag>
<el-tag :type="getStatusType(reminder.status)">
{{ reminder.status }}
</el-tag>
</el-space>
</div>
<div class="reminder-body">
{{ reminder.content }}
</div>
<div class="reminder-footer">
<el-space>
<el-icon><Clock /></el-icon>
<span class="time">{{ reminder.scheduledTime }}</span>
<el-button
v-if="reminder.status === '待处理'"
type="primary"
size="small"
link
@click="handleProcessReminder(reminder.id)"
>
处理
</el-button>
</el-space>
</div>
</div>
</div>
</el-skeleton>
</el-scrollbar>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Plus, Clock, Phone, Message } from '@element-plus/icons-vue'
import {
getFollowUpList,
addFollowUp,
processFollowUp,
} from '@/api/modules/wechat'
import { storeToRefs } from 'pinia'
import { useSessionStore } from '@/stores/modules/wechat'
const sessionStore = useSessionStore()
const { currentSession } = storeToRefs(sessionStore)
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
// 类型映射
const typeMap: Record<string, string> = {
'1': '电话',
'2': '消息',
'3': '会议',
'4': '邮件',
'0': '其他',
}
// 跟进方式选项
const followupMethods = [
{ value: '1', label: '电话回访' },
{ value: '2', label: '发送消息' },
{ value: '3', label: '安排会议' },
{ value: '4', label: '发送邮件' },
{ value: '0', label: '其他' },
]
interface Reminder {
id: string
type: string
status: string
content: string
scheduledTime: string
recipient: string
}
const formRef = ref<FormInstance>()
const form = ref({
type: '1',
reminderTime: '',
content: '',
})
const rules: FormRules = {
type: [{ required: true, message: '请选择跟进方式', trigger: 'change' }],
reminderTime: [
{ required: true, message: '请选择提醒时间', trigger: 'change' },
],
content: [{ required: true, message: '请输入提醒内容', trigger: 'blur' }],
}
const reminders = ref<Reminder[]>([])
const loading = ref(false)
const addLoading = ref(false)
// 加载跟进提醒列表
const loadFollowUpList = async () => {
if (!currentSession.value) return
loading.value = true
try {
const response = await getFollowUpList({
friendId: currentSession.value.id.toString(),
limit: '50',
page: '1',
})
if (response && response.list) {
reminders.value = response.list.map((item: any) => ({
id: item.id?.toString() || '',
type: typeMap[item.type] || '其他',
status: item.isProcess === 1 ? '已完成' : '待处理',
content: item.description || item.title || '',
scheduledTime: item.reminderTime || '',
recipient: currentSession.value?.nickname || '客户',
}))
}
} catch (error) {
console.error('加载跟进提醒列表失败:', error)
ElMessage.error('加载跟进提醒列表失败')
} finally {
loading.value = false
}
}
// 添加提醒
const handleAddReminder = async () => {
if (!currentSession.value || !formRef.value) return
try {
await formRef.value.validate()
addLoading.value = true
const params = {
friendId: currentSession.value.id.toString(),
type: form.value.type,
title: form.value.content,
description: form.value.content,
reminderTime: form.value.reminderTime,
}
await addFollowUp(params)
ElMessage.success('添加跟进提醒成功')
// 重置表单
formRef.value.resetFields()
// 重新加载列表
await loadFollowUpList()
} catch (error: any) {
if (error !== 'cancel') {
console.error('添加跟进提醒失败:', error)
ElMessage.error('添加跟进提醒失败')
}
} finally {
addLoading.value = false
}
}
// 处理提醒
const handleProcessReminder = async (id: string) => {
try {
await processFollowUp({ ids: id })
ElMessage.success('处理成功')
await loadFollowUpList()
} catch (error) {
console.error('处理跟进提醒失败:', error)
ElMessage.error('处理跟进提醒失败')
}
}
// 获取状态类型
const getStatusType = (status: string) => {
return status === '待处理' ? 'warning' : 'success'
}
// 获取类型图标
const getTypeIcon = (type: string) => {
return type === '电话' ? Phone : Message
}
// 监听对话框打开
watch(visible, (val) => {
if (val) {
loadFollowUpList()
}
})
</script>
<style scoped lang="scss">
.dialog-header {
.dialog-title {
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.dialog-subtitle {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
}
.reminder-content {
.add-section {
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.form-item {
margin-bottom: 18px;
}
}
}
.reminder-list {
margin-top: 16px;
.reminder-item {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.reminder-header {
margin-bottom: 8px;
}
.reminder-body {
font-size: 14px;
color: var(--el-text-color-regular);
margin-bottom: 8px;
line-height: 1.5;
}
.reminder-footer {
display: flex;
align-items: center;
font-size: 12px;
color: var(--el-text-color-secondary);
.time {
color: var(--el-text-color-secondary);
}
}
}
}
}
</style>

View File

@@ -41,23 +41,14 @@
</div>
<div class="toolbar-right">
<!-- AI 辅助 -->
<el-tooltip
:content="aiLoading ? 'AI 生成中...' : 'AI 辅助'"
placement="top"
>
<el-button
:icon="MagicStick"
circle
:loading="aiLoading"
:disabled="!canSend || uploading"
@click="handleAIClick"
/>
<!-- 转给他人 -->
<el-tooltip content="转给他人" placement="top">
<el-button :icon="Share" circle @click="handleTransferClick" />
</el-tooltip>
<!-- 聊天记录 -->
<el-tooltip content="聊天记录" placement="top">
<el-button :icon="Search" circle @click="handleSearchClick" />
<el-button :icon="ChatLineSquare" circle @click="handleSearchClick" />
</el-tooltip>
</div>
</div>
@@ -172,8 +163,8 @@ import {
Picture,
Microphone,
Location,
MagicStick,
Search,
Share,
ChatLineSquare,
Promotion,
Loading,
CircleClose,
@@ -195,7 +186,7 @@ const messageStore = useMessageStore()
const chatWindowStore = useChatWindowStore()
const { currentSession } = storeToRefs(sessionStore)
const { aiLoading } = storeToRefs(messageStore)
const { pendingInsertContent } = storeToRefs(chatWindowStore)
// ==================== WebSocket ====================
const { sendCommand } = useWebSocket()
@@ -839,24 +830,19 @@ const handleConfirmLocation = async () => {
}
/**
* AI 辅助点击
* 转给他人点击
*/
const handleAIClick = async () => {
if (!inputRef.value) return
const text = htmlToText(inputRef.value.innerHTML).trim()
if (!text || !currentSession.value) return
try {
await messageStore.requestAIReply(text)
// TODO: 显示 AI 回复建议
ElMessage.info('AI 辅助功能开发中...')
} catch (error) {
console.error('AI 辅助失败:', error)
const handleTransferClick = () => {
if (!currentSession.value) {
ElMessage.warning('请先选择会话')
return
}
// TODO: 实现转接功能
ElMessage.info('转接功能开发中...')
}
/**
* 搜索点击
* 聊天记录点击
*/
const handleSearchClick = () => {
chatWindowStore.openChatHistory()
@@ -902,6 +888,40 @@ watch(
{ immediate: true }
)
/**
* 监听待插入内容(用于快捷语等功能)
*/
watch(pendingInsertContent, (newContent) => {
if (newContent && inputRef.value) {
// 插入文本到输入框
const selection = window.getSelection()
const range = selection?.getRangeAt(0)
if (range && inputRef.value.contains(range.commonAncestorContainer)) {
// 在光标位置插入文本
const textNode = document.createTextNode(newContent)
range.deleteContents()
range.insertNode(textNode)
// 移动光标到插入文本之后
range.setStartAfter(textNode)
range.setEndAfter(textNode)
selection?.removeAllRanges()
selection?.addRange(range)
} else {
// 如果没有选区,追加到末尾
inputRef.value.textContent += newContent
}
// 更新输入内容
handleInput()
// 聚焦输入框
inputRef.value.focus()
// 清空待插入内容
chatWindowStore.clearPendingInsert()
}
})
/**
* 组件挂载时加载草稿
*/

View File

@@ -25,8 +25,12 @@
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="add-group">添加新分组</el-dropdown-item>
<el-dropdown-item command="add-reply">新增快捷语</el-dropdown-item>
<el-dropdown-item command="add-group"
>添加新分组</el-dropdown-item
>
<el-dropdown-item command="add-reply"
>新增快捷语</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -48,16 +52,18 @@
node-key="key"
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div class="tree-node" @click="data.type === 'reply' ? sendQuickReplyNow(data.data) : null">
<template #default="{ data }">
<div class="tree-node">
<span class="node-label">{{ data.label }}</span>
<div class="node-actions">
<!-- 编辑按钮 -->
<el-button
type="link"
text
size="small"
@click.stop="
data.type === 'group' ? handleEditGroup(data) : handleEditReply(data)
data.type === 'group'
? handleEditGroup(data)
: handleEditReply(data)
"
title="编辑"
>
@@ -65,9 +71,9 @@
</el-button>
<!-- 删除按钮 -->
<el-button
type="link"
text
size="small"
danger
type="danger"
@click.stop="handleDelete(data)"
title="删除"
>
@@ -78,7 +84,10 @@
</template>
</el-tree>
<el-empty v-if="!loading && treeData.length === 0" description="暂无快捷语" />
<el-empty
v-if="!loading && treeData.length === 0"
description="暂无快捷语"
/>
<el-skeleton v-if="loading" :rows="5" animated />
</el-scrollbar>
</div>
@@ -134,7 +143,10 @@
>
<el-form :model="groupForm" label-width="80px">
<el-form-item label="分组名称">
<el-input v-model="groupForm.groupName" placeholder="请输入分组名称" />
<el-input
v-model="groupForm.groupName"
placeholder="请输入分组名称"
/>
</el-form-item>
</el-form>
<template #footer>
@@ -146,15 +158,9 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, h } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Search,
Plus,
Refresh,
Edit,
Delete,
} from '@element-plus/icons-vue'
import { Search, Plus, Refresh, Edit, Delete } from '@element-plus/icons-vue'
import {
getQuickReplyList,
addQuickReply,
@@ -166,9 +172,10 @@ import {
} from '@/api/modules/wechat'
import { useWebSocket } from '@/composables/business/wechat'
import { storeToRefs } from 'pinia'
import { useSessionStore } from '@/stores/modules/wechat'
import { useSessionStore, useChatWindowStore } from '@/stores/modules/wechat'
const sessionStore = useSessionStore()
const chatWindowStore = useChatWindowStore()
const { currentSession } = storeToRefs(sessionStore)
const { sendCommand } = useWebSocket()
@@ -245,9 +252,13 @@ const treeData = computed(() => {
if (keyword.value) {
// 简单过滤
data = data.filter((item) => {
const matchGroup = item.groupName.toLowerCase().includes(keyword.value.toLowerCase())
const matchGroup = item.groupName
.toLowerCase()
.includes(keyword.value.toLowerCase())
const matchReplies = item.replies?.some((r: any) =>
(r.title || r.content || '').toLowerCase().includes(keyword.value.toLowerCase())
(r.title || r.content || '')
.toLowerCase()
.includes(keyword.value.toLowerCase())
)
return matchGroup || matchReplies
})
@@ -277,7 +288,9 @@ const groupOptions = computed(() => {
const fetchQuickWords = async () => {
loading.value = true
try {
const response = await getQuickReplyList({ replyType: Number(activeTab.value) })
const response = await getQuickReplyList({
replyType: Number(activeTab.value),
})
quickWordsData.value = response || []
} catch (error) {
console.error('获取快捷语失败:', error)
@@ -421,16 +434,263 @@ const handleSaveGroup = async () => {
}
/**
* 点击树节点:快捷语根据类型处理,分组不处理
* 消息类型枚举
*/
const MessageType = {
TEXT: 1,
IMAGE: 3,
VIDEO: 43,
LINK: 49,
}
/**
* 点击树节点:快捷语根据类型处理
*/
const handleNodeClick = (data: any) => {
if (data.type === 'reply') {
const reply = data.data
// 文本类型:不处理(点击不操作,通过发送按钮发送)
// 图片/视频/链接等:不处理(通过发送按钮发送)
// ⭐ 文本类型:插入到输入框
if (reply.msgType === MessageType.TEXT) {
chatWindowStore.insertText(reply.content || '')
ElMessage.success('已插入到输入框')
}
// ⭐ 其他类型:弹出预览并确认发送
else {
previewAndConfirmSend(reply)
}
}
}
/**
* 预览并确认发送(图片、视频、链接等)
*/
const previewAndConfirmSend = (reply: any) => {
if (reply.msgType === MessageType.LINK) {
// 链接类型:尝试解析 content
let contentData: any = {}
try {
contentData = JSON.parse(reply.content || '{}')
} catch {
contentData = { url: reply.content }
}
// 根据类型显示不同的预览
const isFile = contentData.type === 'file'
const fileExt = isFile
? contentData.title?.split('.').pop()?.toLowerCase()
: ''
const fileIcon = getFileIcon(fileExt || '')
ElMessageBox({
title: '确认发送',
message: h('div', { class: 'quick-reply-preview' }, [
h('div', { class: 'preview-title' }, reply.title || '链接消息'),
h('div', { class: 'preview-content' }, [
isFile
? h('div', { class: 'file-preview' }, [
h('div', { class: 'file-icon' }, fileIcon),
h('div', { class: 'file-info' }, [
h(
'div',
{ class: 'file-name' },
contentData.title || '未知文件'
),
h(
'div',
{ class: 'file-size' },
formatFileSize(contentData.totalLen || 0)
),
contentData.des
? h('div', { class: 'file-desc' }, contentData.des)
: null,
]),
])
: h('div', { class: 'link-preview' }, [
h(
'div',
{ class: 'link-title' },
contentData.title || reply.title
),
contentData.des
? h('div', { class: 'link-desc' }, contentData.des)
: null,
h(
'div',
{ class: 'link-url' },
contentData.url || reply.content
),
]),
]),
]),
confirmButtonText: '发送',
cancelButtonText: '取消',
customClass: 'quick-reply-message-box',
showClose: true,
})
.then(() => {
sendQuickReplyNow(reply)
})
.catch(() => {
// 用户取消
})
} else if (reply.msgType === MessageType.IMAGE) {
// 图片预览
ElMessageBox({
title: '确认发送图片',
message: h('div', { class: 'quick-reply-preview' }, [
h('div', { class: 'image-preview-wrapper' }, [
reply.title
? h('div', { class: 'preview-subtitle' }, reply.title)
: null,
h('div', { class: 'image-container' }, [
h('img', {
src: reply.content,
alt: reply.title || '图片',
onload: (e: Event) => {
const img = e.target as HTMLImageElement
const wrapper = img.closest('.image-container')
if (wrapper) {
const isPortrait = img.naturalHeight > img.naturalWidth
wrapper.classList.toggle('portrait', isPortrait)
}
},
}),
]),
h('div', { class: 'image-tips' }, [
h('span', { class: 'tip-icon' }, '💡'),
h('span', {}, '点击"发送"将图片发送给对方'),
]),
]),
]),
confirmButtonText: '发送',
cancelButtonText: '取消',
customClass: 'quick-reply-message-box image-preview-box',
showClose: true,
})
.then(() => {
sendQuickReplyNow(reply)
})
.catch(() => {})
} else if (reply.msgType === MessageType.VIDEO) {
// 视频预览
let videoUrl = ''
let videoThumb = ''
let videoTitle = ''
try {
const videoData = JSON.parse(reply.content || '{}')
videoUrl = videoData.url || videoData.videoUrl || ''
videoThumb = videoData.previewImage || videoData.thumbPath || ''
videoTitle = videoData.title || reply.title || '视频'
} catch {
videoUrl = reply.content || ''
}
ElMessageBox({
title: '确认发送视频',
message: h('div', { class: 'quick-reply-preview' }, [
h('div', { class: 'video-preview-wrapper' }, [
videoUrl
? h('video', {
src: videoUrl,
poster: videoThumb || undefined,
controls: true,
preload: 'metadata',
style: {
width: '100%',
maxHeight: '400px',
borderRadius: '4px',
backgroundColor: '#000',
},
})
: videoThumb
? h('img', {
src: videoThumb,
style: {
maxWidth: '100%',
maxHeight: '400px',
borderRadius: '4px',
},
})
: h('div', { class: 'video-placeholder' }, '视频消息'),
videoTitle && videoUrl
? h(
'div',
{
class: 'video-title',
style: {
marginTop: '12px',
fontSize: '14px',
color: 'var(--el-text-color-primary)',
},
},
videoTitle
)
: null,
]),
]),
confirmButtonText: '发送',
cancelButtonText: '取消',
customClass: 'quick-reply-message-box',
showClose: true,
})
.then(() => {
sendQuickReplyNow(reply)
})
.catch(() => {})
} else {
// 其他类型
ElMessageBox.confirm('确认发送该快捷语?', '确认发送', {
type: 'info',
confirmButtonText: '发送',
cancelButtonText: '取消',
})
.then(() => {
sendQuickReplyNow(reply)
})
.catch(() => {})
}
}
/**
* 格式化文件大小
*/
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
/**
* 获取文件图标
*/
const getFileIcon = (ext: string): string => {
const iconMap: Record<string, string> = {
pdf: '📄',
doc: '📝',
docx: '📝',
xls: '📊',
xlsx: '📊',
ppt: '📊',
pptx: '📊',
txt: '📃',
zip: '📦',
rar: '📦',
jpg: '🖼️',
jpeg: '🖼️',
png: '🖼️',
gif: '🖼️',
mp4: '🎬',
avi: '🎬',
mp3: '🎵',
wav: '🎵',
}
return iconMap[ext] || '📎'
}
/**
* 编辑快捷语
*/
@@ -538,3 +798,219 @@ fetchQuickWords()
}
}
</style>
<style lang="scss">
// 快捷语预览样式(全局)
.quick-reply-message-box {
// 隐藏左侧图标
:deep(.el-message-box__status) {
display: none !important;
}
// 调整内容区域
:deep(.el-message-box__message) {
margin-left: 0 !important;
}
.quick-reply-preview {
padding: 16px 0;
.preview-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: var(--el-text-color-primary);
}
.preview-subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 16px;
text-align: center;
}
.preview-content {
.file-preview {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
border: 1px solid var(--el-border-color-light);
.file-icon {
font-size: 48px;
line-height: 1;
}
.file-info {
flex: 1;
min-width: 0;
.file-name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 8px;
word-break: break-all;
}
.file-size {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.file-desc {
font-size: 12px;
color: var(--el-text-color-regular);
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
}
}
}
.link-preview {
padding: 16px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
border: 1px solid var(--el-border-color-light);
.link-title {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.link-desc {
font-size: 13px;
color: var(--el-text-color-regular);
margin-bottom: 12px;
}
.link-url {
font-size: 12px;
color: var(--el-color-primary);
word-break: break-all;
padding: 8px;
background: var(--el-fill-color);
border-radius: 4px;
}
}
.image-preview-wrapper {
.image-container {
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #f5f7fa 0%, #e9ecef 100%);
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
min-height: 200px;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgba(255, 255, 255, 0.3) 10px,
rgba(255, 255, 255, 0.3) 20px
);
pointer-events: none;
}
img {
max-width: 100%;
max-height: 450px;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
object-fit: contain;
position: relative;
z-index: 1;
background: #fff;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.02);
}
}
&.portrait img {
max-height: 500px;
}
}
.image-tips {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
background: var(--el-color-primary-light-9);
border-radius: 8px;
font-size: 13px;
color: var(--el-color-primary);
border: 1px solid var(--el-color-primary-light-7);
.tip-icon {
font-size: 16px;
}
}
}
.video-preview-wrapper {
.video-placeholder {
padding: 60px 20px;
text-align: center;
background: var(--el-fill-color-lighter);
border-radius: 8px;
color: var(--el-text-color-placeholder);
font-size: 16px;
}
.video-title {
text-align: center;
font-weight: 500;
}
video {
display: block;
object-fit: contain;
&::-webkit-media-controls-panel {
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 0.5)
);
}
}
}
}
}
}
// 图片预览弹窗特殊样式
.image-preview-box {
:deep(.el-message-box__message) {
padding: 0;
}
:deep(.el-message-box__content) {
padding: 20px 24px;
}
}
</style>

View File

@@ -0,0 +1,383 @@
<template>
<el-dialog
v-model="visible"
title="待办事项清单"
width="500px"
:close-on-click-modal="false"
>
<template #header>
<div class="dialog-header">
<div class="dialog-title">待办事项清单</div>
<div class="dialog-subtitle">管理日常工作任务</div>
</div>
</template>
<div class="todo-content">
<!-- 添加新任务区域 -->
<div class="add-section">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="80px"
>
<el-form-item label="任务标题" prop="title">
<el-input
v-model="form.title"
placeholder="请输入任务标题..."
/>
</el-form-item>
<el-form-item label="任务描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="任务描述(可选)..."
/>
</el-form-item>
<div class="form-row">
<el-form-item label="优先级" prop="level" class="form-item">
<el-select v-model="form.level" placeholder="请选择">
<el-option
v-for="option in priorityOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="截止时间" prop="reminderTime" class="form-item">
<el-date-picker
v-model="form.reminderTime"
type="datetime"
placeholder="选择日期时间"
format="MM/DD HH:mm"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
</div>
<el-button
type="primary"
:icon="Plus"
:loading="addLoading"
@click="handleAddTask"
style="width: 100%"
>
添加任务
</el-button>
</el-form>
</div>
<!-- 任务列表 -->
<div class="todo-list">
<el-scrollbar max-height="300px">
<el-skeleton :loading="loading" animated :rows="3">
<el-empty
v-if="todos.length === 0"
description="暂无待办事项"
/>
<div v-else>
<div
v-for="todo in todos"
:key="todo.id"
class="todo-item"
>
<div class="todo-header">
<el-checkbox
:model-value="todo.completed"
@change="handleToggleComplete(todo.id)"
/>
<span :class="['todo-title', { completed: todo.completed }]">
{{ todo.title }}
</span>
</div>
<div v-if="todo.description" class="todo-description">
{{ todo.description }}
</div>
<div class="todo-footer">
<el-space>
<span class="client-info">客户:{{ todo.client }}</span>
<el-tag :type="getPriorityType(todo.priority)" size="small">
{{ todo.priority }}
</el-tag>
<el-space :size="4" class="due-date">
<el-icon><Calendar /></el-icon>
<span>{{ todo.dueDate }}</span>
</el-space>
</el-space>
</div>
</div>
</div>
</el-skeleton>
</el-scrollbar>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Plus, Calendar } from '@element-plus/icons-vue'
import {
getTodoList,
addTodo,
processTodo,
} from '@/api/modules/wechat'
import { storeToRefs } from 'pinia'
import { useSessionStore } from '@/stores/modules/wechat'
const sessionStore = useSessionStore()
const { currentSession } = storeToRefs(sessionStore)
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
// 优先级映射
const priorityMap: Record<string, string> = {
'0': '低',
'1': '中',
'2': '高',
'3': '紧急',
}
// 优先级选项
const priorityOptions = [
{ value: '2', label: '高优先级' },
{ value: '1', label: '中优先级' },
{ value: '0', label: '低优先级' },
{ value: '3', label: '紧急' },
]
interface TodoItem {
id: string
title: string
description?: string
client?: string
priority: string
dueDate: string
completed: boolean
}
const formRef = ref<FormInstance>()
const form = ref({
title: '',
description: '',
level: '1',
reminderTime: '',
})
const rules: FormRules = {
title: [{ required: true, message: '请输入任务标题', trigger: 'blur' }],
level: [{ required: true, message: '请选择优先级', trigger: 'change' }],
reminderTime: [{ required: true, message: '请选择截止时间', trigger: 'change' }],
}
const todos = ref<TodoItem[]>([])
const loading = ref(false)
const addLoading = ref(false)
// 加载待办事项列表
const loadTodoList = async () => {
if (!currentSession.value) return
loading.value = true
try {
const response = await getTodoList({
friendId: currentSession.value.id.toString(),
limit: '50',
page: '1',
})
if (response && response.list) {
todos.value = response.list.map((item: any) => ({
id: item.id?.toString() || '',
title: item.title || '',
description: item.description || '',
client: currentSession.value?.nickname || '客户',
priority: priorityMap[item.level] || '中',
dueDate: item.reminderTime || '',
completed: item.isProcess === 1,
}))
}
} catch (error) {
console.error('加载待办事项列表失败:', error)
ElMessage.error('加载待办事项列表失败')
} finally {
loading.value = false
}
}
// 添加任务
const handleAddTask = async () => {
if (!currentSession.value || !formRef.value) return
try {
await formRef.value.validate()
addLoading.value = true
const params = {
friendId: currentSession.value.id.toString(),
title: form.value.title,
description: form.value.description,
level: form.value.level,
reminderTime: form.value.reminderTime,
}
await addTodo(params)
ElMessage.success('添加待办事项成功')
// 重置表单
formRef.value.resetFields()
// 重新加载列表
await loadTodoList()
} catch (error: any) {
if (error !== 'cancel') {
console.error('添加待办事项失败:', error)
ElMessage.error('添加待办事项失败')
}
} finally {
addLoading.value = false
}
}
// 切换完成状态
const handleToggleComplete = async (id: string) => {
try {
await processTodo({ ids: id })
ElMessage.success('任务状态更新成功')
await loadTodoList()
} catch (error) {
console.error('更新任务状态失败:', error)
ElMessage.error('更新任务状态失败')
}
}
// 获取优先级类型
const getPriorityType = (priority: string) => {
const map: Record<string, any> = {
'高': 'warning',
'中': 'primary',
'低': 'success',
'紧急': 'danger',
}
return map[priority] || ''
}
// 监听对话框打开
watch(visible, (val) => {
if (val) {
loadTodoList()
}
})
</script>
<style scoped lang="scss">
.dialog-header {
.dialog-title {
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.dialog-subtitle {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
}
.todo-content {
.add-section {
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.form-item {
margin-bottom: 18px;
}
}
}
.todo-list {
margin-top: 16px;
.todo-item {
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
.todo-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.todo-title {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
&.completed {
text-decoration: line-through;
color: var(--el-text-color-secondary);
}
}
}
.todo-description {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
padding-left: 28px;
}
.todo-footer {
display: flex;
align-items: center;
font-size: 12px;
color: var(--el-text-color-secondary);
padding-left: 28px;
.client-info {
color: var(--el-text-color-secondary);
}
.due-date {
display: flex;
align-items: center;
color: var(--el-text-color-secondary);
}
}
}
}
}
</style>