Commit efadec82 by Sweet Zhang

对接子任务详情、附件上传

parent 19046586
VITE_API_BASE_URL='http://139.224.145.34:9002/email/api' VITE_API_BASE_URL='/email/api'
\ No newline at end of file VITE_REMOTE_API_BASE_URL='http://139.224.145.34:9002'
\ No newline at end of file
VITE_API_BASE_URL='/email/api'
\ No newline at end of file
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
/** Vite API基础URL */
readonly VITE_API_BASE_URL: string
/** 远程API基础URL */
readonly VITE_REMOTE_API_BASE_URL: string
}
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
"name": "yd-email", "name": "yd-email",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@fortawesome/fontawesome-free": "^7.0.1", "@fortawesome/fontawesome-free": "^7.0.1",
"axios": "^1.12.2", "axios": "^1.12.2",
"element-plus": "^2.11.3", "element-plus": "^2.11.3",
......
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@fortawesome/fontawesome-free": "^7.0.1", "@fortawesome/fontawesome-free": "^7.0.1",
"axios": "^1.12.2", "axios": "^1.12.2",
"element-plus": "^2.11.3", "element-plus": "^2.11.3",
......
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
<main class="flex-1 overflow-y-auto bg-gray-50 p-4 md:p-6"> <main class="flex-1 overflow-y-auto bg-gray-50 p-4 md:p-6">
<header class="mb-6"> <header class="mb-6">
<h2 class="text-2xl font-bold text-gray-800"> <h2 class="text-2xl font-bold text-gray-800">
<!-- {{ pageTitles[currentPage] }} --> {{ pageTitles[currentPage] }}
</h2> </h2>
</header> </header>
...@@ -80,7 +80,8 @@ const pageTitles = { ...@@ -80,7 +80,8 @@ const pageTitles = {
const handleLogin = () => { const handleLogin = () => {
isAuthenticated.value = true isAuthenticated.value = true
isLoginPage.value = false isLoginPage.value = false
router.push('/compose') // 登录后跳转到写邮件页面 /**登录成功后,跳转到写邮件页面 */
router.push('/compose')
} }
const handleLogout = () => { const handleLogout = () => {
......
...@@ -11,28 +11,29 @@ import type { ...@@ -11,28 +11,29 @@ import type {
EmailTask, EmailTask,
} from '@/types/index' } from '@/types/index'
import type { ApiResponse } from '@/utils/request' import type { ApiResponse } from '@/utils/request'
const baseEmailUrl = '/email/api'
// 联系人管理 // 联系人管理
export const contactApi = { export const contactApi = {
// 新增联系人 // 新增联系人
addContact: (data: Contact): Promise<ApiResponse> => { addContact: (data: Contact): Promise<ApiResponse> => {
return request.post('/emailContact/add', data) return request.post(`${baseEmailUrl}/emailContact/add`, data)
}, },
// 获取联系人详情 // 获取联系人详情
getContactDetail: (id: string): Promise<ApiResponse> => { getContactDetail: (id: string): Promise<ApiResponse> => {
return request.get('/emailContact/detail', { params: { contactBizId: id } }) return request.get(`${baseEmailUrl}/emailContact/detail`, { params: { contactBizId: id } })
}, },
// 更新联系人 // 更新联系人
updateContact: (data: Contact): Promise<ApiResponse> => { updateContact: (data: Contact): Promise<ApiResponse> => {
return request.put('/emailContact/edit', data) return request.put(`${baseEmailUrl}/emailContact/edit`, data)
}, },
// 删除联系人 // 删除联系人
deleteContact: (id: string): Promise<ApiResponse> => { deleteContact: (id: string): Promise<ApiResponse> => {
return request.delete('/emailContact/del?contactBizId=' + id) return request.delete(`${baseEmailUrl}/emailContact/del?contactBizId=${id}`)
}, },
// 获取联系人列表 // 获取联系人列表
getContactList: (data: Contact): Promise<ApiResponse> => { getContactList: (data: Contact): Promise<ApiResponse> => {
return request.post('/emailContact/page', data) return request.post(`${baseEmailUrl}/emailContact/page`, data)
}, },
} }
...@@ -40,7 +41,7 @@ export const contactApi = { ...@@ -40,7 +41,7 @@ export const contactApi = {
/**邮件服务商列表 */ /**邮件服务商列表 */
export const emailProviderApi = { export const emailProviderApi = {
getEmailProviderList: (data: EmailProvider): Promise<ApiResponse> => { getEmailProviderList: (data: EmailProvider): Promise<ApiResponse> => {
return request.post('/emailProviderConfig/page', data) return request.post(`${baseEmailUrl}/emailProviderConfig/page`, data)
}, },
} }
...@@ -48,23 +49,23 @@ export const emailProviderApi = { ...@@ -48,23 +49,23 @@ export const emailProviderApi = {
export const senderApi = { export const senderApi = {
// 新增发送人配置 // 新增发送人配置
addEmailSenderConfig: (data: Sender): Promise<ApiResponse> => { addEmailSenderConfig: (data: Sender): Promise<ApiResponse> => {
return request.post('/emailSenderConfig/add', data) return request.post(`${baseEmailUrl}/emailSenderConfig/add`, data)
}, },
// 删除发送人配置 // 删除发送人配置
deleteEmailSenderConfig: (id: string): Promise<ApiResponse> => { deleteEmailSenderConfig: (id: string): Promise<ApiResponse> => {
return request.delete('/emailSenderConfig/del?senderBizId=' + id) return request.delete(`${baseEmailUrl}/emailSenderConfig/del?senderBizId=${id}`)
}, },
// 编辑发送人配置 // 编辑发送人配置
editEmailSenderConfig: (data: Sender): Promise<ApiResponse> => { editEmailSenderConfig: (data: Sender): Promise<ApiResponse> => {
return request.put('/emailSenderConfig/edit', data) return request.put(`${baseEmailUrl}/emailSenderConfig/edit`, data)
}, },
// 获取发送人配置详情 // 获取发送人配置详情
getEmailSenderConfigDetail: (id: string): Promise<ApiResponse> => { getEmailSenderConfigDetail: (id: string): Promise<ApiResponse> => {
return request.get('/emailSenderConfig/detail', { params: { senderBizId: id } }) return request.get(`${baseEmailUrl}/emailSenderConfig/detail`, { params: { senderBizId: id } })
}, },
// 获取发送配置列表 // 获取发送配置列表
getEmailSenderConfigList: (params: Sender): Promise<ApiResponse> => { getEmailSenderConfigList: (params: Sender): Promise<ApiResponse> => {
return request.post('/emailSenderConfig/page', params) return request.post(`${baseEmailUrl}/emailSenderConfig/page`, params)
}, },
} }
...@@ -72,42 +73,42 @@ export const senderApi = { ...@@ -72,42 +73,42 @@ export const senderApi = {
export const variableApi = { export const variableApi = {
// 分页查询变量 // 分页查询变量
getEmailVariableList: (params: Variable): Promise<ApiResponse> => { getEmailVariableList: (params: Variable): Promise<ApiResponse> => {
return request.post('/emailVariable/page', params) return request.post(`${baseEmailUrl}/emailVariable/page`, params)
}, },
// 新增变量 // 新增变量
addEmailVariable: (data: Variable): Promise<ApiResponse> => { addEmailVariable: (data: Variable): Promise<ApiResponse> => {
return request.post('/emailVariable/add', data) return request.post(`${baseEmailUrl}/emailVariable/add`, data)
}, },
// 编辑变量 // 编辑变量
editEmailVariable: (data: Variable): Promise<ApiResponse> => { editEmailVariable: (data: Variable): Promise<ApiResponse> => {
return request.put('/emailVariable/edit', data) return request.put(`${baseEmailUrl}/emailVariable/edit`, data)
}, },
// 删除变量 // 删除变量
deleteEmailVariable: (id: string): Promise<ApiResponse> => { deleteEmailVariable: (id: string): Promise<ApiResponse> => {
return request.delete('/emailVariable/del?variableBizId=' + id) return request.delete(`${baseEmailUrl}/emailVariable/del?variableBizId=${id}`)
}, },
} }
/** 变量分组管理 */ /** 变量分组管理 */
export const variableGroupApi = { export const variableGroupApi = {
// 新增变量分组 // 新增变量分组
addEmailVariableGroup: (data: VariableTemplate): Promise<ApiResponse> => { addEmailVariableGroup: (data: VariableTemplate): Promise<ApiResponse> => {
return request.post('/emailVariableGroup/add', data) return request.post(`${baseEmailUrl}/emailVariableGroup/add`, data)
}, },
// 编辑变量分组 // 编辑变量分组
editEmailVariableGroup: (data: VariableTemplate): Promise<ApiResponse> => { editEmailVariableGroup: (data: VariableTemplate): Promise<ApiResponse> => {
return request.put('/emailVariableGroup/edit', data) return request.put(`${baseEmailUrl}/emailVariableGroup/edit`, data)
}, },
// 删除变量分组 // 删除变量分组
deleteEmailVariableGroup: (id: string): Promise<ApiResponse> => { deleteEmailVariableGroup: (id: string): Promise<ApiResponse> => {
return request.delete('/emailVariableGroup/del?variableGroupBizId=' + id) return request.delete(`${baseEmailUrl}/emailVariableGroup/del?variableGroupBizId=${id}`)
}, },
// 获取变量分组详情 // 获取变量分组详情
getEmailVariableGroupDetail: (id: string): Promise<ApiResponse> => { getEmailVariableGroupDetail: (id: string): Promise<ApiResponse> => {
return request.get('/emailVariableGroup/detail?variableGroupBizId=' + id) return request.get(`${baseEmailUrl}/emailVariableGroup/detail?variableGroupBizId=${id}`)
}, },
// 获取变量分组列表 // 获取变量分组列表
getEmailVariableGroupList: (params: VariableTemplate): Promise<ApiResponse> => { getEmailVariableGroupList: (params: VariableTemplate): Promise<ApiResponse> => {
return request.post('/emailVariableGroup/page', params) return request.post(`${baseEmailUrl}/emailVariableGroup/page`, params)
}, },
} }
...@@ -117,23 +118,23 @@ export const variableGroupApi = { ...@@ -117,23 +118,23 @@ export const variableGroupApi = {
export const importContactApi = { export const importContactApi = {
// 新增导入联系人 // 新增导入联系人
addEmailContactImport: (data: EditContactImport): Promise<ApiResponse> => { addEmailContactImport: (data: EditContactImport): Promise<ApiResponse> => {
return request.post('/emailContactImport/add', data) return request.post(`${baseEmailUrl}/emailContactImport/add`, data)
}, },
// 导入时,获取sessionId // 导入时,获取sessionId
getEmailContactSessionId: (data: EditContactImport): Promise<ApiResponse> => { getEmailContactSessionId: (data: EditContactImport): Promise<ApiResponse> => {
return request.post('/emailContactImport/select/add', data) return request.post(`${baseEmailUrl}/emailContactImport/select/add`, data)
}, },
// 编辑导入数据 // 编辑导入数据
editEmailContactImport: (data: EditContactImport): Promise<ApiResponse> => { editEmailContactImport: (data: EditContactImport): Promise<ApiResponse> => {
return request.put('/emailContactImport/edit', data) return request.put(`${baseEmailUrl}/emailContactImport/edit`, data)
}, },
// 导入联系人列表查询 // 导入联系人列表查询
getEmailContactImportList: (params: EditContactImport): Promise<ApiResponse> => { getEmailContactImportList: (params: EditContactImport): Promise<ApiResponse> => {
return request.post('/emailContactImport/page', params) return request.post(`${baseEmailUrl}/emailContactImport/page`, params)
}, },
// 详情会话信息前端展示收件人,抄送人 // 详情会话信息前端展示收件人,抄送人
getEmailContactImportDetail: (id: string): Promise<ApiResponse> => { getEmailContactImportDetail: (id: string): Promise<ApiResponse> => {
return request.get('/emailContactImport/detail/sessionId?sessionId=' + id) return request.get(`${baseEmailUrl}/emailContactImport/detail/sessionId?sessionId=${id}`)
}, },
} }
/** /**
...@@ -142,19 +143,19 @@ export const importContactApi = { ...@@ -142,19 +143,19 @@ export const importContactApi = {
export const sendEmailApi = { export const sendEmailApi = {
// 发送邮件 // 发送邮件
sendEmail: (data: SendEmail): Promise<ApiResponse> => { sendEmail: (data: SendEmail): Promise<ApiResponse> => {
return request.post('/email/send', data) return request.post(`${baseEmailUrl}/email/send`, data)
}, },
// 测试发送邮件 // 测试发送邮件
testSendEmail: (data: SendEmail): Promise<ApiResponse> => { testSendEmail: (data: SendEmail): Promise<ApiResponse> => {
return request.post('/email/test/send', data) return request.post(`${baseEmailUrl}/email/test/send`, data)
}, },
// 发送任务列表查询 // 发送任务列表查询
getEmailTaskList: (params: SubTask): Promise<ApiResponse> => { getEmailTaskList: (params: SubTask): Promise<ApiResponse> => {
return request.post('/emailTaskRecipients/page', params) return request.post(`${baseEmailUrl}/emailTaskRecipients/page`, params)
}, },
// 主线任务列表查询 // 主线任务列表查询
getEmailTaskMainList: (params: EmailTask): Promise<ApiResponse> => { getEmailTaskMainList: (params: EmailTask): Promise<ApiResponse> => {
return request.post('/emailTask/page', params) return request.post(`${baseEmailUrl}/emailTask/page`, params)
}, },
} }
...@@ -164,6 +165,26 @@ export const sendEmailApi = { ...@@ -164,6 +165,26 @@ export const sendEmailApi = {
export const uploadApi = { export const uploadApi = {
// 上传文件 // 上传文件
uploadFile: (data: FormData): Promise<ApiResponse> => { uploadFile: (data: FormData): Promise<ApiResponse> => {
return request.post('/oss/upload', data) return request.post(`${baseEmailUrl}/oss/upload`, data)
},
}
/**
* 根据字典类型列表获取字典项列表
*/
export const dictApi = {
// 根据字典类型列表获取字典项列表
getDictList: (params: string[]): Promise<ApiResponse> => {
return request.post(`/user/api/sysDict/type/list`, { typeList: params })
},
}
/**
* 登录接口
*/
export const loginApi = {
// 登录
login: (data: LoginRequest): Promise<ApiResponse> => {
return request.post(`/auth/auth/login`, data)
}, },
} }
import * as axios from 'axios'
declare module 'axios' {
interface AxiosInstance {
(config: AxiosRequestConfig): Promise<any>
}
}
<template>
<div
v-if="visible"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] flex flex-col">
<!-- 头部 -->
<div class="p-6 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-xl font-semibold text-gray-900">邮件发送详情</h3>
<button @click="$emit('close')" class="text-gray-400 hover:text-gray-600 transition-colors">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- 邮件基本信息 -->
<div class="p-6 border-b border-gray-200 bg-gray-50">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">邮件主题</label>
<p class="text-gray-900 font-medium">{{ emailData?.subject || '无' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">发件人</label>
<p class="text-gray-900">{{ emailData?.sendEmail || '无' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">发送时间</label>
<p class="text-gray-900">{{ formatDate(emailData?.sendTime) || '无' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">总收件人数</label>
<p class="text-gray-900 font-medium">{{ emailRecords?.length || 0 }}</p>
</div>
</div>
</div>
<!-- 收件人状态表格 -->
<div class="flex-1 overflow-y-auto">
<div class="p-6">
<h4 class="text-lg font-medium text-gray-900 mb-4">收件人发送状态</h4>
<div
v-if="!emailRecords || emailRecords.length === 0"
class="text-center py-8 text-gray-500"
>
<i class="fas fa-inbox text-4xl mb-3 opacity-30"></i>
<p>暂无收件人记录</p>
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
序号
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
收件人邮箱
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
发送状态
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
发送时间
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
失败原因
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="(record, index) in emailRecords" :key="record.receiveEmail">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ index + 1 }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ record.receiveEmail || '未知邮箱' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full"
:class="getStatusClass(record.status)"
>
{{ getStatusLabel(record.status) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ formatDate(record.sendTime) || '--' }}
</td>
<td class="px-6 py-4 text-sm text-gray-500 max-w-xs">
{{ record.failReason || '--' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="p-6 border-t border-gray-200 flex justify-end">
<button
@click="$emit('close')"
class="px-6 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 transition-colors font-medium"
>
关闭
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
// 定义props
interface EmailRecord {
receiveEmail?: string
status?: string
sendTime?: string
failReason?: string
}
interface EmailTask {
taskBizId?: string
subject?: string
sendEmail?: string
sendTime?: string
records?: EmailRecord[]
}
const props = defineProps<{
visible: boolean
emailData?: EmailTask
}>()
const emit = defineEmits<{
close: []
}>()
// 计算属性
const emailRecords = ref<EmailRecord[]>([])
// 监听emailData变化
watch(
() => props.emailData,
(newEmailData) => {
if (newEmailData && newEmailData.records) {
emailRecords.value = newEmailData.records
} else {
emailRecords.value = []
}
},
{ immediate: true },
)
// 方法
const formatDate = (dateString?: string) => {
if (!dateString) return ''
try {
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch {
return dateString
}
}
const getStatusClass = (status?: string) => {
if (!status) return 'bg-gray-100 text-gray-800'
if (status.includes('SUCCESS')) {
return 'bg-green-100 text-green-800'
} else if (status.includes('ING')) {
return 'bg-yellow-100 text-yellow-800'
} else if (status.includes('FAIL')) {
return 'bg-red-100 text-red-800'
} else {
return 'bg-gray-100 text-gray-800'
}
}
const getStatusLabel = (status?: string) => {
if (!status) return '未知状态'
if (status.includes('SUCCESS')) {
return '发送成功'
} else if (status.includes('ING')) {
return '发送中'
} else if (status.includes('FAIL')) {
return '发送失败'
} else {
return status
}
}
</script>
<style scoped>
/* 自定义滚动条样式 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
<template>
<div class="file-upload-container w-full max-w-3xl mx-auto">
<!-- 上传区域 -->
<div
class="upload-area border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer transition-all hover:border-primary hover:bg-primary/5"
@click="handleUploadClick"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
:class="{ 'border-primary bg-primary/10': isDragging }"
>
<input
ref="fileInput"
type="file"
class="hidden"
:multiple="uploadConfig.multiple"
@change="handleFileInputChange"
/>
<el-icon class="text-5xl text-gray-400 mb-4">
<UploadFilled />
</el-icon>
<div class="upload-text">
<h3 class="text-lg font-medium text-gray-700 mb-1">点击或拖拽文件到此处上传</h3>
<p class="text-sm text-gray-500">
{{ uploadConfig.multiple ? '支持多文件上传,' : '' }}
最大文件大小: {{ uploadConfig.maxSize }}MB
<template v-if="uploadConfig.allowedTypes && uploadConfig.allowedTypes.length">
,支持格式: {{ uploadConfig.allowedTypes.join(', ') }}
</template>
</p>
</div>
<el-button type="primary" class="mt-4" @click.stop="handleBrowseClick"> 选择文件 </el-button>
</div>
<!-- 上传限制提示 -->
<div class="mt-2 text-xs text-gray-500">
<template v-if="uploadConfig.maxCount">
最多可上传 {{ uploadConfig.maxCount }} 个文件,已选择 {{ fileList.length }}
</template>
</div>
<!-- 文件列表 -->
<div class="file-list mt-6">
<template v-if="fileList.length > 0">
<div class="file-list-header flex justify-between items-center mb-2">
<h3 class="font-medium">文件列表</h3>
<div class="flex gap-2">
<el-button
size="small"
type="primary"
@click="handleUploadAll"
:loading="isUploading"
:disabled="isUploading || fileList.every((f) => f.status !== 'ready')"
>
<el-icon v-if="isUploading" class="mr-1">
<Loading />
</el-icon>
开始上传
</el-button>
<el-button size="small" type="danger" text @click="clearFiles" :disabled="isUploading">
清空列表
</el-button>
</div>
</div>
<el-table :data="fileList" border size="small" class="w-full">
<el-table-column prop="name" label="文件名" width="300"></el-table-column>
<el-table-column label="大小" width="120">
<template #default="scope">
{{ formatFileSize(scope.row.size) }}
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="scope">
<el-tag
:type="
scope.row.status === 'success'
? 'success'
: scope.row.status === 'error'
? 'danger'
: scope.row.status === 'uploading'
? 'info'
: 'warning'
"
size="small"
>
{{ formatStatus(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" width="180">
<template #default="scope">
<el-progress
v-if="scope.row.status === 'uploading'"
:percentage="scope.row.progress"
stroke-width="6"
size="small"
></el-progress>
<span v-else-if="scope.row.status === 'success'">100%</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button
size="small"
text
type="danger"
@click="removeFile(scope.row)"
:disabled="scope.row.status === 'uploading'"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from 'vue'
import { useFileUpload, type UploadConfig, type UploadResult } from '@/utils/fileUpload'
// 组件属性
const props = defineProps<{
// 上传配置
config?: Partial<UploadConfig>
// 已上传的文件列表
modelValue?: any[]
}>()
// 组件事件
const emit = defineEmits<{
// 文件上传成功事件
(e: 'success', results: UploadResult[]): void
// 文件上传失败事件
(e: 'error', error: Error): void
// 上传进度更新事件
(e: 'progress', progress: number): void
// v-model 双向绑定
(e: 'update:modelValue', value: any[]): void
}>()
// 初始化上传配置
const uploadConfig: UploadConfig = {
url: `${import.meta.env.VITE_REMOTE_API_BASE_URL}/oss/api/oss/upload`,
fieldName: 'file',
maxSize: 10,
allowedTypes: [],
maxCount: 10,
multiple: false,
...props.config,
}
// 使用文件上传逻辑
const {
fileList,
isUploading,
uploadProgress,
uploadAllFiles,
removeFile,
clearFiles,
handleFileSelect,
} = useFileUpload(uploadConfig)
// 文件输入框引用
const fileInput = ref<HTMLInputElement | null>(null)
// 拖拽状态
const isDragging = ref(false)
// 监听上传进度
watch(uploadProgress, (progress) => {
if (progress) {
emit('progress', progress.percent)
}
})
// 监听上传结果,更新v-model
watch(
() => fileList.filter((f) => f.status === 'success'),
(successFiles) => {
emit('update:modelValue', successFiles)
},
{ deep: true },
)
// 处理文件选择
const handleFileInputChange = (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files && target.files.length > 0) {
handleFileSelect(Array.from(target.files))
}
// 重置input值,以便能再次选择相同文件
target.value = ''
}
// 点击上传区域触发文件选择
const handleUploadClick = () => {
if (!isUploading.value && fileInput.value) {
fileInput.value.click()
}
}
// 点击浏览按钮
const handleBrowseClick = () => {
if (!isUploading.value && fileInput.value) {
fileInput.value.click()
}
}
// 处理拖拽文件
const handleDrop = (e: DragEvent) => {
isDragging.value = false
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFileSelect(Array.from(e.dataTransfer.files))
}
}
// 格式化文件大小
const formatFileSize = (size: number): string => {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`
return `${(size / (1024 * 1024)).toFixed(2)} MB`
}
// 格式化状态文本
const formatStatus = (status: string): string => {
const statusMap: Record<string, string> = {
ready: '待上传',
uploading: '上传中',
success: '已完成',
error: '失败',
}
return statusMap[status] || status
}
// 上传所有文件方法
const handleUploadAll = async () => {
try {
const results = await uploadAllFiles()
emit('success', results)
return results
} catch (error) {
emit('error', error as Error)
throw error
}
}
</script>
<style scoped>
.file-upload-container {
@apply p-4;
}
.upload-area {
@apply transition-all duration-300;
}
.file-list {
@apply transition-all duration-300;
}
</style>
...@@ -3,6 +3,10 @@ import { createPinia } from 'pinia' ...@@ -3,6 +3,10 @@ import { createPinia } from 'pinia'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import '@fortawesome/fontawesome-free/css/all.min.css' import '@fortawesome/fontawesome-free/css/all.min.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import CommonModal from './components/CommonModal.vue'
import FileUpload from './components/FileUploadComponent.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
...@@ -12,6 +16,15 @@ import './index.css' ...@@ -12,6 +16,15 @@ import './index.css'
const app = createApp(App) const app = createApp(App)
app.use(ElementPlus) app.use(ElementPlus)
app.component('CommonModal', CommonModal)
app.component('FileUpload', FileUpload)
app.component('ElMessage', ElMessage)
app.component('ElMessageBox', ElMessageBox)
// 注册 Element Plus 图标组件
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
......
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import App from '../App.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(),
routes: [ routes: [
{ {
path: '/', path: '/',
......
...@@ -134,4 +134,56 @@ export interface EmailTask extends Pagination<EmailTask> { ...@@ -134,4 +134,56 @@ export interface EmailTask extends Pagination<EmailTask> {
subject?: string subject?: string
scheduleTime?: string scheduleTime?: string
sendTime?: string sendTime?: string
statusLabel?: string
}
// 上传参数类型
export interface UploadParams {
file: File
directory?: string // 上传目录
fileName?: string // 自定义文件名
onProgress?: (progress: number) => void // 进度回调
additionalData?: Record<string, any> // 额外的表单数据
}
// 上传结果类型(根据后端返回格式调整)
export interface UploadResult {
code?: number
msg?: string
data?: {
url: string // 文件访问URL
name: string // 文件名
fileSize: number // 文件大小
fileType: string // 文件类型
fileKey: string // 文件唯一标识
originalName: string // 原始文件名
uploadTime: string // 上传时间
accessUrl: string // 文件访问URL
[key: string]: any // 其他可能返回的字段
}
}
// 上传错误类型
export interface UploadError {
code: string | number
msg: string
data?: any
}
// 后端接口响应标准格式
export interface ApiResponse<T = any> {
code: number
msg: string
data?: T
}
// 字典表项接口
export interface DictItem {
id?: number //字典项表主键id
dictItemBizId?: string //字典数据id(业务id)
dictBizId?: string //字典ID(字典类型表id)(业务id)
itemLabel?: string //字典项标签(名称)
itemValue?: string //字典项值(值)
isDefault?: number //是否默认(0:否 1:是)
orderNum?: number //排序
status?: number //状态(0:停用 1:启用)
} }
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElNotification, ElLoading } from 'element-plus'
import { UploadFile, UploadRawFile } from 'element-plus/es/components/upload/src/upload'
import axios, { AxiosRequestConfig, AxiosProgressEvent } from 'axios'
// 上传配置接口定义
export interface UploadConfig {
// 上传接口URL
url: string
// 后端接收文件的字段名
fieldName?: string
// 最大文件大小(MB)
maxSize?: number
// 允许的文件类型,例如['image/jpeg', 'image/png']
allowedTypes?: string[]
// 最大上传文件数量
maxCount?: number
// 额外的请求参数
extraParams?: Record<string, any>
// 上传请求头
headers?: Record<string, string>
// 是否支持多文件上传
multiple?: boolean
}
// 上传进度信息接口
export interface UploadProgress {
percent: number
uploaded: number
total: number
file: UploadRawFile
}
// 上传结果接口
export interface UploadResult {
success?: boolean
data?: unknown
msg?: string
code?: number
error?: {
message: string
code?: number
}
file: UploadRawFile
}
// 上传文件信息接口
export interface UploadFileInfo extends UploadFile {
progress: number
uploadId?: string
}
// 默认配置
const defaultConfig: UploadConfig = {
url: `${import.meta.env.VITE_REMOTE_API_BASE_URL}/oss/api/oss/upload`,
fieldName: 'file',
maxSize: 10,
allowedTypes: [],
maxCount: 10,
extraParams: {},
headers: {},
multiple: false,
}
/**
* 文件上传组合式API
* @param config 上传配置
* @returns 上传相关方法和状态
*/
export function useFileUpload(config: Partial<UploadConfig> = {}) {
// 合并配置
const uploadConfig = { ...defaultConfig, ...config }
// 上传文件列表
const fileList = reactive<UploadFileInfo[]>([])
// 上传中状态
const isUploading = ref(false)
// 上传进度
const uploadProgress = ref<UploadProgress | null>(null)
// 上传ID生成器
let uploadIdCounter = 0
// 计算已上传文件数量
const uploadedCount = computed(() => {
return fileList.filter((file) => file.status === 'success').length
})
// 计算上传失败文件数量
const errorCount = computed(() => {
return fileList.filter((file) => file.status === 'error').length
})
// 生成唯一上传ID
const generateUploadId = () => {
uploadIdCounter++
return `upload_${Date.now()}_${uploadIdCounter}`
}
/**
* 验证文件是否符合要求
* @param file 待验证文件
* @returns 验证结果和错误信息
*/
const validateFile = (file: UploadRawFile): { valid: boolean; message?: string } => {
// 验证文件大小
if (uploadConfig.maxSize) {
const maxSizeBytes = uploadConfig.maxSize * 1024 * 1024
if (file.size > maxSizeBytes) {
return {
valid: false,
message: `文件大小不能超过${uploadConfig.maxSize}MB`,
}
}
}
// 验证文件类型
if (uploadConfig.allowedTypes && uploadConfig.allowedTypes.length > 0) {
// 检查MIME类型
const mimeTypeMatch = uploadConfig.allowedTypes.includes(file.type)
// 检查文件扩展名
const ext = file.name.split('.').pop()?.toLowerCase()
const extMatch = ext ? uploadConfig.allowedTypes.some((type) => type.includes(ext)) : false
if (!mimeTypeMatch && !extMatch) {
return {
valid: false,
message: `不支持的文件类型,允许的类型: ${uploadConfig.allowedTypes.join(', ')}`,
}
}
}
// 验证文件数量
if (uploadConfig.maxCount && fileList.length >= uploadConfig.maxCount) {
return {
valid: false,
message: `最多只能上传${uploadConfig.maxCount}个文件`,
}
}
return { valid: true }
}
/**
* 处理文件选择
* @param files 选中的文件列表
*/
const handleFileSelect = (files: UploadRawFile[]) => {
if (!files || files.length === 0) return
files.forEach((file) => {
const validation = validateFile(file)
if (validation.valid) {
// 添加到文件列表
fileList.push({
uid: file.uid,
name: file.name,
raw: file,
size: file.size,
status: 'ready',
progress: 0,
})
} else if (validation.message) {
ElMessage.error(validation.message)
}
})
}
/**
* 上传单个文件
* @param file 文件信息
* @returns 上传结果
*/
const uploadSingleFile = async (file: UploadFileInfo): Promise<UploadResult> => {
return new Promise((resolve) => {
if (!file.raw) {
const result: UploadResult = {
success: false,
error: { message: '文件不存在' },
file: file.raw as UploadRawFile,
}
return resolve(result)
}
// 创建FormData
const formData = new FormData()
formData.append(uploadConfig.fieldName as string, file.raw, file.name)
// 添加额外参数
if (uploadConfig.extraParams) {
Object.entries(uploadConfig.extraParams).forEach(([key, value]) => {
formData.append(key, value)
})
}
// 更新文件状态
file.status = 'uploading'
file.uploadId = generateUploadId()
isUploading.value = true
// 配置axios请求
const axiosConfig: AxiosRequestConfig = {
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `${localStorage.getItem('authToken') || ''}`,
...uploadConfig.headers,
},
onUploadProgress: (progressEvent: AxiosProgressEvent) => {
if (progressEvent.total) {
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
file.progress = percent
uploadProgress.value = {
percent,
uploaded: progressEvent.loaded,
total: progressEvent.total,
file: file.raw as UploadRawFile,
}
}
},
}
// 发送上传请求
axios
.post(uploadConfig.url, formData, axiosConfig)
.then((response) => {
file.status = 'success'
ElMessage.success(`文件 "${file.name}" 上传成功`)
resolve({
success: true,
data: response.data,
file: file.raw as UploadRawFile,
})
})
.catch((error) => {
file.status = 'error'
const errorMsg = error.response?.data?.message || `文件 "${file.name}" 上传失败`
ElMessage.error(errorMsg)
resolve({
success: false,
error: {
message: errorMsg,
code: error.response?.status,
},
file: file.raw as UploadRawFile,
})
})
.finally(() => {
// 检查是否还有上传中的文件
const hasUploading = fileList.some((f) => f.status === 'uploading')
if (!hasUploading) {
isUploading.value = false
uploadProgress.value = null
}
})
})
}
/**
* 上传所有待上传文件
* @returns 所有文件的上传结果
*/
const uploadAllFiles = async (): Promise<UploadResult[]> => {
const readyFiles = fileList.filter((file) => file.status === 'ready')
if (readyFiles.length === 0) {
ElMessage.warning('没有待上传的文件')
return []
}
const loading = ElLoading.service({
lock: true,
text: '正在上传文件...',
background: 'rgba(0, 0, 0, 0.7)',
})
try {
// 依次上传文件
const results: UploadResult[] = []
for (const file of readyFiles) {
const result = await uploadSingleFile(file)
results.push(result)
}
ElNotification.success({
title: '上传完成',
message: `成功上传 ${results.filter((r) => r.success).length}/${results.length} 个文件`,
duration: 3000,
})
return results
} finally {
loading.close()
}
}
/**
* 移除文件
* @param file 要移除的文件
*/
const removeFile = (file: UploadFileInfo) => {
const index = fileList.indexOf(file)
if (index !== -1) {
// 如果是上传中的文件,先取消上传
if (file.status === 'uploading' && file.uploadId) {
// 这里可以实现取消上传的逻辑
ElMessage.info(`已取消 "${file.name}" 的上传`)
}
fileList.splice(index, 1)
}
}
/**
* 清空文件列表
*/
const clearFiles = () => {
if (isUploading.value) {
ElMessage.warning('有文件正在上传中,不能清空列表')
return
}
fileList.length = 0
}
return {
fileList,
isUploading,
uploadProgress,
uploadedCount,
errorCount,
handleFileSelect,
uploadSingleFile,
uploadAllFiles,
removeFile,
clearFiles,
validateFile,
}
}
...@@ -11,13 +11,11 @@ export interface ApiResponse<T = object> { ...@@ -11,13 +11,11 @@ export interface ApiResponse<T = object> {
// 创建axios实例 // 创建axios实例
const request = axios.create({ const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/email/api', baseURL: '/',
timeout: 10000, timeout: 10000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
// Authorization: 'Bearer ' + localStorage.getItem('authToken'), Authorization: localStorage.getItem('authToken') || '',
Authorization:
'Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyXzEwMDEiLCJyb2xlcyI6W10sImlhdCI6MTc1ODg3MjMyOSwiZXhwIjoxNzU4OTU4NzI5fQ.McyflIoI_ltve_uy2-mZTjOfxYfBGNMEuOoIVfeEtXdAuoycggGErq8yU3mc15npsIWJy2a8zJ5cNpx_NVtGIw',
}, },
}) })
...@@ -29,7 +27,7 @@ request.interceptors.request.use( ...@@ -29,7 +27,7 @@ request.interceptors.request.use(
// 如果token存在,添加到请求头 // 如果token存在,添加到请求头
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = token
} }
return config return config
...@@ -51,12 +49,11 @@ request.interceptors.response.use( ...@@ -51,12 +49,11 @@ request.interceptors.response.use(
if (error.response && error.response.status === 401) { if (error.response && error.response.status === 401) {
// 清除无效token // 清除无效token
localStorage.removeItem('authToken') localStorage.removeItem('authToken')
// 如果不是登录页面,跳转到登录页 // 如果不是登录页面,跳转到登录页
if (!window.location.pathname.includes('/login')) { if (!window.location.pathname.includes('/login')) {
// 保存当前URL,登录后可跳转回来 // 保存当前URL,登录后可跳转回来
localStorage.setItem('redirectPath', window.location.pathname) localStorage.setItem('redirectPath', window.location.pathname)
window.location.href = '/login' window.location.href = '/yd-email/login'
} }
} }
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
> >
<option v-for="sender in senders" :key="sender.senderBizId" :value="sender"> <option v-for="sender in senders" :key="sender.senderBizId" :value="sender">
{{ sender.email }} - {{ sender.displayName }} {{ sender.email }} - 发件人姓名:{{ sender.displayName }}
</option> </option>
</select> </select>
</div> </div>
...@@ -81,22 +81,27 @@ ...@@ -81,22 +81,27 @@
<label class="block text-gray-700 mb-2 font-medium">抄送人</label> <label class="block text-gray-700 mb-2 font-medium">抄送人</label>
<div <div
v-if="emailForm.ccEmails" v-if="emailForm.ccEmails"
class="flex flex-wrap gap-2 p-3 border border-gray-300 rounded-md bg-gray-50 min-h-[42px]" class="p-3 border border-gray-300 rounded-md bg-gray-50 min-h-[42px]"
> >
<div class="flex flex-wrap gap-2">
<div <div
v-for="emailGroup in emailForm.ccEmails.split(',').filter((e) => e.trim())" v-for="(tag, index) in emailForm.ccEmails.split(';').filter((e) => e.trim())"
:key="emailGroup" :key="index"
class="inline-flex flex-wrap gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium" class="inline-flex flex-wrap items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium"
> >
<span <span
v-for="email in emailGroup.split(';').filter((e) => e.trim())" v-for="subtag in tag
:key="email" .trim()
class="inline-flex items-center px-2 py-0.5 bg-orange-50 text-yellow-800 rounded-md text-xs" .split(',')
.filter((e) => e.trim())"
:key="subtag"
class="bg-orange-100 text-orange-800 px-2 py-0.5 rounded-full"
> >
{{ email.trim() }} {{ subtag.trim() }}
</span> </span>
</div> </div>
</div> </div>
</div>
<div <div
v-else v-else
class="flex items-center p-3 border border-gray-300 rounded-md bg-gray-50 text-gray-500 min-h-[42px]" class="flex items-center p-3 border border-gray-300 rounded-md bg-gray-50 text-gray-500 min-h-[42px]"
...@@ -119,23 +124,29 @@ ...@@ -119,23 +124,29 @@
<div class="border border-gray-300 rounded-md overflow-hidden"> <div class="border border-gray-300 rounded-md overflow-hidden">
<div class="bg-gray-50 p-2 border-b border-gray-300 flex flex-wrap gap-2"> <div class="bg-gray-50 p-2 border-b border-gray-300 flex flex-wrap gap-2">
<button <button
v-if="selectedVariableTemplate"
@click="showVariableSelector = true" @click="showVariableSelector = true"
class="text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 px-3 py-1 rounded border border-blue-200 transition-colors" class="text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 px-3 py-1 rounded border border-blue-200 transition-colors"
> >
<i class="fas fa-plus"></i> 插入字段 <i class="fas fa-plus"></i> 插入字段
</button> </button>
<!-- 点击插入姓名,正文插入 {{姓名}} -->
<button <button
@click="insertContent('name')" @click="emailForm.content += '{{name}}'"
class="text-sm bg-orange-50 hover:bg-orange-100 text-orange-600 px-3 py-1 rounded border border-orange-200 transition-colors" class="text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 px-3 py-1 rounded border border-blue-200 transition-colors"
> >
<i class="fas fa-plus"></i> 插入姓名 <i class="fas fa-plus"></i> 收件人姓名
</button> </button>
<button <button
@click="insertContent('appellation')" @click="emailForm.content += '{{appellation}}'"
class="text-sm bg-orange-50 hover:bg-orange-100 text-orange-600 px-3 py-1 rounded border border-orange-200 transition-colors" class="text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 px-3 py-1 rounded border border-blue-200 transition-colors"
> >
<i class="fas fa-plus"></i> 插入称谓 <i class="fas fa-plus"></i> 收件人称谓
</button>
<button
@click="emailForm.content += '{{compantName}}'"
class="text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 px-3 py-1 rounded border border-blue-200 transition-colors"
>
<i class="fas fa-plus"></i> 公司
</button> </button>
</div> </div>
<textarea <textarea
...@@ -148,32 +159,8 @@ ...@@ -148,32 +159,8 @@
<div class="mb-4"> <div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">附件</label> <label class="block text-gray-700 mb-2 font-medium">附件</label>
<div <div class="mb-10 p-6 bg-white rounded-lg shadow-md">
class="border-2 border-dashed border-gray-300 rounded-md p-6 text-center hover:border-blue-500 transition-colors" <FileUploadComponent v-model="uploadedFiles" @success="handleDocumentUploadSuccess" />
>
<input type="file" id="attachment" class="hidden" multiple @change="handleFileUpload" />
<label for="attachment" class="cursor-pointer">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
<p class="text-gray-600">点击或拖拽文件到此处上传</p>
<p class="text-sm text-gray-500 mt-1">支持多种格式文件</p>
</label>
<div v-if="attachments.length > 0" class="mt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">已上传附件:</h4>
<div class="space-y-2">
<div
v-for="(file, index) in attachments"
:key="index"
class="flex items-center p-2 bg-gray-50 rounded"
>
<i class="fas fa-file mr-2 text-gray-500"></i>
<span class="flex-1 text-sm truncate">{{ file.name }}</span>
<button @click="removeAttachment(index)" class="text-red-500 hover:text-red-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
...@@ -195,16 +182,10 @@ ...@@ -195,16 +182,10 @@
<div class="flex justify-end gap-3 mt-6"> <div class="flex justify-end gap-3 mt-6">
<button <button
@click="saveAsDraft" @click="sendSelfEmail"
class="px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
保存草稿
</button>
<button
@click="showPreview = true"
class="px-6 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors" class="px-6 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
> >
预览 测试发送
</button> </button>
<button <button
@click="sendEmail" @click="sendEmail"
...@@ -235,12 +216,6 @@ ...@@ -235,12 +216,6 @@
> >
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md"> <div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<h3 class="text-lg font-semibold mb-4">导入联系人</h3> <h3 class="text-lg font-semibold mb-4">导入联系人</h3>
<!-- <input
type="file"
accept=".csv,.txt,.xlsx"
@change="handleImportContacts"
class="w-full px-3 py-2 border border-gray-300 rounded-md mb-4"
/> -->
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
<button <button
@click="showImportContacts = false" @click="showImportContacts = false"
...@@ -266,27 +241,6 @@ ...@@ -266,27 +241,6 @@
@insert-variable="insertVariable" @insert-variable="insertVariable"
@close="showVariableSelector = false" @close="showVariableSelector = false"
/> />
<!-- 邮件预览弹窗 -->
<EmailPreview
v-if="showPreview"
:email-form="emailForm"
:sender="currentSender?.email || ''"
:attachments="attachments"
@confirm-send="confirmSendEmail"
@close="showPreview = false"
/>
<!-- 弹窗组件 -->
<CommonModal
v-model:visible="modalVisible"
:trigger-key="modalConfig.triggerKey"
:title="modalConfig.title"
type="confirm"
:message="modalConfig.message"
:show-cancel-button="modalConfig.showCancel"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</div> </div>
</template> </template>
...@@ -294,52 +248,12 @@ ...@@ -294,52 +248,12 @@
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
import ContactSelector from './ContactSelector.vue' import ContactSelector from './ContactSelector.vue'
import VariableSelector from './VariableSelector.vue' import VariableSelector from './VariableSelector.vue'
import EmailPreview from './EmailPreview.vue'
import ImportRecordManager from './ImportRecordManager.vue' import ImportRecordManager from './ImportRecordManager.vue'
import ImportDialog from './ImportDialog.vue' import ImportDialog from './ImportDialog.vue'
import type { import type { Sender, Contact, Variable, VariableTemplate, ImportRecord, EmailForm } from '../types'
Sender,
Contact,
Variable,
VariableTemplate,
SendEmail,
ImportRecord,
EmailForm,
} from '../types'
// 引入api接口,获取联系人列表、发件人列表、变量模版列表 // 引入api接口,获取联系人列表、发件人列表、变量模版列表
import { senderApi, variableGroupApi, contactApi, sendEmailApi, importContactApi } from '../api/api' import { senderApi, variableGroupApi, contactApi, sendEmailApi, importContactApi } from '../api/api'
// 引入弹窗组件
import CommonModal from '@/components/CommonModal.vue'
// 弹窗提示信息对象
const modalVisible = ref(false)
const modalConfig = ref({
showCancel: false,
title: '操作确认',
message: '确定要执行此操作吗?',
triggerKey: 'templateModal',
})
const openModal = (
config: { triggerKey?: string; showCancel?: boolean; title?: string; message?: string } = {},
) => {
modalConfig.value = {
showCancel: config.showCancel ?? false,
title: config.title ?? '操作确认',
message: config.message ?? '确定要执行此操作吗?',
triggerKey: config.triggerKey ?? modalConfig.value.triggerKey,
}
modalVisible.value = true
}
const handleConfirm = (triggerKey: string) => {
modalVisible.value = false
console.log('用户确认操作', triggerKey)
}
const handleCancel = (triggerKey: string) => {
modalVisible.value = false
console.log('用户取消操作', triggerKey)
}
// 初始化数据 // 初始化数据
const getSenders = () => { const getSenders = () => {
senderApi senderApi
...@@ -397,7 +311,6 @@ const selectedVariableTemplate = ref<VariableTemplate | null>(null) ...@@ -397,7 +311,6 @@ const selectedVariableTemplate = ref<VariableTemplate | null>(null)
const attachments = ref<File[]>([]) const attachments = ref<File[]>([])
const showContactSelector = ref(false) const showContactSelector = ref(false)
const showVariableSelector = ref(false) const showVariableSelector = ref(false)
const showPreview = ref(false)
const showImportContacts = ref(false) const showImportContacts = ref(false)
const showImportRecordManager = ref(false) const showImportRecordManager = ref(false)
const importRecords = ref<ImportRecord[]>([]) const importRecords = ref<ImportRecord[]>([])
...@@ -405,9 +318,11 @@ const importRecords = ref<ImportRecord[]>([]) ...@@ -405,9 +318,11 @@ const importRecords = ref<ImportRecord[]>([])
// 监听收件人变化,自动匹配抄送人 // 监听收件人变化,自动匹配抄送人
watch( watch(
() => emailForm.value.receiveEmail, () => emailForm.value.receiveEmail,
(newTo) => { (newReceiveEmail) => {
if (newTo) { if (newReceiveEmail) {
const matchedRecord = importRecords.value.find((record) => record.receiveEmail === newTo) const matchedRecord = importRecords.value.find(
(record) => record.receiveEmail === newReceiveEmail,
)
if (matchedRecord && matchedRecord.ccEmailList) { if (matchedRecord && matchedRecord.ccEmailList) {
emailForm.value.ccEmailList = matchedRecord.ccEmailList emailForm.value.ccEmailList = matchedRecord.ccEmailList
} }
...@@ -425,19 +340,7 @@ onMounted(() => { ...@@ -425,19 +340,7 @@ onMounted(() => {
const variablePrefix = '{{' const variablePrefix = '{{'
const variableNextfix = '}}' const variableNextfix = '}}'
// 方法 // 通过变量模版查询变量列表
const handleFileUpload = (e: Event) => {
const input = e.target as HTMLInputElement
if (input.files) {
attachments.value = [...attachments.value, ...Array.from(input.files)]
input.value = ''
}
}
const removeAttachment = (index: number) => {
attachments.value.splice(index, 1)
}
const applyVariableTemplate = () => { const applyVariableTemplate = () => {
if (!selectedVariableTemplate.value) return if (!selectedVariableTemplate.value) return
if (selectedVariableTemplate.value.variableGroupBizId) { if (selectedVariableTemplate.value.variableGroupBizId) {
...@@ -448,7 +351,7 @@ const applyVariableTemplate = () => { ...@@ -448,7 +351,7 @@ const applyVariableTemplate = () => {
}) })
} }
} }
// 插入变量方法
const insertVariable = (variable: Variable) => { const insertVariable = (variable: Variable) => {
// 支持多选变量,循环添加选中变量 // 支持多选变量,循环添加选中变量
const variablesToInsert = Array.isArray(variable) ? variable : [variable] const variablesToInsert = Array.isArray(variable) ? variable : [variable]
...@@ -477,12 +380,9 @@ const insertVariable = (variable: Variable) => { ...@@ -477,12 +380,9 @@ const insertVariable = (variable: Variable) => {
showVariableSelector.value = false showVariableSelector.value = false
} }
const insertContent = (i: string) => { // 确认选择收件人
emailForm.value.content += `${variablePrefix}${i}${variableNextfix}` const confirmContactSelection = (selected: Contact<unknown>[]) => {
} emailForm.value.ccEmailList = selected.map((contact) => contact.email || '')
const confirmContactSelection = (selected) => {
emailForm.value.ccEmailList = selected.cc
const params = { const params = {
sessionId: '', sessionId: '',
apiEmailContactDtoList: selected, apiEmailContactDtoList: selected,
...@@ -525,30 +425,21 @@ const handleImportContacts = (event: { file: File; content: string }) => { ...@@ -525,30 +425,21 @@ const handleImportContacts = (event: { file: File; content: string }) => {
lines.forEach((line) => { lines.forEach((line) => {
const [to, cc] = line.split(',') const [to, cc] = line.split(',')
if (to && to.includes('@')) { if (to && to.includes('@')) {
} importRecords.value.push({
id: Date.now().toString(),
receiveEmail: to.trim(),
ccEmailList: cc ? cc.split(',').map((email) => email.trim()) : [],
contactBizId: '',
name: '',
type: '',
pageNo: 1,
}) })
}
const saveAsDraft = () => {
if (!currentSender.value) {
alert('请添加并选择发件人')
return
} }
const draft: SendEmail = { })
senderBizId: currentSender.value.senderBizId || '',
sender: currentSender.value.email || '',
to: emailForm.value.to ? emailForm.value.to.split(',') : [],
cc: emailForm.value.cc ? emailForm.value.cc.split(',') : [],
subject: emailForm.value.subject || '无主题',
content: emailForm.value.content,
sendTime: new Date().toISOString(),
status: 'draft',
attachments: attachments.value.map((file) => ({ name: file.name })),
}
alert('草稿已保存')
} }
// 发送邮件
const sendEmail = () => { const sendEmail = () => {
showPreview.value = true
const params = { const params = {
...emailForm.value, ...emailForm.value,
variableGroupBizId: selectedVariableTemplate.value?.variableGroupBizId || '', variableGroupBizId: selectedVariableTemplate.value?.variableGroupBizId || '',
...@@ -559,44 +450,17 @@ const sendEmail = () => { ...@@ -559,44 +450,17 @@ const sendEmail = () => {
// 确认发送邮件 // 确认发送邮件
sendEmailApi.sendEmail(params).then((res) => { sendEmailApi.sendEmail(params).then((res) => {
if (res.code === 200) { if (res.code === 200) {
alert('邮件发送成功') ElMessage({
message: '邮件发送成功',
type: 'success',
})
} else { } else {
alert('邮件发送失败') ElMessage({
} message: '邮件发送失败',
type: 'error',
}) })
}
const confirmSendEmail = () => {
if (!currentSender.value) {
alert('请添加并选择发件人')
return
}
if (!emailForm.value.to) {
alert('请填写收件人')
return
} }
if (!emailForm.value.subject && !confirm('确定不填写邮件主题吗?')) { })
return
}
const email: Email = {
id: Date.now().toString(),
sender: currentSender.value.email || '',
to: emailForm.value.to ? emailForm.value.to.split(',') : [],
cc: emailForm.value.cc ? emailForm.value.cc.split(',') : [],
subject: emailForm.value.subject || '无主题',
content: emailForm.value.content,
sendTime:
emailForm.value.scheduleSend && emailForm.value.sendTime
? emailForm.value.sendTime
: new Date().toISOString(),
status: emailForm.value.scheduleSend ? 'scheduled' : 'sent',
attachments: attachments.value.map((file) => ({ name: file.name })),
}
emailForm.value = { to: '', cc: '', subject: '', content: '', scheduleSend: false, sendTime: '' }
attachments.value = []
selectedVariableTemplate.value = null
showPreview.value = false
alert(emailForm.value.scheduleSend ? '邮件已安排定时发送' : '邮件发送成功')
} }
// 通过sessionId获取导入的联系人 // 通过sessionId获取导入的联系人
...@@ -613,7 +477,7 @@ const getImportedContacts = (sessionId: string) => { ...@@ -613,7 +477,7 @@ const getImportedContacts = (sessionId: string) => {
}) })
} }
// 编辑数据 // 编辑数据导入记录
const editImportRecord = (record: ImportRecord) => { const editImportRecord = (record: ImportRecord) => {
console.log('编辑导入记录:', record) console.log('编辑导入记录:', record)
// 这里可以添加编辑逻辑,例如打开编辑弹窗 // 这里可以添加编辑逻辑,例如打开编辑弹窗
...@@ -624,4 +488,47 @@ const editImportRecord = (record: ImportRecord) => { ...@@ -624,4 +488,47 @@ const editImportRecord = (record: ImportRecord) => {
} }
}) })
} }
/**
* 文件上传配置
*/
// 上传附件
import { ElMessage } from 'element-plus'
import FileUploadComponent from '@/components/FileUploadComponent.vue'
import { UploadResult } from '@/utils/fileUpload'
// 上传成功的文件
const uploadedFiles = ref<any[]>([])
// 处理文档上传成功
const handleDocumentUploadSuccess = (results: UploadResult[]) => {
const successCount = results.filter((r) => r.success).length
// 发送邮件时,接口入参attachmentPath,填写附件路径,多个有分号隔开,路径在result里面的data,里面的accessUrl
// 过滤出成功上传的文件
const successFiles = results.filter((r) => r.success)
const attachmentPath = successFiles.map((r) => r.data?.data.accessUrl || '').join(';')
emailForm.value.attachmentPath = attachmentPath
ElMessage.success(`成功上传 ${successCount} 个文档`)
}
// 测试发送邮件
const sendSelfEmail = () => {
const params = {
...emailForm.value,
variableGroupBizId: selectedVariableTemplate.value?.variableGroupBizId || '',
senderBizId: currentSender.value?.senderBizId,
sendEmail: currentSender.value?.email || '',
}
console.log(params)
// 确认发送邮件
sendEmailApi.testSendEmail(params).then((res) => {
if (res.code === 200) {
ElMessage({
message: '测试邮件发送成功',
type: 'success',
})
} else {
ElMessage.error('测试邮件发送失败')
}
})
}
</script> </script>
...@@ -76,29 +76,21 @@ ...@@ -76,29 +76,21 @@
<span <span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
:class=" :class="
email.status === 'sent' email.status.includes('SUCCESS')
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: email.status === 'scheduled' : email.status.includes('ING')
? 'bg-yellow-100 text-yellow-800' ? 'bg-yellow-100 text-yellow-800'
: email.status === 'draft' : email.status.includes('FAIL')
? 'bg-gray-100 text-gray-800' ? 'bg-gray-100 text-gray-800'
: 'bg-red-100 text-red-800' : 'bg-red-100 text-red-800'
" "
> >
{{ {{ email.statusLabel }}
email.status === 'sent'
? '已发送'
: email.status === 'scheduled'
? '已定时'
: email.status === 'draft'
? '草稿'
: '发送失败'
}}
</span> </span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button <button
@click="viewEmailDetail(email)" @click="(viewDetail(email), (detailModalVisible = true))"
class="text-blue-600 hover:text-blue-900 mr-3" class="text-blue-600 hover:text-blue-900 mr-3"
> >
查看 查看
...@@ -116,36 +108,74 @@ ...@@ -116,36 +108,74 @@
<p>暂无邮件发送记录</p> <p>暂无邮件发送记录</p>
</div> </div>
</div> </div>
<!-- 查看详情 -->
<EmailDetailModal
:visible="detailModalVisible"
:email-data="selectedEmail"
@close="detailModalVisible = false"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import type { EmailTask } from '@/types/index' import type { EmailTask } from '@/types/index'
import { sendEmailApi } from '@/api/api' import { sendEmailApi, dictApi } from '@/api/api'
import type { DictItem } from '@/types/index'
import EmailDetailModal from '@/components/EmailDetailModal.vue'
// 状态 // 状态
const emails = ref<EmailTask[]>([]) const emails = ref<EmailTask[]>([])
const detailModalVisible = ref(false)
const selectedEmail = ref<EmailTask>()
const searchTerm = ref('') const searchTerm = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const statusOptions = ref<DictItem[]>([])
onMounted(() => { onMounted(() => {
getEmailTaskMainList() getEmailTaskMainList()
}) })
const viewDetail = (item: EmailTask) => {
console.log(item)
selectedEmail.value = { ...item }
// 调用getEmailTaskList查询详情
sendEmailApi
.getEmailTaskList({
taskBizId: item.taskBizId,
})
.then((res) => {
if (res.code === 200) {
selectedEmail.value = {
...item,
records: res.data.records || [],
}
console.log(selectedEmail)
}
})
.catch((err) => {
console.error('获取邮件详情失败:', err)
})
}
// 匹配发送状态
const getDictLists = async () => {
const res = await dictApi.getDictList(['email_task_status'])
if (res.code === 200) {
console.log(res)
statusOptions.value = res.data[0].dictItemList || []
emails.value.forEach((email) => {
email.statusLabel =
statusOptions.value.find((item) => item.itemValue === email.status)?.itemLabel || '未知状态'
})
}
}
// 方法 // 方法
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString) const date = new Date(dateString)
return date.toLocaleString() return date.toLocaleString()
} }
const viewEmailDetail = (email: EmailTask) => {
// 显示邮件详情
alert(
`邮件主题: ${email.subject || '无'}\n收件人: ${email.to || '无'}\n发送时间: ${formatDate(email.sendTime || '')}`,
)
// 实际项目中可以打开详情弹窗
}
const reuseEmailContent = (email: EmailTask) => { const reuseEmailContent = (email: EmailTask) => {
// 触发复用邮件内容事件 // 触发复用邮件内容事件
} }
...@@ -159,6 +189,7 @@ const getEmailTaskMainList = async () => { ...@@ -159,6 +189,7 @@ const getEmailTaskMainList = async () => {
const res = await sendEmailApi.getEmailTaskMainList(params) const res = await sendEmailApi.getEmailTaskMainList(params)
if (res.code === 200) { if (res.code === 200) {
emails.value = res.data.records || [] emails.value = res.data.records || []
getDictLists()
} }
} }
</script> </script>
<template>
<div class="email-preview"></div>
</template>
...@@ -107,38 +107,11 @@ ...@@ -107,38 +107,11 @@
</span> </span>
</button> </button>
</form> </form>
<!-- 底部分隔线和其他登录方式 -->
<div class="mt-8">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">其他登录方式</span>
</div>
</div>
<div class="mt-6 grid grid-cols-2 gap-3">
<button
class="flex items-center justify-center py-2 px-4 border border-gray-300 rounded-lg shadow-sm bg-white text-gray-700 hover:bg-gray-50 transition-colors"
>
<i class="fab fa-github mr-2 text-gray-800"></i>
<span>GitHub</span>
</button>
<button
class="flex items-center justify-center py-2 px-4 border border-gray-300 rounded-lg shadow-sm bg-white text-gray-700 hover:bg-gray-50 transition-colors"
>
<i class="fab fa-google mr-2 text-red-500"></i>
<span>Google</span>
</button>
</div>
</div>
</div> </div>
<!-- 底部版权信息 --> <!-- 底部版权信息 -->
<div class="bg-gray-50 px-6 py-4 text-center text-sm text-gray-500 border-t border-gray-100"> <div class="bg-gray-50 px-6 py-4 text-center text-sm text-gray-500 border-t border-gray-100">
<p>© 2024 邮件系统. 保留所有权利.</p> <p>© 2025 邮件系统. 保留所有权利.</p>
</div> </div>
</div> </div>
</div> </div>
...@@ -146,6 +119,9 @@ ...@@ -146,6 +119,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineEmits } from 'vue' import { ref, defineEmits } from 'vue'
import { loginApi } from '@/api/api'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Action } from 'element-plus'
// 定义事件 - 登录成功和跳转到忘记密码页面 // 定义事件 - 登录成功和跳转到忘记密码页面
const emits = defineEmits(['login', 'go-to-forgot-password']) const emits = defineEmits(['login', 'go-to-forgot-password'])
...@@ -165,9 +141,6 @@ const handleLogin = async () => { ...@@ -165,9 +141,6 @@ const handleLogin = async () => {
isSubmitting.value = true isSubmitting.value = true
try { try {
// 模拟API请求延迟
await new Promise((resolve) => setTimeout(resolve, 1200))
// 验证用户名和密码(实际项目中应调用后端接口) // 验证用户名和密码(实际项目中应调用后端接口)
if (username.value && password.value) { if (username.value && password.value) {
// 如果勾选了记住我,保存用户名到本地存储 // 如果勾选了记住我,保存用户名到本地存储
...@@ -182,24 +155,57 @@ const handleLogin = async () => { ...@@ -182,24 +155,57 @@ const handleLogin = async () => {
// 否则清除本地存储 // 否则清除本地存储
localStorage.removeItem('savedEmailUser') localStorage.removeItem('savedEmailUser')
} }
/** 调用登录接口*/
const res = await loginApi.login({
username: username.value,
password: password.value,
})
// 检查登录是否成功
if (res.code === 200) {
// 登录成功,处理返回的token等信息
console.log('登录成功', res)
localStorage.setItem('authToken', `${res.data.tokenType} ${res.data.token}`)
// 触发登录事件,传递用户信息 // 触发登录事件,传递用户信息
emits('login', { emits('login', {
username: username.value, username: username.value,
password: password.value, password: password.value,
}) })
} else { } else {
alert('请输入完整的用户名和密码') // 登录失败,处理错误信息
console.error('登录失败', res)
openMessageBox('登录失败,请检查用户名和密码', 'error', '提示')
}
} else {
openMessageBox('请输入完整的用户名和密码', 'error', '提示')
} }
} catch (error) { } catch (error) {
console.error('登录失败:', error) console.error('登录失败:', error)
alert('登录失败,请稍后重试') openMessageBox('登录失败,请稍后重试', 'error', '提示')
} finally { } finally {
// 重置加载状态 // 重置加载状态
isSubmitting.value = false isSubmitting.value = false
} }
} }
// 弹窗提示
const openMessageBox = (
message: string,
type: 'success' | 'error' = 'success',
title: string = 'Title',
) => {
ElMessageBox.alert(message, title, {
// if you want to disable its autofocus
// autofocus: false,
confirmButtonText: 'OK',
callback: (action: Action) => {
ElMessage({
type: type,
message: message,
})
},
})
}
// 处理忘记密码点击事件 // 处理忘记密码点击事件
const handleForgotPassword = () => { const handleForgotPassword = () => {
// 触发跳转到忘记密码页面的事件 // 触发跳转到忘记密码页面的事件
......
<template>
<div class="not-found-container min-h-screen flex items-center justify-center bg-gray-50">
<div class="text-center">
<div class="error-icon mb-6">
<svg
class="w-24 h-24 mx-auto text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h1 class="text-6xl font-bold text-gray-800 mb-4">404</h1>
<h2 class="text-2xl font-semibold text-gray-600 mb-6">页面未找到</h2>
<p class="text-gray-500 mb-8 max-w-md mx-auto">
抱歉,您访问的页面不存在。可能是URL输入错误,或者页面已被移动或删除。
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<el-button type="primary" size="large" @click="goBack">
<el-icon class="mr-2">
<ArrowLeft />
</el-icon>
返回上一页
</el-button>
<el-button size="large" @click="goHome">
<el-icon class="mr-2">
<HomeFilled />
</el-icon>
返回首页
</el-button>
</div>
<div class="mt-8 text-sm text-gray-400">
<p>如果您认为这是一个错误,请联系系统管理员。</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { ArrowLeft, HomeFilled } from '@element-plus/icons-vue'
const router = useRouter()
// 返回上一页
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
// 返回首页
const goHome = () => {
router.push('/')
}
</script>
<style scoped>
.not-found-container {
padding: 2rem;
}
.error-icon {
animation: bounce 2s infinite;
}
@keyframes bounce {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
</style>
...@@ -24,10 +24,25 @@ export default defineConfig({ ...@@ -24,10 +24,25 @@ export default defineConfig({
host: 'localhost', host: 'localhost',
open: true, open: true,
proxy: { proxy: {
'email/api': { '/email/api': {
target: 'http://139.224.145.34:9002', target: 'http://139.224.145.34:9002',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/email\/api/, ''), rewrite: (path) => path,
},
'/auth': {
target: 'http://139.224.145.34:9002',
changeOrigin: true,
rewrite: (path) => path,
},
'/oss': {
target: 'http://139.224.145.34:9002',
changeOrigin: true,
rewrite: (path) => path,
},
'/user': {
target: 'http://139.224.145.34:9002',
changeOrigin: true,
rewrite: (path) => path,
}, },
}, },
}, },
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment