Commit efadec82 by Sweet Zhang

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

parent 19046586
VITE_API_BASE_URL='http://139.224.145.34:9002/email/api'
\ No newline at end of file
VITE_API_BASE_URL='/email/api'
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" />
interface ImportMetaEnv {
/** Vite API基础URL */
readonly VITE_API_BASE_URL: string
/** 远程API基础URL */
readonly VITE_REMOTE_API_BASE_URL: string
}
......@@ -8,6 +8,7 @@
"name": "yd-email",
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@fortawesome/fontawesome-free": "^7.0.1",
"axios": "^1.12.2",
"element-plus": "^2.11.3",
......
......@@ -18,6 +18,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@fortawesome/fontawesome-free": "^7.0.1",
"axios": "^1.12.2",
"element-plus": "^2.11.3",
......
......@@ -29,7 +29,7 @@
<main class="flex-1 overflow-y-auto bg-gray-50 p-4 md:p-6">
<header class="mb-6">
<h2 class="text-2xl font-bold text-gray-800">
<!-- {{ pageTitles[currentPage] }} -->
{{ pageTitles[currentPage] }}
</h2>
</header>
......@@ -80,7 +80,8 @@ const pageTitles = {
const handleLogin = () => {
isAuthenticated.value = true
isLoginPage.value = false
router.push('/compose') // 登录后跳转到写邮件页面
/**登录成功后,跳转到写邮件页面 */
router.push('/compose')
}
const handleLogout = () => {
......
......@@ -11,28 +11,29 @@ import type {
EmailTask,
} from '@/types/index'
import type { ApiResponse } from '@/utils/request'
const baseEmailUrl = '/email/api'
// 联系人管理
export const contactApi = {
// 新增联系人
addContact: (data: Contact): Promise<ApiResponse> => {
return request.post('/emailContact/add', data)
return request.post(`${baseEmailUrl}/emailContact/add`, data)
},
// 获取联系人详情
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> => {
return request.put('/emailContact/edit', data)
return request.put(`${baseEmailUrl}/emailContact/edit`, data)
},
// 删除联系人
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> => {
return request.post('/emailContact/page', data)
return request.post(`${baseEmailUrl}/emailContact/page`, data)
},
}
......@@ -40,7 +41,7 @@ export const contactApi = {
/**邮件服务商列表 */
export const emailProviderApi = {
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 = {
export const senderApi = {
// 新增发送人配置
addEmailSenderConfig: (data: Sender): Promise<ApiResponse> => {
return request.post('/emailSenderConfig/add', data)
return request.post(`${baseEmailUrl}/emailSenderConfig/add`, data)
},
// 删除发送人配置
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> => {
return request.put('/emailSenderConfig/edit', data)
return request.put(`${baseEmailUrl}/emailSenderConfig/edit`, data)
},
// 获取发送人配置详情
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> => {
return request.post('/emailSenderConfig/page', params)
return request.post(`${baseEmailUrl}/emailSenderConfig/page`, params)
},
}
......@@ -72,42 +73,42 @@ export const senderApi = {
export const variableApi = {
// 分页查询变量
getEmailVariableList: (params: Variable): Promise<ApiResponse> => {
return request.post('/emailVariable/page', params)
return request.post(`${baseEmailUrl}/emailVariable/page`, params)
},
// 新增变量
addEmailVariable: (data: Variable): Promise<ApiResponse> => {
return request.post('/emailVariable/add', data)
return request.post(`${baseEmailUrl}/emailVariable/add`, data)
},
// 编辑变量
editEmailVariable: (data: Variable): Promise<ApiResponse> => {
return request.put('/emailVariable/edit', data)
return request.put(`${baseEmailUrl}/emailVariable/edit`, data)
},
// 删除变量
deleteEmailVariable: (id: string): Promise<ApiResponse> => {
return request.delete('/emailVariable/del?variableBizId=' + id)
return request.delete(`${baseEmailUrl}/emailVariable/del?variableBizId=${id}`)
},
}
/** 变量分组管理 */
export const variableGroupApi = {
// 新增变量分组
addEmailVariableGroup: (data: VariableTemplate): Promise<ApiResponse> => {
return request.post('/emailVariableGroup/add', data)
return request.post(`${baseEmailUrl}/emailVariableGroup/add`, data)
},
// 编辑变量分组
editEmailVariableGroup: (data: VariableTemplate): Promise<ApiResponse> => {
return request.put('/emailVariableGroup/edit', data)
return request.put(`${baseEmailUrl}/emailVariableGroup/edit`, data)
},
// 删除变量分组
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> => {
return request.get('/emailVariableGroup/detail?variableGroupBizId=' + id)
return request.get(`${baseEmailUrl}/emailVariableGroup/detail?variableGroupBizId=${id}`)
},
// 获取变量分组列表
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 = {
export const importContactApi = {
// 新增导入联系人
addEmailContactImport: (data: EditContactImport): Promise<ApiResponse> => {
return request.post('/emailContactImport/add', data)
return request.post(`${baseEmailUrl}/emailContactImport/add`, data)
},
// 导入时,获取sessionId
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> => {
return request.put('/emailContactImport/edit', data)
return request.put(`${baseEmailUrl}/emailContactImport/edit`, data)
},
// 导入联系人列表查询
getEmailContactImportList: (params: EditContactImport): Promise<ApiResponse> => {
return request.post('/emailContactImport/page', params)
return request.post(`${baseEmailUrl}/emailContactImport/page`, params)
},
// 详情会话信息前端展示收件人,抄送人
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 = {
export const sendEmailApi = {
// 发送邮件
sendEmail: (data: SendEmail): Promise<ApiResponse> => {
return request.post('/email/send', data)
return request.post(`${baseEmailUrl}/email/send`, data)
},
// 测试发送邮件
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> => {
return request.post('/emailTaskRecipients/page', params)
return request.post(`${baseEmailUrl}/emailTaskRecipients/page`, params)
},
// 主线任务列表查询
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 = {
export const uploadApi = {
// 上传文件
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'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.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 router from './router'
......@@ -12,6 +16,15 @@ import './index.css'
const app = createApp(App)
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(router)
......
import { createRouter, createWebHistory } from 'vue-router'
import App from '../App.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
history: createWebHistory(),
routes: [
{
path: '/',
......
......@@ -134,4 +134,56 @@ export interface EmailTask extends Pagination<EmailTask> {
subject?: string
scheduleTime?: 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> {
// 创建axios实例
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/email/api',
baseURL: '/',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
// Authorization: 'Bearer ' + localStorage.getItem('authToken'),
Authorization:
'Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VyXzEwMDEiLCJyb2xlcyI6W10sImlhdCI6MTc1ODg3MjMyOSwiZXhwIjoxNzU4OTU4NzI5fQ.McyflIoI_ltve_uy2-mZTjOfxYfBGNMEuOoIVfeEtXdAuoycggGErq8yU3mc15npsIWJy2a8zJ5cNpx_NVtGIw',
Authorization: localStorage.getItem('authToken') || '',
},
})
......@@ -29,7 +27,7 @@ request.interceptors.request.use(
// 如果token存在,添加到请求头
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = token
}
return config
......@@ -51,12 +49,11 @@ request.interceptors.response.use(
if (error.response && error.response.status === 401) {
// 清除无效token
localStorage.removeItem('authToken')
// 如果不是登录页面,跳转到登录页
if (!window.location.pathname.includes('/login')) {
// 保存当前URL,登录后可跳转回来
localStorage.setItem('redirectPath', window.location.pathname)
window.location.href = '/login'
window.location.href = '/yd-email/login'
}
}
......
......@@ -76,29 +76,21 @@
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
:class="
email.status === 'sent'
email.status.includes('SUCCESS')
? 'bg-green-100 text-green-800'
: email.status === 'scheduled'
: email.status.includes('ING')
? 'bg-yellow-100 text-yellow-800'
: email.status === 'draft'
: email.status.includes('FAIL')
? 'bg-gray-100 text-gray-800'
: 'bg-red-100 text-red-800'
"
>
{{
email.status === 'sent'
? '已发送'
: email.status === 'scheduled'
? '已定时'
: email.status === 'draft'
? '草稿'
: '发送失败'
}}
{{ email.statusLabel }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
@click="viewEmailDetail(email)"
@click="(viewDetail(email), (detailModalVisible = true))"
class="text-blue-600 hover:text-blue-900 mr-3"
>
查看
......@@ -116,36 +108,74 @@
<p>暂无邮件发送记录</p>
</div>
</div>
<!-- 查看详情 -->
<EmailDetailModal
:visible="detailModalVisible"
:email-data="selectedEmail"
@close="detailModalVisible = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
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 detailModalVisible = ref(false)
const selectedEmail = ref<EmailTask>()
const searchTerm = ref('')
const filterStatus = ref('')
const statusOptions = ref<DictItem[]>([])
onMounted(() => {
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 date = new Date(dateString)
return date.toLocaleString()
}
const viewEmailDetail = (email: EmailTask) => {
// 显示邮件详情
alert(
`邮件主题: ${email.subject || '无'}\n收件人: ${email.to || '无'}\n发送时间: ${formatDate(email.sendTime || '')}`,
)
// 实际项目中可以打开详情弹窗
}
const reuseEmailContent = (email: EmailTask) => {
// 触发复用邮件内容事件
}
......@@ -159,6 +189,7 @@ const getEmailTaskMainList = async () => {
const res = await sendEmailApi.getEmailTaskMainList(params)
if (res.code === 200) {
emails.value = res.data.records || []
getDictLists()
}
}
</script>
<template>
<div class="email-preview"></div>
</template>
......@@ -107,38 +107,11 @@
</span>
</button>
</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 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>
......@@ -146,6 +119,9 @@
<script setup lang="ts">
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'])
......@@ -165,9 +141,6 @@ const handleLogin = async () => {
isSubmitting.value = true
try {
// 模拟API请求延迟
await new Promise((resolve) => setTimeout(resolve, 1200))
// 验证用户名和密码(实际项目中应调用后端接口)
if (username.value && password.value) {
// 如果勾选了记住我,保存用户名到本地存储
......@@ -182,24 +155,57 @@ const handleLogin = async () => {
// 否则清除本地存储
localStorage.removeItem('savedEmailUser')
}
// 触发登录事件,传递用户信息
emits('login', {
/** 调用登录接口*/
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', {
username: username.value,
password: password.value,
})
} else {
// 登录失败,处理错误信息
console.error('登录失败', res)
openMessageBox('登录失败,请检查用户名和密码', 'error', '提示')
}
} else {
alert('请输入完整的用户名和密码')
openMessageBox('请输入完整的用户名和密码', 'error', '提示')
}
} catch (error) {
console.error('登录失败:', error)
alert('登录失败,请稍后重试')
openMessageBox('登录失败,请稍后重试', 'error', '提示')
} finally {
// 重置加载状态
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 = () => {
// 触发跳转到忘记密码页面的事件
......
<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({
host: 'localhost',
open: true,
proxy: {
'email/api': {
'/email/api': {
target: 'http://139.224.145.34:9002',
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