Commit 4deed578 by kyle

合并冲突

parents 7fb0a12e de2b37d8
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'
VITE_REMOTE_API_BASE_URL=''
\ 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
}
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<html lang=""> <html lang="">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.gif" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>银盾邮件系统</title> <title>银盾邮件系统</title>
</head> </head>
......
...@@ -7,8 +7,8 @@ ...@@ -7,8 +7,8 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --mode development",
"build": "run-p type-check \"build-only {@}\" --", "build": "run-p build-only",
"preview": "vite preview", "preview": "vite preview",
"test:unit": "vitest", "test:unit": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
...@@ -18,14 +18,20 @@ ...@@ -18,14 +18,20 @@
"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",
"date-fns": "^4.1.0",
"element-plus": "^2.11.3",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"vue": "^3.5.18", "vue": "^3.5.18",
"vue-router": "^4.5.1" "vue-router": "^4.5.1",
"wangeditor": "^4.7.15"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.54.1", "@playwright/test": "^1.54.1",
"@tsconfig/node22": "^22.0.2", "@tsconfig/node22": "^22.0.2",
"@types/date-fns": "^2.5.3",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/node": "^22.16.5", "@types/node": "^22.16.5",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
......
<template> <template>
<div id="app" class="min-h-screen bg-gray-50 text-gray-800 flex flex-col"> <div id="app" class="min-h-screen bg-gray-50 text-gray-800 flex flex-col">
<!-- 登录页面 --> <!-- 登录页面 -->
<LoginPage v-if="isLoginPage && !isAuthenticated" @login="handleLogin" /> <LoginPage v-if="route.path === '/login'" @login="handleLogin" />
<!-- 主应用布局 --> <!-- 主应用布局 -->
<div v-else class="flex flex-1 overflow-hidden"> <div v-else class="flex flex-1 h-screen">
<!-- 侧边导航 --> <!-- 侧边导航 -->
<Sidebar :current-page="currentPage" @change-page="handlePageChange" @logout="handleLogout" /> <Sidebar
:current-page="currentPage"
:collapsed="sidebarCollapsed"
@logout="handleLogout"
@toggle-collapse="toggleSidebar"
/>
<!-- 移动端菜单按钮 --> <!-- 移动端菜单按钮 -->
<button <button
...@@ -20,12 +25,15 @@ ...@@ -20,12 +25,15 @@
<MobileSidebar <MobileSidebar
v-if="showMobileMenu" v-if="showMobileMenu"
:current-page="currentPage" :current-page="currentPage"
@change-page="handleMobilePageChange"
@close-menu="showMobileMenu = false" @close-menu="showMobileMenu = false"
@logout="handleLogout" @logout="handleLogout"
/> />
<!-- 主内容区域 --> <!-- 主内容区域 -->
<div
class="flex-1 flex flex-col transition-all duration-300"
:class="sidebarCollapsed ? 'ml-16' : 'ml-64'"
>
<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">
...@@ -33,229 +41,72 @@ ...@@ -33,229 +41,72 @@
</h2> </h2>
</header> </header>
<!-- 写邮件页面 --> <!-- 使用router-view显示当前路由组件 -->
<ComposeEmail <router-view />
v-if="currentPage === 'compose'"
:senders="senders"
:contacts="contacts"
:variables="variables"
:variable-templates="variableTemplates"
:emails="emails"
@save-email="saveEmail"
/>
<!-- 联系人管理页面 -->
<ContactManagement
v-if="currentPage === 'contacts'"
:contacts="contacts"
@update-contacts="updateContacts"
/>
<!-- 发件人管理页面 -->
<SenderManagement
v-if="currentPage === 'senders'"
:senders="senders"
@update-senders="updateSenders"
/>
<!-- 变量管理页面 -->
<VariableManagement
v-if="currentPage === 'variables'"
:variables="variables"
:variable-templates="variableTemplates"
@update-variables="updateVariables"
@update-variable-templates="updateVariableTemplates"
/>
<!-- 邮件管理页面 -->
<EmailManagement
v-if="currentPage === 'emails'"
:emails="emails"
@reuse-email="reuseEmail"
/>
</main> </main>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, watch } from 'vue'
import LoginPage from './components/LoginPage.vue' import { useRoute, useRouter } from 'vue-router'
import Sidebar from './components/Sidebar.vue' import LoginPage from './views/LoginPage.vue'
import MobileSidebar from './components/MobileSidebar.vue' import Sidebar from './views/Sidebar.vue'
import ComposeEmail from './components/ComposeEmail.vue' import MobileSidebar from './views/MobileSidebar.vue'
import ContactManagement from './components/ContactManagement.vue' import { pageTitles } from '@/utils/menuConfig'
import SenderManagement from './components/SenderManagement.vue'
import VariableManagement from './components/VariableManagement.vue' const route = useRoute()
import EmailManagement from './components/EmailManagement.vue' const router = useRouter()
import { Contact, Sender, Variable, VariableTemplate, Email } from './types'
// 状态管理 // 状态管理
const isLoginPage = ref(true)
const isAuthenticated = ref(false)
const currentPage = ref('compose') const currentPage = ref('compose')
const showMobileMenu = ref(false) const showMobileMenu = ref(false)
const sidebarCollapsed = ref(false) // 新增:侧边栏折叠状态
// 监听路由变化,更新当前页面状态
watch(
() => route.name,
(newRouteName) => {
if (newRouteName) {
currentPage.value = newRouteName.toString()
}
},
)
// 数据存储 // 切换侧边栏折叠状态
const contacts = ref<Contact[]>([]) const toggleSidebar = () => {
const senders = ref<Sender[]>([]) sidebarCollapsed.value = !sidebarCollapsed.value
const variables = ref<Variable[]>([])
const variableTemplates = ref<VariableTemplate[]>([])
const emails = ref<Email[]>([])
// 页面标题映射
const pageTitles = {
compose: '写邮件',
contacts: '联系人管理',
senders: '发件人管理',
variables: '变量管理',
emails: '邮件记录',
} }
// 方法 // 登录处理
const handleLogin = () => { const handleLogin = () => {
// 模拟登录验证 // 检查是否有重定向路径
isAuthenticated.value = true const redirectPath = localStorage.getItem('redirectPath')
isLoginPage.value = false if (redirectPath) {
// 登录成功后加载初始数据 router.push(redirectPath)
loadInitialData() localStorage.removeItem('redirectPath')
} else {
router.push('/compose')
}
} }
// 退出登录处理
const handleLogout = () => { const handleLogout = () => {
isAuthenticated.value = false // 清除本地存储的token
isLoginPage.value = true localStorage.removeItem('authToken')
} // 跳转到登录页面
router.push('/login')
const handlePageChange = (page: string) => {
currentPage.value = page
}
const handleMobilePageChange = (page: string) => {
currentPage.value = page
showMobileMenu.value = false
}
const updateContacts = (newContacts: Contact[]) => {
contacts.value = newContacts
}
const updateSenders = (newSenders: Sender[]) => {
senders.value = newSenders
}
const updateVariables = (newVariables: Variable[]) => {
variables.value = newVariables
}
const updateVariableTemplates = (newTemplates: VariableTemplate[]) => {
variableTemplates.value = newTemplates
}
const saveEmail = (email: Email) => {
emails.value.push(email)
}
const reuseEmail = (emailData: any) => {
currentPage.value = 'compose'
// 这里可以传递需要复用的邮件数据到ComposeEmail组件
// 实际实现中可以使用状态管理或props
}
const loadInitialData = () => {
// 模拟加载初始数据
// 联系人
contacts.value = [
{
id: '1',
name: '张三',
title: '先生',
company: 'ABC公司',
email: 'zhangsan@example.com',
ccEmail: 'zhangsan_cc@example.com',
other: '技术总监',
},
{
id: '2',
name: '李四',
title: '女士',
company: 'XYZ企业',
email: 'lisi@example.com',
ccEmail: '',
other: '市场经理',
},
]
// 发件人
senders.value = [
{
id: '1',
email: 'service@mycompany.com',
password: '******',
smtpServer: 'smtp.mycompany.com',
smtpPort: '587',
},
]
// 变量
variables.value = [
{
id: '1',
name: '用户名',
key: 'username',
description: '接收者的用户名',
},
{
id: '2',
name: '订单号',
key: 'order_no',
description: '订单编号',
},
{
id: '3',
name: '金额',
key: 'amount',
description: '订单金额',
},
]
// 变量模板
variableTemplates.value = [
{
id: '1',
name: '订单通知',
description: '订单相关通知邮件模板',
variableIds: ['1', '2', '3'],
},
]
// 邮件记录
emails.value = [
{
id: '1',
sender: 'service@mycompany.com',
to: 'zhangsan@example.com',
cc: 'zhangsan_cc@example.com',
subject: '关于您的订单',
content: '尊敬的{{username}},您的订单{{order_no}}已发货,金额为{{amount}}元。',
sendTime: new Date().toISOString(),
status: 'sent',
attachments: [{ name: '订单详情.pdf' }],
},
{
id: '2',
sender: 'service@mycompany.com',
to: 'lisi@example.com',
cc: '',
subject: '市场活动邀请',
content: '尊敬的{{username}},诚邀您参加我们的市场活动。',
sendTime: new Date(Date.now() + 86400000).toISOString(),
status: 'scheduled',
},
]
} }
// 初始化 // 初始化 - 检查登录状态
onMounted(() => { onMounted(() => {
// 检查是否已登录(实际项目中应该检查本地存储或令牌) // 检查是否已登录
const token = localStorage.getItem('authToken')
if (!token && route.path !== '/login') {
// 如果未登录且当前不在登录页面,重定向到登录页
router.push('/login')
}
}) })
</script> </script>
import request from '@/utils/request'
import type {
EditContactImport,
SendEmail,
Contact,
EmailProvider,
Sender,
Variable,
VariableTemplate,
SubTask,
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(`${baseEmailUrl}/emailContact/add`, data)
},
// 获取联系人详情
getContactDetail: (id: string): Promise<ApiResponse> => {
return request.get(`${baseEmailUrl}/emailContact/detail`, { params: { contactBizId: id } })
},
// 更新联系人
updateContact: (data: Contact): Promise<ApiResponse> => {
return request.put(`${baseEmailUrl}/emailContact/edit`, data)
},
// 删除联系人
deleteContact: (id: string): Promise<ApiResponse> => {
return request.delete(`${baseEmailUrl}/emailContact/del?contactBizId=${id}`)
},
// 获取联系人列表
getContactList: (data: Contact): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailContact/page`, data)
},
}
//
/**邮件服务商列表 */
export const emailProviderApi = {
getEmailProviderList: (data: EmailProvider): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailProviderConfig/page`, data)
},
}
/**发件人管理*/
export const senderApi = {
// 新增发送人配置
addEmailSenderConfig: (data: Sender): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailSenderConfig/add`, data)
},
// 删除发送人配置
deleteEmailSenderConfig: (id: string): Promise<ApiResponse> => {
return request.delete(`${baseEmailUrl}/emailSenderConfig/del?senderBizId=${id}`)
},
// 编辑发送人配置
editEmailSenderConfig: (data: Sender): Promise<ApiResponse> => {
return request.put(`${baseEmailUrl}/emailSenderConfig/edit`, data)
},
// 获取发送人配置详情
getEmailSenderConfigDetail: (id: string): Promise<ApiResponse> => {
return request.get(`${baseEmailUrl}/emailSenderConfig/detail`, { params: { senderBizId: id } })
},
// 获取发送配置列表
getEmailSenderConfigList: (params: Sender): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailSenderConfig/page`, params)
},
}
/**变量管理 */
export const variableApi = {
// 分页查询变量
getEmailVariableList: (params: Variable): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailVariable/page`, params)
},
// 新增变量
addEmailVariable: (data: Variable): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailVariable/add`, data)
},
// 编辑变量
editEmailVariable: (data: Variable): Promise<ApiResponse> => {
return request.put(`${baseEmailUrl}/emailVariable/edit`, data)
},
// 删除变量
deleteEmailVariable: (id: string): Promise<ApiResponse> => {
return request.delete(`${baseEmailUrl}/emailVariable/del?variableBizId=${id}`)
},
}
/** 变量分组管理 */
export const variableGroupApi = {
// 新增变量分组
addEmailVariableGroup: (data: VariableTemplate): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailVariableGroup/add`, data)
},
// 编辑变量分组
editEmailVariableGroup: (data: VariableTemplate): Promise<ApiResponse> => {
return request.put(`${baseEmailUrl}/emailVariableGroup/edit`, data)
},
// 删除变量分组
deleteEmailVariableGroup: (id: string): Promise<ApiResponse> => {
return request.delete(`${baseEmailUrl}/emailVariableGroup/del?variableGroupBizId=${id}`)
},
// 获取变量分组详情
getEmailVariableGroupDetail: (id: string): Promise<ApiResponse> => {
return request.get(`${baseEmailUrl}/emailVariableGroup/detail?variableGroupBizId=${id}`)
},
// 获取变量分组列表
getEmailVariableGroupList: (params: VariableTemplate): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailVariableGroup/page`, params)
},
// 导出变量模版
exportEmailVariableGroup: (id: string): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailFile/export/excel/variable`, {
variableGroupBizId: id,
})
},
}
/**
* 导入联系人管理
*/
export const importContactApi = {
// 新增导入联系人
addEmailContactImport: (data: EditContactImport): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailContactImport/add`, data)
},
// 导入时,获取sessionId
getEmailContactSessionId: (data: EditContactImport): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailContactImport/select/add`, data)
},
// 编辑导入数据
editEmailContactImport: (data: EditContactImport): Promise<ApiResponse> => {
return request.put(`${baseEmailUrl}/emailContactImport/edit`, data)
},
// 导入联系人列表查询
getEmailContactImportList: (params: EditContactImport): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailContactImport/page`, params)
},
// 详情会话信息前端展示收件人,抄送人
getEmailContactImportDetail: (id: string): Promise<ApiResponse> => {
return request.get(`${baseEmailUrl}/emailContactImport/detail/sessionId?sessionId=${id}`)
},
// 删除导入联系人
deleteEmailContactImport: (id: string): Promise<ApiResponse> => {
return request.delete(`${baseEmailUrl}/emailContactImport/del?importBizId=${id}`)
},
}
/**
* 发送邮件
*/
export const sendEmailApi = {
// 发送邮件
sendEmail: (data: SendEmail): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/email/send`, data)
},
// 测试发送邮件
testSendEmail: (data: SendEmail): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/email/test/send`, data)
},
// 发送任务列表查询
getEmailTaskList: (params: SubTask): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailTaskRecipients/page`, params)
},
// 主线任务列表查询
getEmailTaskMainList: (params: EmailTask): Promise<ApiResponse> => {
return request.post(`${baseEmailUrl}/emailTask/page`, params)
},
}
/**
* 文件服务接口
*/
export const uploadApi = {
// 上传文件
uploadFile: (data: FormData): Promise<ApiResponse> => {
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)
},
}
<template>
<el-dialog
v-model="dialogVisible"
:title="title"
:width="width"
:top="top"
:modal="modal"
:close-on-click-modal="closeOnClickModal"
:close-on-press-escape="closeOnPressEscape"
:show-close="showClose"
:destroy-on-close="destroyOnClose"
:custom-class="customClass"
@close="handleClose"
>
<!-- 弹窗头部插槽 -->
<template #header v-if="$slots.header">
<slot name="header"></slot>
</template>
<!-- 弹窗内容 -->
<div class="modal-content">
<!-- 图标区域 -->
<!-- <div
v-if="type && showIcon"
class="icon-container mr-4 flex-shrink-0"
:class="iconContainerClass"
>
<component :is="getIconComponent" class="w-6 h-6" />
</div> -->
<!-- 内容区域 -->
<div class="content-container flex-1">
<!-- 默认消息内容 -->
<template v-if="!$slots.default">
<p class="text-gray-800 text-sm leading-6 mb-0">
{{ message }}
</p>
<p v-if="subMessage" class="text-gray-500 text-xs leading-5 mt-2">
{{ subMessage }}
</p>
</template>
<!-- 自定义内容插槽 -->
<slot></slot>
</div>
</div>
<!-- 弹窗底部 -->
<template #footer>
<slot name="footer">
<div class="flex gap-3 justify-end">
<el-button
v-if="showCancelButton"
@click="handleCancel"
size="default"
:loading="cancelLoading"
class="px-4 py-2"
>
{{ cancelText }}
</el-button>
<el-button
@click="handleConfirm"
:type="getButtonType"
size="default"
:loading="confirmLoading"
class="px-4 py-2"
>
{{ confirmText }}
</el-button>
</div>
</slot>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, computed, watch, ref, onUnmounted } from 'vue'
import { Check, Warning, CircleClose, InfoFilled, QuestionFilled } from '@element-plus/icons-vue'
// 弹窗类型
type ModalType = 'success' | 'warning' | 'error' | 'info' | 'confirm' | ''
// 定义属性
const props = defineProps({
/** 触发源标识 - 用于区分是哪个方法触发的弹窗 */
triggerKey: {
type: String,
default: '',
},
/** 控制弹窗显示/隐藏 */
visible: {
type: Boolean,
default: false,
},
/** 弹窗类型 */
type: {
type: String as () => ModalType,
default: '',
},
/** 弹窗标题 */
title: {
type: String,
default: '',
},
/** 主要消息内容 */
message: {
type: String,
default: '',
},
/** 次要消息内容 */
subMessage: {
type: String,
default: '',
},
/** 弹窗宽度 */
width: {
type: String,
default: '500px',
},
/** 弹窗距离顶部的距离 */
top: {
type: String,
default: '15vh',
},
/** 是否显示遮罩层 */
modal: {
type: Boolean,
default: true,
},
/** 点击遮罩层是否关闭弹窗 */
closeOnClickModal: {
type: Boolean,
default: false,
},
/** 按ESC键是否关闭弹窗 */
closeOnPressEscape: {
type: Boolean,
default: true,
},
/** 是否显示关闭按钮 */
showClose: {
type: Boolean,
default: true,
},
/** 是否在关闭弹窗时销毁内容 */
destroyOnClose: {
type: Boolean,
default: true,
},
/** 是否显示取消按钮 */
showCancelButton: {
type: Boolean,
default: false,
},
/** 确认按钮文本 */
confirmText: {
type: String,
default: '确定',
},
/** 取消按钮文本 */
cancelText: {
type: String,
default: '取消',
},
/** 自动关闭时间(毫秒),0表示不自动关闭 */
autoClose: {
type: Number,
default: 0,
},
/** 自定义CSS类 */
customClass: {
type: String,
default: '',
},
/** 是否显示图标 */
showIcon: {
type: Boolean,
default: true,
},
/** 确认按钮加载状态 */
confirmLoading: {
type: Boolean,
default: false,
},
/** 取消按钮加载状态 */
cancelLoading: {
type: Boolean,
default: false,
},
/** 是否可拖拽 */
draggable: {
type: Boolean,
default: false,
},
})
// 定义事件 - 事件参数包含triggerKey
const emit = defineEmits<{
(e: 'confirm', triggerKey: string): void
(e: 'cancel', triggerKey: string): void
(e: 'close', triggerKey: string): void
(e: 'update:visible', value: boolean): void
}>()
// 内部状态管理
const dialogVisible = ref(props.visible)
// 定时器
const autoCloseTimer = ref<NodeJS.Timeout | null>(null)
// 监听visible变化
watch(
() => props.visible,
(newVal) => {
dialogVisible.value = newVal
},
)
// 监听dialogVisible变化
watch(
() => dialogVisible.value,
(newVal) => {
if (newVal) {
// 处理自动关闭
if (props.autoClose > 0) {
clearAutoCloseTimer()
autoCloseTimer.value = setTimeout(() => {
handleClose()
}, props.autoClose)
}
} else {
// 触发update:visible事件
emit('update:visible', false)
clearAutoCloseTimer()
}
},
)
// 清除自动关闭定时器
const clearAutoCloseTimer = () => {
if (autoCloseTimer.value) {
clearTimeout(autoCloseTimer.value)
autoCloseTimer.value = null
}
}
// 根据类型获取按钮样式
const getButtonType = computed(() => {
switch (props.type) {
case 'success':
return 'success'
case 'warning':
return 'warning'
case 'error':
return 'danger'
case 'info':
return 'info'
case 'confirm':
return 'primary'
default:
return 'primary'
}
})
// 处理关闭事件 - 传递triggerKey
const handleClose = () => {
dialogVisible.value = false
emit('close', props.triggerKey)
}
// 处理确认事件 - 传递triggerKey
const handleConfirm = () => {
emit('confirm', props.triggerKey)
handleClose()
}
// 处理取消事件 - 传递triggerKey
const handleCancel = () => {
emit('cancel', props.triggerKey)
handleClose()
}
// 组件卸载时清理定时器
onUnmounted(() => {
clearAutoCloseTimer()
})
</script>
<style scoped>
.modal-content {
@apply flex items-start;
}
.icon-container {
@apply flex items-center justify-center;
}
.content-container {
@apply min-h-0;
}
/* 适配Element Plus的样式 */
:deep(.el-dialog__body) {
@apply px-6 py-4;
}
:deep(.el-dialog__header) {
@apply border-b border-gray-200 pb-4 mb-0;
}
:deep(.el-dialog__footer) {
@apply border-t border-gray-200 pt-4 mt-0;
}
</style>
<template>
<div>
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">添加联系人</h3>
<button
v-if="editingContactId"
@click="resetForm"
class="text-gray-500 hover:text-gray-700"
>
<i class="fas fa-times"></i> 取消编辑
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-gray-700 mb-1 text-sm">姓名 *</label>
<input
v-model="formData.name"
type="text"
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"
/>
</div>
<div>
<label class="block text-gray-700 mb-1 text-sm">称谓</label>
<input
v-model="formData.title"
type="text"
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"
placeholder="先生/女士/教授等"
/>
</div>
<div>
<label class="block text-gray-700 mb-1 text-sm">公司</label>
<input
v-model="formData.company"
type="text"
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"
/>
</div>
<div>
<label class="block text-gray-700 mb-1 text-sm">邮箱 *</label>
<input
v-model="formData.email"
type="email"
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"
/>
</div>
<div>
<label class="block text-gray-700 mb-1 text-sm">关联抄送邮箱</label>
<input
v-model="formData.ccEmail"
type="email"
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"
placeholder="当此联系人作为收件人时自动抄送的邮箱"
/>
</div>
<div>
<label class="block text-gray-700 mb-1 text-sm">其他信息</label>
<input
v-model="formData.other"
type="text"
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"
/>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
@click="saveContact"
class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700 transition-colors"
:disabled="!formData.name || !formData.email"
>
{{ editingContactId ? '更新联系人' : '添加联系人' }}
</button>
</div>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="p-6 border-b border-gray-200">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h3 class="text-lg font-semibold">联系人列表</h3>
<div class="w-full sm:w-auto">
<div class="relative">
<input
v-model="searchTerm"
type="text"
class="w-full px-3 py-2 pl-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="搜索联系人..."
/>
<i
class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
></i>
</div>
</div>
</div>
</div>
<div 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>
<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-right 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="contact in filteredContacts" :key="contact.id">
<td class="px-6 py-4 whitespace-nowrap">{{ contact.name }}</td>
<td class="px-6 py-4 whitespace-nowrap">{{ contact.title || '-' }}</td>
<td class="px-6 py-4 whitespace-nowrap">{{ contact.company || '-' }}</td>
<td class="px-6 py-4 whitespace-nowrap">{{ contact.email }}</td>
<td class="px-6 py-4 whitespace-nowrap">{{ contact.ccEmail || '-' }}</td>
<td class="px-6 py-4">{{ contact.other || '-' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
@click="editContact(contact)"
class="text-blue-600 hover:text-blue-900 mr-3"
>
编辑
</button>
<button @click="deleteContact(contact.id)" class="text-red-600 hover:text-red-900">
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="filteredContacts.length === 0" class="p-8 text-center text-gray-500">
<i class="fas fa-address-book text-4xl mb-3 opacity-30"></i>
<p>暂无联系人,请添加联系人</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps, defineEmits } from 'vue'
import { Contact } from '../types'
const props = defineProps({
contacts: {
type: Array as () => Contact[],
required: true,
},
})
const emits = defineEmits(['update-contacts'])
// 状态
const contacts = ref<Contact[]>([...props.contacts])
const searchTerm = ref('')
const editingContactId = ref('')
const formData = ref<Partial<Contact>>({
name: '',
title: '',
company: '',
email: '',
ccEmail: '',
other: '',
})
// 计算属性
const filteredContacts = computed(() => {
return contacts.value.filter(
(contact) =>
contact.name.toLowerCase().includes(searchTerm.value.toLowerCase()) ||
contact.email.toLowerCase().includes(searchTerm.value.toLowerCase()) ||
contact.company.toLowerCase().includes(searchTerm.value.toLowerCase()),
)
})
// 方法
const resetForm = () => {
editingContactId.value = ''
formData.value = {
name: '',
title: '',
company: '',
email: '',
ccEmail: '',
other: '',
}
}
const saveContact = () => {
if (!formData.value.name || !formData.value.email) return
if (editingContactId.value) {
// 更新现有联系人
const index = contacts.value.findIndex((c) => c.id === editingContactId.value)
if (index > -1) {
contacts.value[index] = {
...contacts.value[index],
...formData.value,
} as Contact
emits('update-contacts', [...contacts.value])
alert('联系人更新成功')
}
} else {
// 添加新联系人
const newContact: Contact = {
id: Date.now().toString(),
name: formData.value.name || '',
title: formData.value.title || '',
company: formData.value.company || '',
email: formData.value.email || '',
ccEmail: formData.value.ccEmail || '',
other: formData.value.other || '',
}
contacts.value.push(newContact)
emits('update-contacts', [...contacts.value])
alert('联系人添加成功')
}
resetForm()
}
const editContact = (contact: Contact) => {
editingContactId.value = contact.id
formData.value = { ...contact }
}
const deleteContact = (id: string) => {
if (confirm('确定要删除这个联系人吗?')) {
contacts.value = contacts.value.filter((contact) => contact.id !== id)
emits('update-contacts', [...contacts.value])
}
}
</script>
<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?.scheduleTime) || '无' }}</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)"
>
{{ record.statusLabel || '未知状态' }}
</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.errorMsg || '--' }}
</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'
import type { DictItem } from '@/types/index'
// 定义props
interface EmailRecord {
receiveEmail?: string
status?: string
sendTime?: string
errorMsg?: string
}
interface EmailTask {
taskBizId?: string
subject?: string
sendEmail?: string
sendTime?: string
records?: EmailRecord[]
scheduleTime?: string
}
const props = defineProps<{
visible: boolean
emailData?: EmailTask
statusOptions?: DictItem[]
}>()
const emit = defineEmits<{
close: []
}>()
// 计算属性
const emailRecords = ref<EmailRecord[]>([])
// 监听emailData变化
watch(
() => props.emailData,
(newEmailData) => {
if (newEmailData && newEmailData.records && props.statusOptions) {
emailRecords.value = newEmailData.records
// 为每个邮件设置状态标签
emailRecords.value.forEach((email) => {
email.statusLabel =
props.statusOptions.find((item) => item.itemValue === email.status)?.itemLabel ||
'未知状态'
})
} 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="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-semibold">邮件预览</h3>
<button @click="$emit('close')">
<i class="fas fa-times text-gray-500"></i>
</button>
</div>
<div class="p-6 flex-1 overflow-y-auto">
<div class="mb-4">
<div class="text-sm text-gray-500">发件人:</div>
<div class="font-medium">{{ sender }}</div>
</div>
<div class="mb-4">
<div class="text-sm text-gray-500">收件人:</div>
<div>{{ emailForm.to }}</div>
</div>
<div v-if="emailForm.cc" class="mb-4">
<div class="text-sm text-gray-500">抄送人:</div>
<div>{{ emailForm.cc }}</div>
</div>
<div class="mb-6 pt-4 border-t border-gray-200">
<div class="text-xl font-semibold">{{ emailForm.subject }}</div>
</div>
<div class="mb-6">
<div v-html="previewContent" class="prose max-w-none"></div>
</div>
<div v-if="attachments.length > 0" class="pt-4 border-t border-gray-200">
<div class="text-sm text-gray-500 mb-2">附件:</div>
<div class="space-y-1">
<div
v-for="(file, index) in attachments"
:key="index"
class="flex items-center text-sm"
>
<i class="fas fa-file mr-2 text-gray-400"></i>
<span>{{ file.name }}</span>
</div>
</div>
</div>
<div
v-if="emailForm.scheduleSend"
class="mt-4 pt-4 border-t border-gray-200 text-sm text-gray-600"
>
<i class="fas fa-clock mr-1"></i> 定时发送: {{ emailForm.sendTime || '未设置时间' }}
</div>
</div>
<div class="p-4 border-t border-gray-200 flex justify-end gap-3">
<button
@click="$emit('close')"
class="px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
返回编辑
</button>
<button
@click="$emit('confirm-send')"
class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700 transition-colors"
>
确认发送
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineProps, defineEmits } from 'vue'
import { EmailForm } from '../types'
const props = defineProps({
emailForm: {
type: Object as () => EmailForm,
required: true,
},
sender: {
type: String,
required: true,
},
attachments: {
type: Array as () => File[],
required: true,
},
})
const emits = defineEmits(['confirm-send', 'close'])
// 计算属性
const previewContent = computed(() => {
// 替换变量为占位符用于预览
return props.emailForm.content.replace(
/{{\s*(\w+)\s*}}/g,
'<span class="bg-blue-100 px-1 rounded">[$1]</span>',
)
})
</script>
<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 () => {
const results = await uploadAllFiles()
if (results[0].data.code === 200) {
console.log('上传成功:', results)
emit('success', results)
return results
} else {
console.log('上传失败:', results)
emit('error', results[0].data.msg as 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>
<template>
<div class="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden" @click="$emit('close-menu')">
<div class="bg-sky-700 text-white w-64 h-full p-4" @click.stop>
<div class="flex justify-between items-center mb-6">
<h1 class="text-xl font-bold">邮件系统</h1>
<button @click="$emit('close-menu')">
<i class="fas fa-times"></i>
</button>
</div>
<nav>
<ul>
<li class="mb-2">
<button
@click="$emit('change-page', 'compose')"
class="w-full text-left px-4 py-2 rounded hover:bg-blue-500 transition-colors flex items-center"
:class="currentPage === 'compose' ? 'bg-blue-500' : ''"
>
<i class="fas fa-pen mr-2"></i>写邮件
</button>
</li>
<li class="mb-2">
<button
@click="$emit('change-page', 'contacts')"
class="w-full text-left px-4 py-2 rounded hover:bg-blue-500 transition-colors flex items-center"
:class="currentPage === 'contacts' ? 'bg-blue-500' : ''"
>
<i class="fas fa-address-book mr-2"></i>联系人管理
</button>
</li>
<li class="mb-2">
<button
@click="$emit('change-page', 'senders')"
class="w-full text-left px-4 py-2 rounded hover:bg-blue-500 transition-colors flex items-center"
:class="currentPage === 'senders' ? 'bg-blue-500' : ''"
>
<i class="fas fa-user-circle mr-2"></i>发件人管理
</button>
</li>
<li class="mb-2">
<button
@click="$emit('change-page', 'variables')"
class="w-full text-left px-4 py-2 rounded hover:bg-blue-500 transition-colors flex items-center"
:class="currentPage === 'variables' ? 'bg-blue-500' : ''"
>
<i class="fas fa-variable mr-2"></i>变量管理
</button>
</li>
<li class="mb-2">
<button
@click="$emit('change-page', 'emails')"
class="w-full text-left px-4 py-2 rounded hover:bg-blue-500 transition-colors flex items-center"
:class="currentPage === 'emails' ? 'bg-blue-500' : ''"
>
<i class="fas fa-history mr-2"></i>邮件记录
</button>
</li>
</ul>
</nav>
<div class="absolute bottom-4 left-0 right-0 px-4">
<button
@click="$emit('logout')"
class="w-full text-left px-4 py-2 rounded hover:bg-blue-500 transition-colors flex items-center text-sm"
>
<i class="fas fa-sign-out-alt mr-2"></i>退出登录
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
currentPage: {
type: String,
required: true,
},
})
const emits = defineEmits(['change-page', 'close-menu', 'logout'])
</script>
<template>
<div
class="pagination-container flex items-center justify-between px-4 py-3 bg-white border-t border-gray-200"
>
<!-- 左侧信息 -->
<div class="pagination-info flex items-center text-sm text-gray-700">
<span>显示第 {{ startItem }} 到第 {{ endItem }} 条,共 {{ total }} 条记录</span>
</div>
<!-- 右侧分页控件 -->
<div class="pagination-controls flex items-center space-x-2">
<!-- 每页显示数量选择器 -->
<div class="page-size-selector flex items-center space-x-2">
<span class="text-sm text-gray-700">每页显示</span>
<el-select
v-model="pageSize"
:disabled="disabled"
size="small"
style="width: 100px"
@change="handlePageSizeChange"
>
<el-option
v-for="size in pageSizeOptions"
:key="size"
:label="`${size} 条`"
:value="size"
/>
</el-select>
</div>
<!-- 分页器 -->
<el-pagination
v-model:current-page="currentPage"
:page-size="pageSize"
:total="total"
:disabled="disabled"
:background="background"
:layout="layout"
:page-sizes="pageSizeOptions"
:pager-count="pagerCount"
@size-change="handlePageSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
// 定义组件属性
interface Props {
total: number
current?: number
pageSize?: number
pageSizes?: number[]
layout?: string
background?: boolean
disabled?: boolean
pagerCount?: number
}
// 定义组件事件
interface Emits {
(e: 'update:current', value: number): void
(e: 'update:pageSize', value: number): void
(e: 'change', page: number, pageSize: number): void
}
// 默认属性值
const props = withDefaults(defineProps<Props>(), {
current: 1,
pageSize: 10,
pageSizes: () => [10, 20, 50, 100],
layout: 'prev, pager, next, jumper',
background: true,
disabled: false,
pagerCount: 7,
})
const emit = defineEmits<Emits>()
// 响应式数据
const currentPage = ref(props.current)
const pageSize = ref(props.pageSize)
const pageSizeOptions = ref(props.pageSizes)
// 计算属性
const startItem = computed(() => {
return (currentPage.value - 1) * pageSize.value + 1
})
const endItem = computed(() => {
const end = currentPage.value * pageSize.value
return end > props.total ? props.total : end
})
// 监听外部属性变化
watch(
() => props.current,
(newVal) => {
currentPage.value = newVal
},
)
watch(
() => props.pageSize,
(newVal) => {
pageSize.value = newVal
},
)
watch(
() => props.pageSizes,
(newVal) => {
pageSizeOptions.value = newVal
},
)
// 事件处理
const handlePageSizeChange = (newSize: number) => {
pageSize.value = newSize
emit('update:pageSize', newSize)
emit('change', currentPage.value, newSize)
}
const handleCurrentChange = (newPage: number) => {
currentPage.value = newPage
emit('update:current', newPage)
emit('change', newPage, pageSize.value)
}
</script>
<style scoped>
.pagination-container {
min-height: 56px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.pagination-container {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.pagination-controls {
justify-content: space-between;
}
}
</style>
<template>
<div class="rich-text-editor">
<div ref="editorRef" class="editor-container"></div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch, onMounted, onUnmounted } from 'vue'
import WangEditor from 'wangeditor'
import axios from 'axios'
// Props/Emits 保持不变
const props = defineProps<{
modelValue: string
config?: { height?: number; uploadUrl?: string; placeholder?: string }
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'change', value: string): void
}>()
// 默认配置
const defaultConfig = {
height: 300,
uploadUrl: `${import.meta.env.VITE_REMOTE_API_BASE_URL}/oss/api/oss/upload`,
placeholder: '请输入内容...',
}
const config = { ...defaultConfig, ...props.config }
// 编辑器实例
const editorRef = ref<HTMLDivElement | null>(null)
let editor: WangEditor | null = null
const content = ref(props.modelValue)
// 封装上传方法(复用)
const uploadImage = async (file: File) => {
const formData = new FormData()
formData.append('file', file) // 固定key为file
const res = await axios.post(config.uploadUrl, formData, {
headers: {
'Content-Type': 'multipart/form-data',
Authorization: localStorage.getItem('authToken') || '',
},
})
if (res.data.code !== 200) throw new Error(res.data.msg || '上传失败')
return res.data.data.url
}
onMounted(() => {
if (!editorRef.value) return
editor = new WangEditor(editorRef.value)
// 基础配置(保留wangEditor原生风格)
editor.config.height = config.height
editor.config.placeholder = config.placeholder
editor.config.showLinkImg = false
editor.config.onchange = (html: string) => {
content.value = html
emit('update:modelValue', html)
emit('change', html)
}
// 核心:仅重写上传逻辑(最简版)
editor.config.customUploadImg = async (files, insertImg) => {
try {
// 仅上传第一张(符合原限制)
const url = await uploadImage(files[0])
insertImg(url) // 插入到编辑器
} catch (err) {
console.error('图片上传失败:', err)
// 可添加Element Plus提示:ElMessage.error('图片上传失败')
}
}
editor.create()
// 初始化内容
if (props.modelValue) editor.txt.html(props.modelValue)
})
// 监听外部内容变化
watch(
() => props.modelValue,
(newVal) => {
if (editor && editor.txt.html() !== newVal) {
editor.txt.html(newVal)
content.value = newVal
}
},
{ immediate: true },
)
// 销毁编辑器
onUnmounted(() => editor?.destroy())
</script>
<style scoped>
.rich-text-editor {
border: 1px solid #e5e7eb;
border-radius: 4px;
width: 100%;
}
.editor-container {
min-height: 300px;
padding: 10px;
}
:deep(.w-e-toolbar) {
border-bottom: 1px solid #e5e7eb;
flex-wrap: wrap;
}
:deep(.w-e-text-container) {
min-height: 250px;
}
</style>
<template>
<div>
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">添加发件人邮箱</h3>
<button v-if="editingSenderId" @click="resetForm" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i> 取消编辑
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-gray-700 mb-1 text-sm">邮箱地址 *</label>
<input
v-model="formData.email"
type="email"
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"
placeholder="例如:service@example.com"
/>
</div>
<div>
<label class="block text-gray-700 mb-1 text-sm">密码/授权码 *</label>
<input
v-model="formData.password"
type="password"
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"
placeholder="邮箱密码或授权码"
/>
</div>
<div>
<label class="block text-gray-700 mb-1 text-sm">SMTP服务器 *</label>
<input
v-model="formData.smtpServer"
type="text"
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"
placeholder="例如:smtp.example.com"
/>
</div>
<div>
<label class="block text-gray-700 mb-1 text-sm">SMTP端口 *</label>
<input
v-model="formData.smtpPort"
type="number"
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"
placeholder="例如:587"
/>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
@click="saveSender"
class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700 transition-colors"
:disabled="
!formData.email || !formData.password || !formData.smtpServer || !formData.smtpPort
"
>
{{ editingSenderId ? '更新发件人' : '添加发件人' }}
</button>
</div>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="p-6 border-b border-gray-200">
<h3 class="text-lg font-semibold">发件人列表</h3>
</div>
<div 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"
>
SMTP服务器
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
SMTP端口
</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-right 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="sender in senders" :key="sender.id">
<td class="px-6 py-4 whitespace-nowrap">{{ sender.email }}</td>
<td class="px-6 py-4 whitespace-nowrap">{{ sender.smtpServer }}</td>
<td class="px-6 py-4 whitespace-nowrap">{{ sender.smtpPort }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"
>
可用
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button @click="editSender(sender)" class="text-blue-600 hover:text-blue-900 mr-3">
编辑
</button>
<button @click="deleteSender(sender.id)" class="text-red-600 hover:text-red-900">
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="senders.length === 0" class="p-8 text-center text-gray-500">
<i class="fas fa-envelope text-4xl mb-3 opacity-30"></i>
<p>暂无发件人邮箱,请添加发件人</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits } from 'vue'
import { Sender } from '../types'
const props = defineProps({
senders: {
type: Array as () => Sender[],
required: true,
},
})
const emits = defineEmits(['update-senders'])
// 状态
const senders = ref<Sender[]>([...props.senders])
const editingSenderId = ref('')
const formData = ref<Partial<Sender>>({
email: '',
password: '',
smtpServer: '',
smtpPort: '',
})
// 方法
const resetForm = () => {
editingSenderId.value = ''
formData.value = {
email: '',
password: '',
smtpServer: '',
smtpPort: '',
}
}
const saveSender = () => {
if (
!formData.value.email ||
!formData.value.password ||
!formData.value.smtpServer ||
!formData.value.smtpPort
)
return
if (editingSenderId.value) {
// 更新现有发件人
const index = senders.value.findIndex((s) => s.id === editingSenderId.value)
if (index > -1) {
senders.value[index] = {
...senders.value[index],
...formData.value,
} as Sender
emits('update-senders', [...senders.value])
alert('发件人更新成功')
}
} else {
// 添加新发件人
const newSender: Sender = {
id: Date.now().toString(),
email: formData.value.email || '',
password: formData.value.password || '',
smtpServer: formData.value.smtpServer || '',
smtpPort: formData.value.smtpPort || '',
}
senders.value.push(newSender)
emits('update-senders', [...senders.value])
alert('发件人添加成功')
}
resetForm()
}
const editSender = (sender: Sender) => {
editingSenderId.value = sender.id
formData.value = { ...sender }
}
const deleteSender = (id: string) => {
if (confirm('确定要删除这个发件人吗?')) {
senders.value = senders.value.filter((sender) => sender.id !== id)
emits('update-senders', [...senders.value])
}
}
</script>
...@@ -6,7 +6,6 @@ ...@@ -6,7 +6,6 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* 自定义样式 */ /* 自定义样式 */
#app { #app {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
......
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' 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 App from './App.vue'
import router from './router' import router from './router'
...@@ -8,6 +15,16 @@ import router from './router' ...@@ -8,6 +15,16 @@ import router from './router'
import './index.css' import './index.css'
const app = createApp(App) 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(createPinia())
app.use(router) app.use(router)
......
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory('/yd-email/'),
routes: [], routes: [
{
path: '/',
name: 'home',
redirect: (to) => {
console.log('----', to.fullPath)
// 检查用户是否已登录
const token = localStorage.getItem('authToken')
console.log('----', token)
if (token) {
// 已登录,重定向到写邮件页面
return '/compose'
} else {
// 未登录,重定向到登录页面
return '/login'
}
},
},
{
path: '/compose',
name: 'compose',
component: () => import('../views/ComposeEmail.vue'),
},
{
path: '/contacts',
name: 'contacts',
component: () => import('../views/ContactManagement.vue'),
},
{
path: '/senders',
name: 'senders',
component: () => import('../views/SenderManagement.vue'),
},
{
path: '/variables',
name: 'variables',
component: () => import('../views/VariableManagement.vue'),
},
{
path: '/emails',
name: 'emails',
component: () => import('../views/EmailManagement.vue'),
},
{
path: '/signature-management',
name: 'signature-management',
component: () => import('../views/SignatureManagement.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginPage.vue'),
},
],
})
// 添加路由守卫,实现条件导航
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('authToken')
// 如果用户访问登录页且已登录,重定向到首页
if (to.path === '/login' && token) {
next('/compose')
return
}
// 如果用户访问需要认证的页面且未登录,重定向到登录页
const publicPages = ['/login']
const authRequired = !publicPages.includes(to.path)
if (authRequired && !token) {
// 保存当前路径,登录后可跳转回来
localStorage.setItem('redirectPath', to.fullPath)
next('/login')
return
}
next()
}) })
export default router export default router
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
// 分页类型
export interface Pagination<T> {
records?: T[]
total?: number
pageSize?: number
sortField?: string
sortOrder?: string
pageNo?: number
}
// 联系人类型 // 联系人类型
export interface Contact { export interface Contact<T> extends Pagination<Contact> {
id: string contactBizId?: string
name: string name?: string
title: string type?: string
company: string companyName?: string
email: string email?: string
ccEmail: string ccEmailList?: string[]
other: string other?: string
appellation?: string
} }
// 发件人类型 // 发件人类型
export interface Sender { export interface Sender extends Pagination<Sender> {
id: string senderBizId?: string
email: string email?: string
password: string password?: string
smtpServer: string displayName?: string
smtpPort: string providerBizId?: string
active?: number
emailSenderConfigName?: string
emailSenderConfigEmail?: string
} }
// 变量类型 // 变量类型
export interface Variable { export interface Variable extends Pagination<Variable> {
id: string id?: string
name: string variableBizId?: string
key: string variableNameCn?: string
description: string variableNameEn?: string
description?: string
isGeneral?: number
} }
// 变量模板类型 // 变量模板类型
export interface VariableTemplate { export interface VariableTemplate extends Pagination<VariableTemplate> {
id: string variableGroupBizId?: string
name: string groupName?: string
description: string description?: string
variableIds: string[] variableBizIdList?: string[]
} variableNameEns?: string[]
variableNameEnList?: string[]
// 邮件类型
export interface Email {
id: string
sender: string
to: string
cc: string
subject: string
content: string
sendTime: string
status: 'sent' | 'scheduled' | 'draft' | 'failed'
attachments?: { name: string }[]
}
// 邮件表单类型
export interface EmailForm {
to: string
cc: string
subject: string
content: string
scheduleSend: boolean
sendTime: string
} }
// 忘记密码表单类型 // 忘记密码表单类型
...@@ -63,3 +58,137 @@ export interface ForgotPasswordForm { ...@@ -63,3 +58,137 @@ export interface ForgotPasswordForm {
newPassword: string newPassword: string
confirmPassword: string confirmPassword: string
} }
// 导入记录类型
export interface ImportRecord extends Contact<ImportRecord> {
sessionId?: string
receiveEmailList?: string[]
ccEmailList?: string[]
ccEmail?: string
}
// 选择联系人时,调用接口,获取sessionId
export interface ContactSessionId {
sessionId?: string
apiEmailContactDtoList?: Contact<unknown>[]
}
// 编辑-邮件联系人导入信息
export interface EditContactImport extends Pagination<EditContactImport> {
receiveEmail?: string
sessionId?: string
source?: string
}
// 发送邮件
export interface SendEmail {
senderBizId?: string
sendEmail?: string
subject?: string
content?: string
scheduleTime?: string
attachmentPath?: string
variableGroupBizId?: string
sessionId?: string
recipientEmailList?: string[]
ccEmailList?: string[]
bccEmailList?: string[]
receiveEmailList?: string[]
}
export interface EmailForm {
senderBizId?: string
sendEmail?: string
variableGroupBizId?: string
receiveEmail?: string
ccEmailList?: string[]
subject?: string
content?: string
attachmentPath?: string
sessionId?: string
ccEmails?: string
scheduleSend?: boolean
scheduleTime?: string
signatureId?: string
customContent?: string
}
//邮件服务商类型
export interface EmailProvider extends Pagination<EmailProvider> {
providerBizId?: string
providerName?: string
smtpHost?: string
smtpPort?: string
sslEnabled?: number
active?: number
description?: string
}
// 发送任务列表查询参数
export interface SubTask extends Pagination<SubTask> {
taskBizId?: string
receiveEmail?: string
status?: string
}
// 主线任务列表查询参数
export interface EmailTask extends Pagination<EmailTask> {
queryContent?: string
status?: string
taskBizId?: string
taskName?: string
senderBizId?: string
sendEmail?: string
receiveEmails?: string
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:启用)
}
// 签名基础类型
export interface Signature {
id: string | number
name: string // 签名名称
type: 'template' | 'custom' // 类型:模板型/自定义型
isDefault: boolean // 是否默认签名
config: {
// 模板型签名配置
companyName: string // 公司名称
logoUrl: string // LOGO地址
name: string // 姓名
alias: string // 别名/职位
phone: string // 电话
email: string // 邮箱
address: string // 地址
// 字段显示配置
showCompanyName: boolean
showLogo: boolean
showName: boolean
showAlias: boolean
showPhone: boolean
showEmail: boolean
showAddress: boolean
}
customContent: string // 自定义富文本内容
createTime: string
updateTime: string
}
// 签名字段配置项
export interface SignatureField {
key: keyof Signature['config']
label: string
type: 'text' | 'image' | 'boolean'
placeholder?: string
}
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,
}
}
// 菜单项接口定义
export interface MenuItem {
name: string
path: string
icon: string
title: string
}
// 菜单配置
export const menuConfig: MenuItem[] = [
{
name: 'compose',
path: '/compose',
icon: 'fas fa-pen',
title: '写邮件',
},
{
name: 'contacts',
path: '/contacts',
icon: 'fas fa-address-book',
title: '联系人管理',
},
{
name: 'senders',
path: '/senders',
icon: 'fas fa-user-circle',
title: '发件人管理',
},
{
name: 'variables',
path: '/variables',
icon: 'fas fa-file-excel',
title: '变量管理',
},
// {
// name: 'signatures',
// path: '/signature-management',
// icon: 'fas fa-file-excel',
// title: '签名管理',
// },
{
name: 'emails',
path: '/emails',
icon: 'fas fa-history',
title: '邮件记录',
},
]
// 页面标题映射
export const pageTitles: Record<string, string> = {
compose: '写邮件',
contacts: '联系人管理',
senders: '发件人管理',
variables: '变量管理',
emails: '邮件记录',
// signatures: '签名管理',
}
import axios, { type AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
// 定义统一的响应类型
export interface ApiResponse<T = object> {
code: number
message?: string
data: T
success: boolean
msg?: string
}
// 创建axios实例
const request = axios.create({
baseURL: '/',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
Authorization: localStorage.getItem('authToken') || '',
},
})
// 请求拦截器 - 添加Authorization头
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 从本地存储获取token
const token = localStorage.getItem('authToken')
// 如果token存在,添加到请求头
if (token && config.headers) {
config.headers.Authorization = token
}
return config
},
(error: AxiosError) => {
// 处理请求错误
return Promise.reject(error)
},
)
// 响应拦截器 - 处理常见错误
request.interceptors.response.use(
(response: AxiosResponse) => {
// 直接返回响应数据
return response.data
},
(error: AxiosError) => {
// 处理401未授权错误
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 = '/yd-email/login'
}
}
return Promise.reject(error)
},
)
export default request
...@@ -14,26 +14,27 @@ ...@@ -14,26 +14,27 @@
type="text" type="text"
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"
placeholder="搜索联系人..." placeholder="搜索联系人..."
@change="handleQueryChange"
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div <div
v-for="contact in filteredContacts" v-for="contact in filteredContacts"
:key="contact.id" :key="contact.contactBizId"
class="flex items-center p-3 border border-gray-200 rounded-md hover:bg-blue-50 cursor-pointer" class="flex items-center p-3 border border-gray-200 rounded-md hover:bg-blue-50 cursor-pointer"
@click="toggleSelection(contact)" @click="toggleSelection(contact)"
> >
<input <input
type="checkbox" type="checkbox"
:id="'contact-' + contact.id" :id="'contact-' + contact.contactBizId"
:checked="selectedContacts.includes(contact.id)" :checked="selectedContacts.includes(contact.contactBizId || '')"
class="mr-3" class="mr-3"
/> />
<label for="'contact-' + contact.id" class="flex-1"> <label for="'contact-' + contact.contactBizId" class="flex-1">
<div class="font-medium">{{ contact.name }}</div> <div class="font-medium">{{ contact.name }}</div>
<div class="text-sm text-gray-500">{{ contact.email }}</div> <div class="text-sm text-gray-500">{{ contact.email }}</div>
</label> </label>
<div class="text-sm text-gray-500">{{ contact.company || '' }}</div> <div class="text-sm text-gray-500">{{ contact.companyName || '' }}</div>
</div> </div>
</div> </div>
<div v-if="filteredContacts.length === 0" class="p-6 text-center text-gray-500"> <div v-if="filteredContacts.length === 0" class="p-6 text-center text-gray-500">
...@@ -60,7 +61,7 @@ ...@@ -60,7 +61,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, defineProps, defineEmits } from 'vue' import { ref, computed, defineProps, defineEmits } from 'vue'
import { Contact } from '../types' import type { Contact } from '@/types/index'
const props = defineProps({ const props = defineProps({
contacts: { contacts: {
...@@ -69,7 +70,7 @@ const props = defineProps({ ...@@ -69,7 +70,7 @@ const props = defineProps({
}, },
}) })
const emits = defineEmits(['confirm-selection', 'close']) const emits = defineEmits(['confirm-selection', 'close', 'query-change'])
// 状态 // 状态
const searchTerm = ref('') const searchTerm = ref('')
...@@ -79,30 +80,31 @@ const selectedContacts = ref<string[]>([]) ...@@ -79,30 +80,31 @@ const selectedContacts = ref<string[]>([])
const filteredContacts = computed(() => { const filteredContacts = computed(() => {
return props.contacts.filter( return props.contacts.filter(
(contact) => (contact) =>
contact.name.toLowerCase().includes(searchTerm.value.toLowerCase()) || contact.name?.toLowerCase().includes(searchTerm.value.toLowerCase()) ||
contact.email.toLowerCase().includes(searchTerm.value.toLowerCase()) || contact.email?.toLowerCase().includes(searchTerm.value.toLowerCase()) ||
contact.company.toLowerCase().includes(searchTerm.value.toLowerCase()), contact.companyName?.toLowerCase().includes(searchTerm.value.toLowerCase()),
) )
}) })
const handleQueryChange = () => {
searchTerm.value = searchTerm.value.trim()
emits('query-change', searchTerm.value)
}
// 方法 // 方法
const toggleSelection = (contact: Contact) => { const toggleSelection = (contact: Contact) => {
const index = selectedContacts.value.indexOf(contact.id) const index = selectedContacts.value.indexOf(contact.contactBizId || '')
if (index > -1) { if (index > -1) {
selectedContacts.value.splice(index, 1) selectedContacts.value.splice(index, 1)
} else { } else {
selectedContacts.value.push(contact.id) selectedContacts.value.push(contact.contactBizId || '')
} }
} }
const confirmSelection = () => { const confirmSelection = () => {
const selected = props.contacts.filter((contact) => selectedContacts.value.includes(contact.id)) const selected = props.contacts.filter((contact) =>
const to = selected.map((contact) => contact.email).join(',') selectedContacts.value.includes(contact.contactBizId || ''),
const cc = selected )
.map((contact) => contact.ccEmail) emits('confirm-selection', selected)
.filter(Boolean)
.join(',')
emits('confirm-selection', { to, cc })
} }
</script> </script>
<template>
<div
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
v-if="visible"
>
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-semibold">
{{ editingTemplateId ? '编辑变量模板' : '创建变量模板' }}
</h3>
<button @click="closeTemplateModal">
<i class="fas fa-times text-gray-500"></i>
</button>
</div>
<div class="p-4 flex-1 overflow-y-auto">
<div class="mb-4">
<label class="block text-gray-700 mb-1 text-sm">模板名称 *</label>
<input
v-model="templateForm.groupName"
type="text"
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"
/>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-1 text-sm">模板描述</label>
<textarea
v-model="templateForm.description"
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"
rows="2"
></textarea>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-1 text-sm">选择变量</label>
<div
class="space-y-2 max-h-[300px] overflow-y-auto p-2 border border-gray-200 rounded-md"
>
<!-- 增加搜索框 -->
<input
type="text"
v-model="searchQuery"
placeholder="搜索变量"
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"
/>
<div
v-for="variable in variables"
:key="variable.variableBizId"
class="flex items-center p-2 hover:bg-blue-50 rounded"
>
<input
type="checkbox"
:id="'template-var-' + variable.variableBizId"
:checked="templateForm.variableBizIdList?.includes(variable.variableBizId || '')"
class="mr-3"
@change="toggleTemplateVariable(variable.variableBizId || '')"
/>
<label for="'template-var-' + variable.variableBizId">
<div class="text-sm text-gray-500">{{ variable.variableNameCn }}</div>
<div class="font-medium font-mono text-sm">
{{ variablePrefix }}{{ variable.variableNameEn }}{{ variableNextfix }}
</div>
</label>
</div>
</div>
<div v-if="variables.length === 0" class="p-4 text-center text-gray-500 text-sm">
<p>暂无可用变量,请先添加变量</p>
</div>
</div>
</div>
<div class="p-4 border-t border-gray-200 flex justify-end gap-3">
<button
@click="closeTemplateModal"
class="px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
取消
</button>
<button
@click="saveVariableTemplate"
class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700 transition-colors"
>
{{ editingTemplateId ? '更新模板' : '创建模板' }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { variableApi, variableGroupApi } from '@/api/api'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Action } from 'element-plus/es/components/message-box/interface'
const props = defineProps({
editingTemplateId: {
type: String,
default: '',
},
visible: {
type: Boolean,
default: false,
},
})
const templateForm = ref({
groupName: '',
description: '',
variableBizIdList: [],
})
const searchQuery = ref('')
const variables = ref([])
// 状态
const variablePrefix = '{{'
const variableNextfix = '}}'
const emit = defineEmits(['confirm', 'cancel', 'closeTemplateModal'])
const closeTemplateModal = () => {
templateForm.value = {
groupName: '',
description: '',
variableBizIdList: [],
}
// 关闭弹窗
emit('closeTemplateModal')
}
onMounted(() => {
fetchVariables()
})
const toggleTemplateVariable = (variableId: string) => {
if (!templateForm.value.variableBizIdList) {
templateForm.value.variableBizIdList = []
}
const index = templateForm.value.variableBizIdList.indexOf(variableId)
if (index > -1) {
templateForm.value.variableBizIdList.splice(index, 1)
} else {
templateForm.value.variableBizIdList.push(variableId)
}
}
const open = (msg: string, title: string) => {
ElMessageBox.alert(msg, title, {
confirmButtonText: 'OK',
callback: (action: Action) => {},
})
}
const saveVariableTemplate = () => {
if (!templateForm.value.groupName) {
open('请输入模版名称', '错误')
return
}
if (props.editingTemplateId) {
console.log('更新变量模版', templateForm.value)
// 更新现有模板
variableGroupApi
.editEmailVariableGroup({
variableGroupBizId: props.editingTemplateId || '',
groupName: templateForm.value.groupName || '',
description: templateForm.value.description || '',
variableBizIdList: templateForm.value.variableBizIdList || [],
})
.then(() => {
open('更新变量模版成功', '成功')
// 通知父组件,刷新变量模版列表
emit('confirm', props.editingTemplateId || '')
templateForm.value = {
groupName: '',
description: '',
variableBizIdList: [],
}
})
.catch((error) => {
console.error('更新变量模版失败:', error)
if (error.response?.data?.msg) {
open(error.response.data.msg, '错误')
} else {
open(error.response.data.msg, '错误')
}
})
} else {
// 调用变量组保存接口
variableGroupApi
.addEmailVariableGroup({
groupName: templateForm.value.groupName || '',
description: templateForm.value.description || '',
variableBizIdList: templateForm.value.variableBizIdList || [],
})
.then(() => {
open('创建变量模版成功', '成功')
// 通知父组件,刷新变量模版列表
emit('confirm', '')
templateForm.value = {
groupName: '',
description: '',
variableBizIdList: [],
}
})
.catch((error) => {
console.error('创建变量模版失败:', error)
if (error.response?.data?.msg) {
open(error.response.data.msg, '创建变量模版失败')
} else {
open(error.response.data.msg, '创建变量模版失败')
}
})
}
closeTemplateModal()
}
const fetchVariables = () => {
variableApi
.getEmailVariableList({
queryContent: searchQuery.value || '',
pageNum: 1,
pageSize: 1000,
isGeneral: 0,
})
.then((response) => {
variables.value = response.data.records || []
console.log(response.data)
})
.catch((error) => {
console.error('获取变量列表失败:', error)
if (error.response?.data?.msg) {
open(error.response.data.msg, '获取变量列表失败')
} else {
open(error.response.data.msg, '获取变量列表失败')
}
})
}
// 防抖函数
const debounce = (func: Function, delay: number) => {
let timeoutId: NodeJS.Timeout
return (...args: any[]) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => func.apply(null, args), delay)
}
}
// 防抖后的搜索函数
const debouncedFetchVariables = debounce(fetchVariables, 500)
// 监听搜索查询的变化
watch(searchQuery, (newValue) => {
if (newValue.trim() !== '') {
debouncedFetchVariables()
} else {
// 如果搜索内容为空,直接调用(不需要防抖)
fetchVariables()
}
})
</script>
<template>
<div
v-if="visible"
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
>
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<h3 class="text-lg font-semibold mb-4">{{ title }}</h3>
<FileUploadComponent
v-model="uploadedFiles"
:config="uploadConfig"
@success="handleDocumentUploadSuccess"
@error="handleDocumentUploadError"
/>
<div class="flex justify-end gap-3">
<button
v-if="confirmBtnFlag"
@click="handleConfirm"
class="text-white bg-blue-500 px-4 py-2 border border-gray-300 rounded-md"
>
确认
</button>
<button
@click="handleCancel"
class="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
取消
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '导入文件',
},
accept: {
type: String,
default: '.csv,.xlsx',
},
uploadUrl: {
type: String,
default: `${import.meta.env.VITE_REMOTE_API_BASE_URL}/email/api/emailFile/import/excel/variable`,
},
triggerKey: {
type: String,
default: 'importContactModal',
},
})
/**
* 文件上传配置
*/
// 上传附件
import FileUploadComponent from '@/components/FileUploadComponent.vue'
import { UploadResult, UploadConfig } from '@/utils/fileUpload'
const confirmBtnFlag = ref<boolean>(false)
const uploadConfig: UploadConfig = {
url: props.uploadUrl,
fieldName: 'file',
maxSize: 10,
allowedTypes: [],
maxCount: 1,
multiple: false,
}
// 上传成功的文件
const uploadedFiles = ref<any[]>([])
const result = ref<any>({})
// 处理文档上传成功
const handleDocumentUploadSuccess = (results: UploadResult[]) => {
confirmBtnFlag.value = true
result.value = results[0]
emit('success', result.value)
}
// 处理文档上传失败
const handleDocumentUploadError = (error: string) => {
emit('error', error, props.triggerKey)
}
const emit = defineEmits(['success', 'error', 'cancel', 'confirm'])
const handleCancel = () => {
emit('cancel', props.triggerKey)
}
const handleConfirm = () => {
emit('confirm', result.value, props.triggerKey)
}
// 监听visible变化,重置文件输入
watch(
() => props.visible,
(newVisible) => {
if (newVisible) {
// 下次DOM更新后重置文件输入
setTimeout(() => {
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
if (fileInput) {
fileInput.value = ''
}
}, 100)
}
},
)
</script>
<template>
<div class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] flex flex-col">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-semibold">导入记录管理</h3>
<button @click="$emit('close')">
<i class="fas fa-times text-gray-500"></i>
</button>
</div>
<div class="p-4 border-b border-gray-200">
<div class="flex gap-2">
<input
v-model="searchTerm"
type="text"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="搜索收件人邮箱..."
/>
<button
@click="clearSearch"
class="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
清除
</button>
</div>
</div>
<div class="p-4 flex-1 overflow-y-auto">
<div v-if="filteredRecords.length === 0" class="text-center text-gray-500 py-8">
<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>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="record in filteredRecords"
:key="record.importBizId"
class="hover:bg-gray-50"
>
<td class="px-6 py-4 whitespace-nowrap">
<div v-if="editingRecordId === record.importBizId" class="w-full">
<input
v-model="editingRecord.receiveEmail"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="收件人邮箱"
/>
</div>
<div v-else class="text-sm text-gray-900">
{{ record.receiveEmail || '无' }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div v-if="editingRecordId === record.importBizId" class="w-full">
<!-- 抄送人tag输入区域 -->
<div class="flex flex-wrap gap-1 mb-2">
<span
v-for="(tag, index) in ccTags"
:key="index"
class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
>
{{ tag }}
<button
@click="removeCcTag(index)"
class="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
</div>
<!-- 抄送人输入框 -->
<div class="flex gap-2">
<input
v-model="newCcTag"
type="text"
@keydown="handleCcInputKeydown"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入抄送人邮箱后按回车"
/>
<button
@click="addCcTag"
class="px-3 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm"
>
添加
</button>
</div>
</div>
<div v-else class="text-sm text-gray-900">
<div v-if="record.ccEmail" class="flex flex-wrap gap-1">
<span
v-for="(tag, index) in parseCcTags(record.ccEmail)"
:key="index"
class="inline-flex items-center px-2 py-1 bg-gray-100 text-gray-800 text-xs rounded-full"
>
{{ tag }}
</span>
</div>
<span v-else></span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div v-if="editingRecordId === record.importBizId" class="flex gap-2">
<button
@click="cancelEdit"
class="px-3 py-1 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors text-sm"
>
取消
</button>
<button
@click="saveEdit"
class="px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm"
>
保存
</button>
</div>
<div v-else class="flex gap-2">
<button
@click="editRecord(record)"
class="px-3 py-1 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors text-sm"
>
编辑
</button>
<button
@click="deleteRecord(record.importBizId)"
class="px-3 py-1 bg-red-100 text-red-700 rounded-md hover:bg-red-200 transition-colors text-sm"
>
删除
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="p-4 border-t border-gray-200">
<button
@click="$emit('close')"
class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
>
关闭
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps, defineEmits } from 'vue'
import type { ImportRecord } from '@/types/index'
const props = defineProps({
records: {
type: Array as () => ImportRecord[],
required: true,
},
})
const emits = defineEmits(['update-record', 'delete-record', 'close'])
const searchTerm = ref('')
const editingRecordId = ref<string | null>(null)
const editingRecord = ref<Partial<ImportRecord>>({})
const ccTags = ref<string[]>([])
const newCcTag = ref('')
const filteredRecords = computed(() => {
if (!searchTerm.value) return props.records
return props.records.filter(
(record) =>
record.receiveEmail?.toLowerCase().includes(searchTerm.value.toLowerCase()) ||
record.ccEmail?.toLowerCase().includes(searchTerm.value.toLowerCase()),
)
})
// 将分号分隔的字符串转换为数组
const parseCcTags = (ccString: string) => {
if (!ccString) return []
return ccString
.split(';')
.filter((tag) => tag.trim())
.map((tag) => tag.trim())
}
// 将数组转换为分号分隔的字符串
const joinCcTags = (tags: string[]) => {
return tags.join(';')
}
// 添加新的抄送人tag
const addCcTag = () => {
if (newCcTag.value.trim()) {
ccTags.value.push(newCcTag.value.trim())
newCcTag.value = ''
updateEditingRecordCc()
}
}
// 删除抄送人tag
const removeCcTag = (index: number) => {
ccTags.value.splice(index, 1)
updateEditingRecordCc()
}
// 更新编辑记录中的抄送人字段
const updateEditingRecordCc = () => {
editingRecord.value.ccEmail = joinCcTags(ccTags.value)
}
// 处理输入框回车事件
const handleCcInputKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault()
addCcTag()
}
}
const editRecord = (record: ImportRecord) => {
editingRecordId.value = record.importBizId
editingRecord.value = { ...record }
// 初始化抄送人tag数组
ccTags.value = parseCcTags(record.ccEmail || '')
newCcTag.value = ''
}
const cancelEdit = () => {
editingRecordId.value = null
editingRecord.value = {}
ccTags.value = []
newCcTag.value = ''
}
const saveEdit = () => {
if (editingRecordId.value && editingRecord.value.receiveEmail) {
console.log(editingRecord.value)
emits('update-record', {
importBizId: editingRecordId.value,
receiveEmail: editingRecord.value.receiveEmail,
ccEmail: editingRecord.value.ccEmail || '',
})
editingRecordId.value = null
editingRecord.value = {}
ccTags.value = []
newCcTag.value = ''
}
}
const deleteRecord = (id: string) => {
if (confirm('确定要删除这条导入记录吗?')) {
emits('delete-record', id)
}
}
const clearSearch = () => {
searchTerm.value = ''
}
</script>
...@@ -107,16 +107,6 @@ ...@@ -107,16 +107,6 @@
</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>
</div>
</div> </div>
<!-- 底部版权信息 --> <!-- 底部版权信息 -->
...@@ -129,6 +119,10 @@ ...@@ -129,6 +119,10 @@
<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'
import router from '@/router'
// 定义事件 - 登录成功和跳转到忘记密码页面 // 定义事件 - 登录成功和跳转到忘记密码页面
const emits = defineEmits(['login', 'go-to-forgot-password']) const emits = defineEmits(['login', 'go-to-forgot-password'])
...@@ -148,9 +142,6 @@ const handleLogin = async () => { ...@@ -148,9 +142,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) {
// 如果勾选了记住我,保存用户名到本地存储 // 如果勾选了记住我,保存用户名到本地存储
...@@ -165,24 +156,70 @@ const handleLogin = async () => { ...@@ -165,24 +156,70 @@ 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}`)
// 触发登录事件,传递用户信息 // 检查是否有重定向路径
const redirectPath = localStorage.getItem('redirectPath')
if (redirectPath) {
// 跳转到之前访问的页面
router.push(redirectPath)
localStorage.removeItem('redirectPath')
} else {
// 默认跳转到写邮件页面
router.push('/compose')
}
// 触发登录事件
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="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden" @click="$emit('close-menu')">
<div class="bg-sky-700 text-white w-64 h-full p-4" @click.stop>
<div class="flex justify-between items-center mb-6">
<h1 class="text-xl font-bold">邮件系统</h1>
<button @click="$emit('close-menu')">
<i class="fas fa-times"></i>
</button>
</div>
<nav>
<ul>
<li v-for="menu in menuItems" :key="menu.name" class="mb-2">
<router-link
:to="menu.path"
class="w-full text-left px-4 py-2 rounded hover:bg-blue-500 transition-colors flex items-center block"
:class="{ 'bg-blue-500': currentPage === menu.name }"
@click="$emit('close-menu')"
>
<i :class="menu.icon" class="mr-2"></i>{{ menu.title }}
</router-link>
</li>
</ul>
</nav>
<div class="absolute bottom-4 left-0 right-0 px-4">
<button
@click="$emit('logout')"
class="w-full text-left px-4 py-2 rounded hover:bg-blue-500 transition-colors flex items-center text-sm"
>
<i class="fas fa-sign-out-alt mr-2"></i>退出登录
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { menuConfig } from '@/utils/menuConfig'
const router = useRouter()
const currentPage = ref(router.currentRoute.value.name as string)
// 使用统一的菜单配置
const menuItems = computed(() => menuConfig)
watch(
() => router.currentRoute.value.name,
(name) => {
if (name) {
currentPage.value = name as string
}
},
)
</script>
<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>
<template>
<aside
class="bg-sky-700 text-white flex-shrink-0 hidden md:block transition-all duration-300 ease-in-out fixed h-full z-40"
:class="collapsed ? 'w-16' : 'w-64'"
>
<!-- 顶部区域 -->
<div class="p-4 border-b border-blue-500 flex items-center justify-between">
<h1
class="text-xl font-bold transition-all duration-300"
:class="collapsed ? 'opacity-0 w-0' : 'opacity-100'"
>
邮件系统
</h1>
<button
@click="$emit('toggle-collapse')"
class="p-1 rounded hover:bg-blue-500 transition-colors flex items-center justify-center"
:title="collapsed ? '展开菜单' : '折叠菜单'"
>
<i class="fas" :class="collapsed ? 'fa-chevron-right' : 'fa-chevron-left'"></i>
</button>
</div>
<!-- 导航菜单 -->
<nav class="p-4">
<ul>
<li v-for="menu in menuItems" :key="menu.name" class="mb-2">
<router-link
:to="menu.path"
class="w-full text-left px-4 py-2 rounded hover:bg-blue-500 transition-colors flex items-center"
:class="{
'bg-blue-500': currentPage === menu.name,
'justify-center': collapsed,
'justify-start': !collapsed,
}"
:title="collapsed ? menu.title : ''"
>
<i :class="[menu.icon, collapsed ? '' : 'mr-2']" class="flex-shrink-0"></i>
<span
class="transition-all duration-300"
:class="collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 ml-2'"
>
{{ menu.title }}
</span>
</router-link>
</li>
</ul>
</nav>
<!-- 底部退出按钮 -->
<div class="absolute bottom-4 left-0 right-0 px-4">
<button
@click="$emit('logout')"
class="w-full text-left px-4 py-2 rounded hover:bg-blue-500 transition-colors flex items-center justify-center text-sm"
:class="collapsed ? 'justify-center' : 'justify-start'"
:title="collapsed ? '退出登录' : ''"
>
<i class="fas fa-sign-out-alt flex-shrink-0" :class="collapsed ? '' : 'mr-2'"></i>
<span
class="transition-all duration-300"
:class="collapsed ? 'opacity-0 w-0' : 'opacity-100'"
>
退出登录
</span>
</button>
</div>
</aside>
</template>
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue'
import { menuConfig } from '@/utils/menuConfig'
const props = defineProps({
currentPage: {
type: String,
required: true,
},
collapsed: {
type: Boolean,
default: false,
},
})
const emits = defineEmits(['logout', 'toggle-collapse'])
// 使用统一的菜单配置
const menuItems = computed(() => menuConfig)
</script>
<style scoped>
/* 确保折叠时图标居中 */
.router-link-active {
@apply bg-blue-500;
}
/* 优化折叠状态下的样式 */
aside {
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
}
/* 防止文字在折叠时显示 */
span {
white-space: nowrap;
overflow: hidden;
}
/* 增加菜单项的可点击区域 */
li a {
min-height: 44px; /* 确保触摸友好的最小高度 */
cursor: pointer;
}
/* 优化折叠状态下的交互体验 */
li a:hover {
background-color: rgba(59, 130, 246, 0.8);
}
/* 确保图标和文字垂直对齐 */
li a {
align-items: center;
}
/* 优化图标和文字的间距 */
i {
width: 1.25rem; /* 固定图标宽度 */
text-align: center;
}
</style>
...@@ -17,35 +17,57 @@ ...@@ -17,35 +17,57 @@
/> />
</div> </div>
<div class="grid grid-cols-1 gap-2"> <div class="grid grid-cols-1 gap-2">
<button <div
v-for="variable in filteredVariables" v-for="variable in filteredVariables"
:key="variable.id" :key="variable.variableBizId"
class="p-3 border border-gray-200 rounded-md hover:bg-blue-50 text-left transition-colors" class="flex items-center p-3 border border-gray-200 rounded-md hover:bg-blue-50 cursor-pointer transition-colors"
@click="selectVariable(variable)"
> >
<div class="font-medium font-mono">{{ variablePrefix }}{{ variable.key }}</div> <input
<div class="text-sm text-gray-500">{{ variable.name }}</div> type="checkbox"
</button> :id="variable.variableBizId"
:checked="selectedVariables.includes(variable.variableBizId || '')"
class="mr-3 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
@change="toggleVariable(variable)"
@click.stop
/>
<label :for="variable.variableBizId" class="flex-1 cursor-pointer">
<div class="text-sm text-gray-500">{{ variable.variableNameCn }}</div>
<div class="font-medium font-mono">
{{ variablePrefix }}{{ variable.variableNameEn }}{{ variableNextfix }}
</div>
</label>
</div>
</div> </div>
<div v-if="filteredVariables.length === 0" class="p-6 text-center text-gray-500"> <div v-if="filteredVariables.length === 0" class="p-6 text-center text-gray-500">
<p>未找到匹配的变量</p> <p>未找到匹配的变量</p>
</div> </div>
</div> </div>
<div class="p-4 border-t border-gray-200 flex justify-end"> <div class="p-4 border-t border-gray-200 flex justify-between items-center">
<div class="text-sm text-gray-500">已选择 {{ selectedVariables.length }} 个变量</div>
<div class="flex gap-3">
<button <button
@click="$emit('close')" @click="clearSelection"
class="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
:disabled="selectedVariables.length === 0"
>
清空
</button>
<button
@click="confirmSelection"
class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700 transition-colors" class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700 transition-colors"
:disabled="selectedVariables.length === 0"
> >
关闭 插入变量
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, defineProps, defineEmits } from 'vue' import { ref, computed, defineProps, defineEmits } from 'vue'
import { Variable } from '../types' import type { Variable } from '@/types/index'
const props = defineProps({ const props = defineProps({
variables: { variables: {
...@@ -58,19 +80,38 @@ const emits = defineEmits(['insert-variable', 'close']) ...@@ -58,19 +80,38 @@ const emits = defineEmits(['insert-variable', 'close'])
// 状态 // 状态
const searchTerm = ref('') const searchTerm = ref('')
const selectedVariables = ref<string[]>([])
const variablePrefix = '{{' const variablePrefix = '{{'
const variableNextfix = '}}'
// 计算属性 // 计算属性
const filteredVariables = computed(() => { const filteredVariables = computed(() => {
return props.variables.filter( return props.variables.filter(
(variable) => (variable) =>
variable.name.toLowerCase().includes(searchTerm.value.toLowerCase()) || variable.variableNameCn?.toLowerCase().includes(searchTerm.value.toLowerCase()) ||
variable.key.toLowerCase().includes(searchTerm.value.toLowerCase()), variable.variableNameEn?.toLowerCase().includes(searchTerm.value.toLowerCase()),
) )
}) })
// 方法 // 方法
const selectVariable = (variable: Variable) => { const toggleVariable = (variable: Variable) => {
emits('insert-variable', variable) const index = selectedVariables.value.indexOf(variable.variableBizId || '')
if (index > -1) {
selectedVariables.value.splice(index, 1)
} else {
selectedVariables.value.push(variable.variableBizId || '')
}
}
const confirmSelection = () => {
const selected = props.variables.filter((variable) =>
selectedVariables.value.includes(variable.variableBizId || ''),
)
emits('insert-variable', selected)
emits('close')
}
const clearSelection = () => {
selectedVariables.value = []
} }
</script> </script>
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
"exclude": ["src/**/__tests__/*"], "exclude": ["src/**/__tests__/*"],
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
......
{ {
"files": [], "compilerOptions": {
"references": [ "target": "ES2020",
{ "useDefineForClassFields": true,
"path": "./tsconfig.node.json" "lib": ["ES2020", "DOM", "DOM.Iterable"],
}, "module": "ESNext",
{ "skipLibCheck": true,
"path": "./tsconfig.app.json" "moduleResolution": "bundler",
}, "allowImportingTsExtensions": true,
{ "resolveJsonModule": true,
"path": "./tsconfig.vitest.json" "isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"composite": true,
"declaration": true,
"declarationMap": true,
"baseUrl": "/yd-email",
"paths": {
"@/*": ["src/*"],
"@/components/*": ["src/components/*"],
"@/views/*": ["src/views/*"],
"@/utils/*": ["src/utils/*"],
"@/api/*": ["src/api/*"],
"@/types/*": ["src/types/*"],
"@/stores/*": ["src/stores/*"],
"@/assets/*": ["src/assets/*"]
} }
] },
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts", "src/shims-vue.d.ts"],
"exclude": ["node_modules", "dist"]
} }
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx' import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools' import vueDevTools from 'vite-plugin-vue-devtools'
import { resolve } from 'path'
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
// 关键配置:设置基础路径为子目录 yd-email // 关键配置:设置基础路径为子目录 yd-email
// 生产环境(部署到服务器)用 '/yd-email/',本地开发用 '/'(避免开发时路径错误) base: process.env.NODE_ENV === 'production' ? '/yd-email/' : '/yd-email/',
base: process.env.NODE_ENV === 'production' ? '/yd-email/' : '/',
plugins: [vue(), vueJsx(), vueDevTools()], plugins: [vue(), vueJsx(), vueDevTools()],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)),
'@/components': resolve(__dirname, 'src/components'),
},
},
// 添加CSS配置
css: {
postcss: './postcss.config.js',
},
server: {
port: 5173,
host: 'localhost',
open: true,
proxy: {
'/email/api': {
target: 'http://139.224.145.34:9002',
changeOrigin: true,
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