新增跟进提醒和待办事项相关API,优化聊天窗口状态管理,支持文本插入功能。更新组件以增强用户交互体验,确保输入框操作流畅。引入新组件以处理快捷回复和消息预览,提升整体功能性。
This commit is contained in:
@@ -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')
|
||||
}
|
||||
|
||||
1
TouchVueThree/src/components.d.ts
vendored
1
TouchVueThree/src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 组件挂载时加载草稿
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user