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>
......
...@@ -8,14 +8,20 @@ ...@@ -8,14 +8,20 @@
"name": "yd-email", "name": "yd-email",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@fortawesome/fontawesome-free": "^7.0.1", "@fortawesome/fontawesome-free": "^7.0.1",
"axios": "^1.12.2",
"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",
...@@ -487,6 +493,27 @@ ...@@ -487,6 +493,27 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime-corejs3": {
"version": "7.28.4",
"resolved": "https://registry.npmmirror.com/@babel/runtime-corejs3/-/runtime-corejs3-7.28.4.tgz",
"integrity": "sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==",
"license": "MIT",
"dependencies": {
"core-js-pure": "^3.43.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz",
...@@ -649,6 +676,24 @@ ...@@ -649,6 +676,24 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.10", "version": "0.25.10",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
...@@ -1267,6 +1312,31 @@ ...@@ -1267,6 +1312,31 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@fortawesome/fontawesome-free": { "node_modules/@fortawesome/fontawesome-free": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmmirror.com/@fortawesome/fontawesome-free/-/fontawesome-free-7.0.1.tgz", "resolved": "https://registry.npmmirror.com/@fortawesome/fontawesome-free/-/fontawesome-free-7.0.1.tgz",
...@@ -1487,6 +1557,17 @@ ...@@ -1487,6 +1557,17 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.7",
"resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz",
"integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.29", "version": "1.0.0-beta.29",
"resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
...@@ -1839,6 +1920,13 @@ ...@@ -1839,6 +1920,13 @@
"@types/deep-eql": "*" "@types/deep-eql": "*"
} }
}, },
"node_modules/@types/date-fns": {
"version": "2.5.3",
"resolved": "https://registry.npmmirror.com/@types/date-fns/-/date-fns-2.5.3.tgz",
"integrity": "sha512-4KVPD3g5RjSgZtdOjvI/TDFkLNUHhdoWxmierdQbDeEg17Rov0hbBYtIzNaQA67ORpteOhvR9YEMTb6xeDCang==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
...@@ -1872,6 +1960,21 @@ ...@@ -1872,6 +1960,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.18.6", "version": "22.18.6",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.18.6.tgz", "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.18.6.tgz",
...@@ -1889,6 +1992,12 @@ ...@@ -1889,6 +1992,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.16",
"resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz",
"integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.44.0", "version": "8.44.0",
"resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz",
...@@ -2746,6 +2855,94 @@ ...@@ -2746,6 +2855,94 @@
} }
} }
}, },
"node_modules/@vueuse/core": {
"version": "9.13.0",
"resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-9.13.0.tgz",
"integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.16",
"@vueuse/metadata": "9.13.0",
"@vueuse/shared": "9.13.0",
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/core/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vueuse/metadata": {
"version": "9.13.0",
"resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.13.0.tgz",
"integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "9.13.0",
"resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-9.13.0.tgz",
"integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==",
"license": "MIT",
"dependencies": {
"vue-demi": "*"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/abbrev": { "node_modules/abbrev": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-2.0.0.tgz",
...@@ -2897,6 +3094,18 @@ ...@@ -2897,6 +3094,18 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.21", "version": "10.4.21",
"resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.21.tgz", "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.21.tgz",
...@@ -2935,6 +3144,17 @@ ...@@ -2935,6 +3144,17 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
...@@ -3064,6 +3284,19 @@ ...@@ -3064,6 +3284,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
...@@ -3207,6 +3440,18 @@ ...@@ -3207,6 +3440,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": { "node_modules/commander": {
"version": "10.0.1", "version": "10.0.1",
"resolved": "https://registry.npmmirror.com/commander/-/commander-10.0.1.tgz", "resolved": "https://registry.npmmirror.com/commander/-/commander-10.0.1.tgz",
...@@ -3257,6 +3502,17 @@ ...@@ -3257,6 +3502,17 @@
"url": "https://github.com/sponsors/mesqueeb" "url": "https://github.com/sponsors/mesqueeb"
} }
}, },
"node_modules/core-js-pure": {
"version": "3.47.0",
"resolved": "https://registry.npmmirror.com/core-js-pure/-/core-js-pure-3.47.0.tgz",
"integrity": "sha512-BcxeDbzUrRnXGYIVAGFtcGQVNpFcUhVjr6W7F8XktvQW2iJP9e66GP6xdKotCRFlrxBvNIBrhwKteRXqMV86Nw==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
...@@ -3319,6 +3575,22 @@ ...@@ -3319,6 +3575,22 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/dayjs": {
"version": "1.11.18",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.18.tgz",
"integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==",
"license": "MIT"
},
"node_modules/de-indent": { "node_modules/de-indent": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz",
...@@ -3411,6 +3683,15 @@ ...@@ -3411,6 +3683,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/didyoumean": { "node_modules/didyoumean": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz",
...@@ -3425,6 +3706,20 @@ ...@@ -3425,6 +3706,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/eastasianwidth": { "node_modules/eastasianwidth": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
...@@ -3487,6 +3782,32 @@ ...@@ -3487,6 +3782,32 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/element-plus": {
"version": "2.11.3",
"resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.11.3.tgz",
"integrity": "sha512-769xsjLR4B9Vf9cl5PDXnwTEdmFJvMgAkYtthdJKPhjVjU3hdAwTJ+gXKiO+PUyo2KWFwOYKZd4Ywh6PHfkbJg==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.1",
"@element-plus/icons-vue": "^2.3.1",
"@floating-ui/dom": "^1.0.1",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
"@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6",
"@vueuse/core": "^9.1.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.13",
"escape-html": "^1.0.3",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lodash-unified": "^1.0.2",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz",
...@@ -3516,6 +3837,24 @@ ...@@ -3516,6 +3837,24 @@
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
...@@ -3523,6 +3862,33 @@ ...@@ -3523,6 +3862,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.10", "version": "0.25.10",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.10.tgz", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.10.tgz",
...@@ -3575,6 +3941,12 @@ ...@@ -3575,6 +3941,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
...@@ -4104,6 +4476,26 @@ ...@@ -4104,6 +4476,26 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz",
...@@ -4121,6 +4513,22 @@ ...@@ -4121,6 +4513,22 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz",
...@@ -4154,7 +4562,6 @@ ...@@ -4154,7 +4562,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
...@@ -4170,6 +4577,43 @@ ...@@ -4170,6 +4577,43 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-stream": { "node_modules/get-stream": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz", "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz",
...@@ -4234,6 +4678,18 @@ ...@@ -4234,6 +4678,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graphemer": { "node_modules/graphemer": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz",
...@@ -4251,11 +4707,37 @@ ...@@ -4251,11 +4707,37 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
...@@ -4828,6 +5310,29 @@ ...@@ -4828,6 +5310,29 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz",
...@@ -4861,6 +5366,21 @@ ...@@ -4861,6 +5366,21 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/memorystream": { "node_modules/memorystream": {
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmmirror.com/memorystream/-/memorystream-0.3.1.tgz", "resolved": "https://registry.npmmirror.com/memorystream/-/memorystream-0.3.1.tgz",
...@@ -4894,6 +5414,27 @@ ...@@ -4894,6 +5414,27 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz",
...@@ -5030,6 +5571,12 @@ ...@@ -5030,6 +5571,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/npm-normalize-package-bin": { "node_modules/npm-normalize-package-bin": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
...@@ -5727,6 +6274,12 @@ ...@@ -5727,6 +6274,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
...@@ -6558,6 +7111,12 @@ ...@@ -6558,6 +7111,12 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
...@@ -7241,6 +7800,17 @@ ...@@ -7241,6 +7800,17 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/wangeditor": {
"version": "4.7.15",
"resolved": "https://registry.npmmirror.com/wangeditor/-/wangeditor-4.7.15.tgz",
"integrity": "sha512-aPTdREd8BxXVyJ5MI+LU83FQ7u1EPd341iXIorRNYSOvoimNoZ4nPg+yn3FGbB93/owEa6buLw8wdhYnMCJQLg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.11.2",
"@babel/runtime-corejs3": "^7.11.2",
"tslib": "^2.1.0"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
......
...@@ -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
<template>
<div class="bg-white rounded-lg shadow-md p-6" v-loading="loading">
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">发件人</label>
<el-select
v-model="currentSender"
value-key="senderBizId"
filterable
remote
reserve-keyword
placeholder="请输入发件人姓名或邮箱搜索"
:remote-method="(query) => remoteSearch(query, 'senders')"
:loading="loading"
size="large"
class="w-full"
@change="setDefaultSignature"
>
<el-option
v-for="sender in senders"
:key="sender.senderBizId"
:label="sender.email"
:value="sender"
/>
</el-select>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">变量模板</label>
<div class="flex flex-col sm:flex-row gap-2">
<el-select
v-model="selectedVariableTemplate"
value-key="variableGroupBizId"
filterable
remote
reserve-keyword
placeholder="选择模板"
:remote-method="(query) => remoteSearch(query, 'groups')"
:loading="loading"
size="large"
class="flex-auto"
@change="applyVariableTemplate"
>
<el-option value="">-- 选择模板 --</el-option>
<el-option
v-for="template in groups"
:key="template.variableGroupBizId"
:value="template"
:label="template.groupName"
/>
</el-select>
<!-- 当选择模版有值时,显示导入数据按钮 -->
<button
@click="((showImportContacts = true), (importSource = 1))"
class="bg-blue-50 hover:bg-blue-100 text-blue-600 px-4 py-2 rounded-md border border-blue-200 transition-colors flex-none items-center"
v-if="selectedVariableTemplate"
>
<i class="fas fa-address-book mr-1"></i> 导入数据
</button>
</div>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">收件人</label>
<div class="flex flex-col sm:flex-row gap-2">
<!-- 多个邮箱用tag的样式展示 -->
<div
v-if="emailForm.receiveEmail"
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"
>
<span
v-for="email in emailForm.receiveEmail.split(',').filter((e) => e.trim())"
:key="email"
class="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium"
>
{{ email.trim() }}
</span>
</div>
<div
v-else
class="flex-1 px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
暂无收件人
</div>
<!-- 当选择模版有值时,不显示选择联系人按钮 -->
<button
@click="((showContactSelector = true), (importSource = 0))"
class="bg-blue-50 hover:bg-blue-100 text-blue-600 px-4 py-2 rounded-md border border-blue-200 transition-colors flex items-center"
>
<i class="fas fa-address-book mr-1"></i> 选择联系人
</button>
<button
@click="showImportRecordManager = true"
class="bg-green-50 hover:bg-green-100 text-green-600 px-4 py-2 rounded-md border border-green-200 transition-colors flex items-center"
v-if="importRecords.length > 0"
>
<i class="fas fa-cog mr-1"></i> 编辑数据
</button>
</div>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">抄送人</label>
<div
v-if="emailForm.ccEmails"
class="p-3 border border-gray-300 rounded-md bg-gray-50 min-h-[42px]"
>
<div class="flex flex-wrap gap-2">
<div
v-for="(tag, index) in emailForm.ccEmails.split(',').filter((e) => e.trim())"
:key="index"
class="inline-flex flex-wrap items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium"
>
<span
v-for="subtag in tag
.trim()
.split(';')
.filter((e) => e.trim())"
:key="subtag"
class="bg-orange-100 text-orange-800 px-2 py-0.5 rounded-full"
>
{{ subtag.trim() }}
</span>
</div>
</div>
</div>
<div
v-else
class="flex items-center p-3 border border-gray-300 rounded-md bg-gray-50 text-gray-500 min-h-[42px]"
>
暂无抄送人
</div>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">主题</label>
<input
v-model="emailForm.subject"
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 class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">正文</label>
<div class="border border-gray-300 rounded-md overflow-hidden">
<div class="bg-gray-50 p-2 border-b border-gray-300 flex flex-wrap gap-2">
<button
@click="showVariableSelector = true"
class="text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 px-3 py-1 rounded border border-blue-200 transition-colors"
>
<i class="fas fa-plus"></i> 插入字段
</button>
</div>
<textarea
v-model="emailForm.content"
class="w-full p-3 min-h-[200px] focus:outline-none"
placeholder="请输入邮件内容..."
></textarea>
</div>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">附件</label>
<div class="mb-10 p-6 bg-white rounded-lg shadow-md">
<FileUploadComponent
v-model="uploadedFiles"
:config="uploadConfig"
@success="handleDocumentUploadSuccess"
/>
</div>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">签名</label>
<div class="mb-10 p-6 bg-white rounded-lg shadow-md">
<RichTextEditor v-model="emailForm.customContent" />
</div>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">发送设置</label>
<div class="flex items-center">
<input type="checkbox" id="scheduleSend" v-model="emailForm.scheduleSend" class="mr-2" />
<label for="scheduleSend" class="mr-4">定时发送</label>
<div v-if="emailForm.scheduleSend" class="flex items-center">
<input
type="datetime-local"
v-model="emailForm.scheduleTime"
class="px-3 py-1 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button
@click="sendSelfEmail"
class="px-6 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
>
测试发送
</button>
<button
@click="sendEmail"
class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700 transition-colors"
>
发送
</button>
</div>
<!-- 编辑数据管理弹窗 -->
<ImportRecordManager
v-if="showImportRecordManager"
:records="importRecords"
@update-record="updateImportRecord"
@delete-record="deleteImportRecord"
@close="showImportRecordManager = false"
/>
<!-- 导入数据弹窗 -->
<ImportDialog
v-model:visible="showImportContacts"
title="导入数据"
accept=".csv,.txt,.xlsx"
@error="handleImportError"
@confirm="handleImportContacts"
@close="showImportContacts = false"
@cancel="showImportContacts = false"
/>
<!-- 联系人选择弹窗 -->
<ContactSelector
v-if="showContactSelector"
:contacts="contacts"
@confirm-selection="confirmContactSelection"
@query-change="remoteSearch($event, 'contacts')"
@close="showContactSelector = false"
@cancel="showContactSelector = false"
/>
<!-- 变量选择弹窗 -->
<VariableSelector
v-if="showVariableSelector"
:variables="variables"
@insert-variable="insertVariable"
@close="showVariableSelector = false"
/>
<!-- 发送邮件弹窗 -->
<el-dialog v-model="dialogVisible" title="提示" width="500" :z-index="99999">
<span>邮件发送任务已提交,请前往邮件记录中查看发送状态</span>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">暂不前往</el-button>
<!-- 路由导航到邮件记录 -->
<el-button type="primary" @click="navigateToEmailRecords"> 前往邮件记录 </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import { Signature } from '@/types/signature'
import { useRouter } from 'vue-router'
import RichTextEditor from '@/components/RichTextEditor.vue'
const router = useRouter()
const navigateToEmailRecords = () => {
router.push({ name: 'emails' })
}
import ContactSelector from './ContactSelector.vue'
import VariableSelector from './VariableSelector.vue'
import ImportRecordManager from './ImportRecordManager.vue'
import ImportDialog from './ImportDialog.vue'
import type { Sender, Contact, Variable, VariableTemplate, ImportRecord, EmailForm } from '../types'
// 引入api接口,获取联系人列表、发件人列表、变量模版列表
import {
senderApi,
variableGroupApi,
contactApi,
sendEmailApi,
variableApi,
importContactApi,
} from '../api/api'
const dialogVisible = ref(false)
const uploadConfig = {
multiple: true,
}
// 远程搜索方法
const remoteSearch = async (query: string, type: string) => {
console.log(query, type)
if (query) {
loading.value = true
try {
if (type === 'senders') {
const res = await senderApi.getEmailSenderConfigList({
queryContent: query,
pageNo: 1,
pageSize: 100,
})
senders.value = res.data?.records || []
} else if (type === 'groups') {
const res = await variableGroupApi.getEmailVariableGroupList({
queryContent: query,
pageNo: 1,
pageSize: 100,
})
groups.value = res.data?.records || []
} else if (type === 'contacts') {
const res = await contactApi.getContactList({
queryContent: query,
pageNo: 1,
pageSize: 100,
})
contacts.value = res.data?.records || []
}
} catch (error) {
console.error('搜索失败:', error)
ElMessage.error('搜索失败')
} finally {
loading.value = false
}
} else {
// 如果查询为空,重新加载默认选项
if (type === 'contacts') {
getContacts()
} else if (type === 'groups') {
getGroups()
} else if (type === 'senders') {
getSenders()
}
}
}
// 初始化数据
const getSenders = () => {
senderApi
.getEmailSenderConfigList({
pageNo: 1,
pageSize: 100,
})
.then((res) => {
senders.value = res.data?.records || []
})
}
const getContacts = () => {
contactApi
.getContactList({
pageNo: 1,
pageSize: 100,
})
.then((res) => {
contacts.value = res.data?.records || []
})
}
const getGroups = () => {
variableGroupApi
.getEmailVariableGroupList({
pageNo: 1,
pageSize: 100,
})
.then((res) => {
groups.value = res.data?.records || []
})
}
const importSource = ref(0)
const senders = ref<Sender[]>([])
const contacts = ref<Contact<unknown>[]>([])
const groups = ref<VariableTemplate[]>([])
const variables = ref<Variable[]>([])
const currentSender = ref()
const emailForm = ref<EmailForm>({
senderBizId: '',
sendEmail: '',
variableGroupBizId: '',
receiveEmail: '',
ccEmailList: [],
subject: '',
content: '',
attachmentPath: '',
sessionId: '',
ccEmails: '',
signatureId: '',
customContent: '',
})
const selectedVariableTemplate = ref<VariableTemplate | null>(null)
const showContactSelector = ref(false)
const showVariableSelector = ref(false)
const showImportContacts = ref(false)
const showImportRecordManager = ref(false)
const importRecords = ref<ImportRecord[]>([])
// 监听收件人变化,自动匹配抄送人
watch(
() => emailForm.value.receiveEmail,
(newReceiveEmail) => {
if (newReceiveEmail) {
const matchedRecord = importRecords.value.find((record) =>
record.receiveEmailList?.includes(newReceiveEmail),
)
if (matchedRecord && matchedRecord.ccEmailList) {
emailForm.value.ccEmailList = matchedRecord.ccEmailList || []
}
}
},
)
// 挂载时,加载获取基础数据接口
onMounted(() => {
getSenders()
getContacts()
getGroups()
applyVariableTemplate()
})
const loading = ref(false)
const variablePrefix = '{{'
const variableNextfix = '}}'
// 通过变量模版查询变量列表
const applyVariableTemplate = () => {
variableApi
.getEmailVariableList({
isGeneral: 1,
})
.then((res) => {
variables.value = res.data?.records || []
})
if (selectedVariableTemplate.value?.variableGroupBizId) {
variableGroupApi
.getEmailVariableGroupDetail(selectedVariableTemplate.value.variableGroupBizId)
.then((res) => {
variables.value = res.data?.emailVariableDtoList.concat(variables.value) || []
})
}
}
const setDefaultSignature = () => {
emailForm.value.customContent = setSignaturePreview(currentSender.value)
}
// 插入变量方法
const insertVariable = (variable: Variable) => {
// 支持多选变量,循环添加选中变量
const variablesToInsert = Array.isArray(variable) ? variable : [variable]
variablesToInsert.forEach((v) => {
const variableText = `${variablePrefix}${v.variableNameEn}${variableNextfix}`
// 在光标位置插入变量,如果没有光标则添加到末尾
const textarea = document.querySelector('textarea[name="content"]') as HTMLTextAreaElement
if (textarea && document.activeElement === textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentValue = emailForm.value.content || ''
emailForm.value.content =
currentValue.substring(0, start) + variableText + currentValue.substring(end)
// 设置光标位置到插入内容之后
const newCursorPos = start + variableText.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
} else {
// 如果没有焦点在textarea,则添加到末尾
emailForm.value.content += variableText
}
})
showVariableSelector.value = false
}
// 确认选择收件人
const confirmContactSelection = (selected: Contact<unknown>[]) => {
emailForm.value.ccEmailList = selected.map((contact) => contact.email || '')
const params = {
sessionId: '',
apiEmailContactDtoList: selected,
}
showContactSelector.value = false
importContactApi.getEmailContactSessionId(params).then((res) => {
if (res.code === 200) {
emailForm.value.sessionId = res.data?.sessionId || ''
if (res.data?.sessionId) {
importContactApi.getEmailContactImportDetail(res.data?.sessionId || '').then((res) => {
if (res.code === 200) {
emailForm.value.receiveEmail = res.data?.receiveEmails || ''
emailForm.value.ccEmails = res.data?.ccEmails || ''
}
})
}
getImportedContacts(emailForm.value.sessionId || '', '')
}
})
}
const handleImportError = (error: string) => {
ElMessage.error(error)
}
// 编辑数据记录
const updateImportRecord = (updatedRecord: ImportRecord) => {
const params = {
...updatedRecord,
ccEmailList: updatedRecord.ccEmail.split(';') || '',
ccEmail: undefined,
}
importContactApi.editEmailContactImport(params).then((res) => {
if (res.code === 200) {
ElMessage({
message: '导入记录更新成功',
type: 'success',
})
// 更新成功之后,刷新导入记录列表
getImportedContacts(emailForm.value.sessionId || '', 'update')
} else {
ElMessage({
message: '导入记录更新失败',
type: 'error',
})
}
})
}
// 删除导入记录
const deleteImportRecord = (importBizId: string) => {
importContactApi.deleteEmailContactImport(importBizId).then((res) => {
if (res.code === 200) {
ElMessage({
message: '导入记录删除成功',
type: 'success',
})
// 删除成功之后,刷新导入记录列表
getImportedContacts(emailForm.value.sessionId || '', 'update')
} else {
ElMessage({
message: res.msg || '导入记录删除失败',
type: 'error',
})
}
})
}
// 导入数据
const handleImportContacts = (results) => {
console.log('导入数据:', results)
if (results.data) {
showImportContacts.value = false
emailForm.value.sessionId = results.data.data.sessionId || ''
getImportedContacts(emailForm.value.sessionId || '', 'update')
}
}
// 发送邮件
const sendEmail = () => {
// 构建完整的邮件内容(正文 + 签名)
const signatureContent = emailForm.value.customContent
? emailForm.value.customContent
: setSignaturePreview(currentSender.value)
const fullContent = `${emailForm.value.content}\n\n${signatureContent}`
const params = {
...emailForm.value,
content: fullContent,
variableGroupBizId: selectedVariableTemplate.value?.variableGroupBizId || '',
senderBizId: currentSender.value?.senderBizId,
sendEmail: currentSender.value?.email || '',
}
console.log('发送邮件参数:', params)
loading.value = true
try {
// 确认发送邮件
sendEmailApi.sendEmail(params).then((res) => {
loading.value = false
if (res.code === 200) {
dialogVisible.value = true
// 发送成功之后,清空表单
emailForm.value = {
receiveEmail: '',
ccEmailList: [],
content: '',
attachmentPath: '',
sessionId: '',
}
} else {
ElMessage({
message: res.msg || '邮件发送失败,请重新提交',
type: 'error',
})
}
})
} catch (error) {
loading.value = false
ElMessage({
message: '邮件发送失败,请重新提交',
type: 'error',
})
}
}
// 通过sessionId获取导入的联系人
const getImportedContacts = (sessionId?: string, type?: string) => {
const params = {
sessionId: sessionId || emailForm.value.sessionId || '',
source: importSource.value,
}
importContactApi.getEmailContactImportList(params).then((res) => {
if (res.code === 200) {
console.log('导入的联系人:', res.data)
importRecords.value = res.data.records || []
// 从导入记录中提取收件人和抄送人
if (type == 'update') {
console.log(importRecords.value)
emailForm.value.receiveEmail =
importRecords.value.length > 0
? importRecords.value.map((item) => item.receiveEmail || '').join(',')
: ''
emailForm.value.ccEmails =
importRecords.value.length > 0
? importRecords.value.map((item) => item.ccEmail || '').join(',')
: ''
}
console.log(emailForm.value)
}
})
}
/**
* 文件上传配置
*/
// 上传附件
import { ElMessage } from 'element-plus'
import FileUploadComponent from '@/components/FileUploadComponent.vue'
import { UploadResult } from '@/utils/fileUpload'
// 上传成功的文件
const uploadedFiles = ref<any[]>([])
// 处理文档上传成功
const handleDocumentUploadSuccess = (results: UploadResult[]) => {
const successCount = results.filter((r) => r.success).length
// 发送邮件时,接口入参attachmentPath,填写附件路径,多个有分号隔开,路径在result里面的data,里面的accessUrl
// 过滤出成功上传的文件
const successFiles = results.filter((r) => r.success)
const attachmentPath = successFiles.map((r) => r.data?.data.url || '').join(';')
emailForm.value.attachmentPath = attachmentPath
ElMessage.success(`成功上传 ${successCount} 个文档`)
}
// 获取签名预览HTML
const setSignaturePreview = (currentSender): string => {
if (currentSender) {
const config = {
showCompanyName: true,
showLogo: true,
showName: true,
showPhone: true,
showEmail: true,
showAddress: true,
name: currentSender.displayName,
logoUrl: 'https://m.zuihuibi.cn/ydLife/assets/images/ydinsurance_logo.png',
companyName: 'Yindun Insurance Brokers Co..Ltd',
phone: currentSender.phone || '',
email: currentSender.email || '',
address:
'Room 3063, Tower No.8 Shuang Xing , No 1 Weigongcun Ave, Haidian District, Beijing. P.R. China',
}
let html =
'<div style="font-family: Arial, sans-serif; line-height: 1.8; color: #333;"><p style="margin: 15px 0; font-weight: 500;">Best Regards</p><p style="margin: 15px 0; border:1px solid #ccc;"></p></div>'
if (config.showName) {
html += `<p style="margin: 0; font-weight: bold; font-size: 14px;">${config.name}`
html += '</p>'
}
if (config.showLogo && config.logoUrl) {
html += `<img src="${config.logoUrl}" alt="公司LOGO" style="width: 120px; height: auto; margin: 8px 0;" />`
}
if (config.showCompanyName && config.companyName) {
html += `<p style="margin: 0; font-size: 13px;">${config.companyName}</p>`
}
if (config.showPhone && config.phone) {
html += `<p style="margin: 0; font-size: 12px;">M:+86 ${config.phone}</p>`
}
if (config.showEmail && config.email) {
html += `<p style="margin: 0; font-size: 12px;">E:<a href="mailto:${config.email}" style="color: #409eff; text-decoration: none;">${config.email}</a></p>`
}
if (config.showAddress && config.address) {
html += `<p style="margin: 0; font-size: 12px;">A:${config.address}</p>`
}
html += '</div>'
return html
}
}
// 测试发送邮件
const sendSelfEmail = () => {
// 构建完整的邮件内容(正文 + 签名)
const signatureContent = emailForm.value.customContent
? emailForm.value.customContent
: setSignaturePreview(currentSender.value)
const fullContent = `${emailForm.value.content}\n\n${signatureContent}`
const params = {
...emailForm.value,
content: fullContent,
variableGroupBizId: selectedVariableTemplate.value?.variableGroupBizId || '',
senderBizId: currentSender.value?.senderBizId,
sendEmail: currentSender.value?.email || '',
}
console.log(params)
// 确认发送邮件
sendEmailApi.testSendEmail(params).then((res) => {
if (res.code === 200) {
ElMessage({
message: '测试邮件发送成功',
type: 'success',
})
} else {
ElMessage.error(`测试邮件发送失败: ${res.msg || '未知错误'}`)
}
})
}
</script>
<style scoped>
.email-compose-container {
@apply bg-gray-50 min-h-[800px];
}
.email-content-editor {
@apply font-sans;
}
.signature-preview {
@apply bg-gray-50 p-3 rounded;
}
.signature-content {
line-height: 1.8;
}
</style>
<template>
<div>
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6 gap-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="flex flex-col md:flex-row md:items-center md:justify-between mb-6 gap-4">
<div class="flex gap-3">
<button @click="showImportModal = true" class="btn-primary flex items-center">
<i class="fas fa-upload mr-2"></i> 批量导入
</button>
<!-- 下载模版 -->
<a
href="https://yd-ali-oss.oss-cn-shanghai-finance-1-pub.aliyuncs.com/xlsx/2025/10/09/2b0ec7e9ab4443fb8530131c001b9a3d.xlsx"
download="contact_template.xlsx"
class="flex items-center text-blue-700"
>
下载模版
</a>
<button @click="openAddContactModal()" class="btn-outline flex items-center">
<i class="fas fa-plus mr-2"></i> 新增联系人
</button>
</div>
</div>
</div>
<!-- 搜索和筛选区域 -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<div class="flex flex-col md:flex-row gap-4">
<div class="relative flex-1">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
v-model="searchQuery.name"
type="text"
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg"
placeholder="搜索联系人姓名..."
/>
</div>
<!-- 搜索公司 -->
<div class="relative flex-1">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
v-model="searchQuery.companyName"
type="text"
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg"
placeholder="搜索公司..."
/>
</div>
<!-- 搜索邮箱 -->
<div class="relative flex-1">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
v-model="searchQuery.email"
type="text"
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg"
placeholder="搜索邮箱..."
/>
</div>
<div class="flex gap-3 w-full md:w-auto">
<select
v-model="sortBy"
class="w-full md:w-auto px-4 py-2 border border-gray-300 rounded-lg"
>
<option value="name">按姓名排序</option>
<option value="companyName">按公司排序</option>
<option value="email">按邮箱排序</option>
</select>
</div>
<button @click="resetSearch" class="btn-outline px-4">
<i class="fas fa-sync mr-2"></i>重置
</button>
<!-- 搜索按钮 -->
<button @click="searchContacts" class="btn-primary px-4 flex items-center">
<i class="fas fa-search mr-2"></i> 搜索
</button>
</div>
</div>
<!-- 联系人列表 -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<el-table :data="filteredContacts" max-height="500" style="width: 100%" table-layout="auto">
<el-table-column label="姓名">
<template #default="scope">
<div style="display: flex; align-items: center">
<div
class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 mr-3"
>
<span class="text-sm font-medium">{{ scope.row.name?.charAt(0) || '-' }}</span>
</div>
<span style="margin-left: 10px">{{ scope.row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="title" label="称谓" />
<el-table-column prop="companyName" label="公司" />
<el-table-column prop="email" label="收件人邮箱" />
<el-table-column label="抄送人邮箱">
<template #default="scope">
<div class="flex flex-wrap gap-1">
<span
v-for="(email, index) in scope.row.ccEmailList || []"
:key="index"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
{{ email }}
</span>
<span v-if="scope.row.ccEmailList?.length === 0" class="text-sm text-gray-500"
>-</span
>
</div>
</template>
</el-table-column>
<el-table-column prop="other" label="其他信息" />
<el-table-column label="操作">
<template #default="scope">
<el-button size="small" @click="editContactModal(scope.row)"> 编辑 </el-button>
<el-button size="small" type="danger" @click="deleteContactModal(scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页组件 -->
<Pagination
:total="total"
:current="currentPage"
:page-size="pageSize"
@change="handlePageChange"
@update:current="handleCurrentUpdate"
@update:page-size="handlePageSizeUpdate"
/>
</div>
<!-- 联系人模态框 -->
<div
v-if="showContactModal"
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-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">
{{ isEditing ? '编辑联系人' : '新建联系人' }}
</h3>
<button @click="closeContactModal" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-4 flex-1 overflow-y-auto">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 姓名 -->
<div class="md:col-span-2">
<label class="block text-gray-700 mb-1 text-sm">姓名 *</label>
<input
v-model="form.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"
placeholder="请输入联系人姓名"
/>
<p v-if="errors.name" class="text-red-500 text-xs mt-1">{{ errors.name }}</p>
</div>
<!-- 联系人类型 -->
<div>
<label class="block text-gray-700 mb-1 text-sm">联系人类型</label>
<select
v-model="form.type"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">请选择类型</option>
<option value="customer">客户</option>
<option value="partner">合作伙伴</option>
<option value="supplier">供应商</option>
<option value="internal">内部员工</option>
<option value="other">其他</option>
</select>
</div>
<!-- 称谓 -->
<div>
<label class="block text-gray-700 mb-1 text-sm">称谓</label>
<input
v-model="form.appellation"
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 class="md:col-span-2">
<label class="block text-gray-700 mb-1 text-sm">公司</label>
<input
v-model="form.companyName"
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 class="md:col-span-2">
<label class="block text-gray-700 mb-1 text-sm">收件人邮箱 *</label>
<input
v-model="form.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="example@company.com"
/>
<p v-if="errors.email" class="text-red-500 text-xs mt-1">{{ errors.email }}</p>
</div>
<!-- 抄送人邮箱 -->
<div class="md:col-span-2">
<label class="block text-gray-700 mb-1 text-sm">抄送人邮箱</label>
<div class="flex gap-2 mb-2">
<input
v-model="newCcEmail"
type="email"
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="添加抄送邮箱"
@keyup.enter="addCcEmail"
/>
<button
@click="addCcEmail"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700"
>
添加
</button>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(email, index) in form.ccEmailList"
:key="index"
class="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
>
{{ email }}
<button
@click="removeCcEmail(index)"
class="ml-2 text-blue-600 hover:text-blue-900"
>
<i class="fas fa-times"></i>
</button>
</span>
</div>
</div>
<!-- 其他信息 -->
<div class="md:col-span-2">
<label class="block text-gray-700 mb-1 text-sm">其他信息</label>
<textarea
v-model="form.other"
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="3"
placeholder="备注信息"
></textarea>
</div>
</div>
</div>
<div class="p-4 border-t border-gray-200 flex justify-end gap-3">
<button
@click="closeContactModal"
class="px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
取消
</button>
<button
@click="saveContact"
:disabled="isSubmitting"
class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700 transition-colors"
>
{{ isSubmitting ? '保存中...' : isEditing ? '更新' : '保存' }}
</button>
</div>
</div>
</div>
<CommonModal
v-model:visible="modalVisible"
:trigger-key="modalConfig.triggerKey"
:title="modalConfig.title"
type="confirm"
:message="modalConfig.message"
:show-cancel-button="modalConfig.showCancel"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
<!-- 导入联系人 -->
<ImportDialog
v-model:visible="showImportModal"
:trigger-key="'importContactModal'"
:uploadUrl="uploadUrl"
:title="'导入联系人'"
@success="handleImportSuccess"
@error="handleImportError"
@cancel="handleImportCancel"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
// 引入我们创建的api拦截器
import { contactApi } from '@/api/api'
import type { Contact } from '@/types/index'
// 引入分页组件
import Pagination from '@/components/Pagination.vue'
import ImportDialog from '@/views/ImportDialog.vue'
// 初始数据
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const uploadUrl = `${import.meta.env.VITE_REMOTE_API_BASE_URL}/email/api/emailFile/import/excel/contact`
// 处理分页变化
const handlePageChange = (page: number, size: number) => {
console.log('分页变化:', page, size)
fetchContacts()
// 这里可以发起API请求获取新数据
}
const handleCurrentUpdate = (page: number) => {
currentPage.value = page
}
const handlePageSizeUpdate = (size: number) => {
pageSize.value = size
currentPage.value = 1 // 重置到第一页
}
const handleImportSuccess = (file: File) => {
console.log('导入成功:', file)
showImportModal.value = false
openModal({
triggerKey: '',
title: '成功',
message: '联系人导入成功',
})
// 刷新联系人列表
fetchContacts()
}
const handleImportCancel = (triggerKey: string) => {
console.log('导入取消:', triggerKey)
showImportModal.value = false
}
const handleImportError = (error: string) => {
console.log('导入错误:', error)
showImportModal.value = false
openModal({
triggerKey: '',
title: '错误',
message: error,
})
}
// 引入弹窗组件
import CommonModal from '@/components/CommonModal.vue'
// 弹窗提示信息对象
const modalVisible = ref(false)
const modalConfig = ref({
showCancel: false,
title: '操作确认',
message: '确定要执行此操作吗?',
triggerKey: 'templateModal',
})
const openModal = (
config: { triggerKey?: string; showCancel?: boolean; title?: string; message?: string } = {},
) => {
modalConfig.value = {
showCancel: config.showCancel ?? false,
title: config.title ?? '操作确认',
message: config.message ?? '确定要执行此操作吗?',
triggerKey: config.triggerKey ?? modalConfig.value.triggerKey,
}
modalVisible.value = true
}
const handleConfirm = (triggerKey: string) => {
modalVisible.value = false
console.log('用户确认操作', triggerKey)
if (triggerKey === 'deleteContactModal' && form.value.contactBizId) {
contactApi.deleteContact(form.value.contactBizId as string).then((res) => {
if (res.code === 200) {
fetchContacts()
openModal({
triggerKey: '',
title: '成功',
message: '联系人删除成功',
})
} else {
openModal({
triggerKey: '',
title: '错误',
message: res.msg || '联系人删除失败',
})
}
})
} else if (triggerKey === 'importContactModal') {
showImportModal.value = true
console.log('确认')
}
}
const handleCancel = (triggerKey: string) => {
modalVisible.value = false
console.log('用户取消操作', triggerKey)
}
const editingSenderId = ref('')
// 页面状态
const contacts = ref<Contact[]>([])
const searchQuery = ref({
name: '',
companyName: '',
email: '',
})
const sortBy = ref('name')
const isLoading = ref(false)
// 模态框状态
const showContactModal = ref(false)
const isEditing = ref(false)
const showImportModal = ref(false)
// 表单数据
const form = ref<Partial<Contact>>({
name: '',
type: '',
companyName: '',
email: '',
ccEmailList: [],
other: '',
appellation: '',
})
const newCcEmail = ref('')
// 表单错误
const errors = ref({
name: '',
email: '',
})
const resetForm = () => {
form.value = {
name: '',
type: '',
companyName: '',
email: '',
ccEmailList: [],
other: '',
appellation: '',
}
newCcEmail.value = ''
errors.value = { name: '', email: '' }
isEditing.value = false
showContactModal.value = true
}
const isSubmitting = ref(false)
// 初始化页面
onMounted(() => {
fetchContacts()
})
// 过滤联系人
const filteredContacts = computed<Contact[]>(() => {
return contacts.value
})
// 获取联系人列表
const fetchContacts = async () => {
try {
isLoading.value = true
const data = await contactApi.getContactList({
companyName: searchQuery.value.companyName,
name: searchQuery.value.name,
email: searchQuery.value.email,
pageNo: currentPage.value,
pageSize: pageSize.value,
sortField: sortBy.value,
sortOrder: 'asc',
})
contacts.value = data.data.records
total.value = data.data.total
currentPage.value = data.data.current
pageSize.value = data.data.size
} catch (error) {
console.error('获取联系人失败:', error)
openModal({
title: '获取联系人失败',
message: '获取联系人失败,请稍后重试',
})
} finally {
isLoading.value = false
}
}
// 搜索联系人
const searchContacts = () => {
currentPage.value = 1
fetchContacts()
}
// 重置搜索
const resetSearch = () => {
searchQuery.value = {
name: '',
companyName: '',
email: '',
}
sortBy.value = 'name'
currentPage.value = 1
fetchContacts()
}
// 打开添加联系人模态框
const openAddContactModal = () => {
form.value = {
name: '',
type: '',
companyName: '',
email: '',
ccEmailList: [],
other: '',
appellation: '',
}
newCcEmail.value = ''
errors.value = { name: '', email: '' }
isEditing.value = false
showContactModal.value = true
}
// 关闭联系人模态框
const closeContactModal = () => {
showContactModal.value = false
}
// 编辑联系人
const editContactModal = (contact: Contact) => {
form.value = { ...contact }
newCcEmail.value = ''
errors.value = { name: '', email: '' }
isEditing.value = true
showContactModal.value = true
}
// 验证表单
const validateForm = (): boolean => {
let isValid = true
errors.value = { name: '', email: '' }
if (!form.value.name?.trim()) {
errors.value.name = '请输入联系人姓名'
isValid = false
}
if (!form.value.email?.trim()) {
errors.value.email = '请输入收件人邮箱'
isValid = false
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.value.email)) {
errors.value.email = '请输入有效的邮箱地址'
isValid = false
}
return isValid
}
// 添加抄送人邮箱
const addCcEmail = () => {
const email = newCcEmail.value.trim()
if (!email) return
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
showError('请输入有效的邮箱地址')
return
}
// 检查是否已存在
if (form.value.ccEmailList?.includes(email)) {
showError('该邮箱已在抄送人列表中')
return
}
form.value.ccEmailList = [...(form.value.ccEmailList || []), email]
newCcEmail.value = ''
}
// 移除抄送人邮箱
const removeCcEmail = (index: number) => {
if (form.value.ccEmailList) {
form.value.ccEmailList.splice(index, 1)
}
}
// 保存联系人
const saveContact = async () => {
if (!validateForm()) return
try {
isSubmitting.value = true
const contactData = {
contactBizId: form.value.contactBizId || undefined,
type: form.value.type,
name: form.value.name,
companyName: form.value.companyName,
email: form.value.email,
ccEmailList: form.value.ccEmailList,
other: form.value.other,
appellation: form.value.appellation,
}
if (isEditing.value && form.value.contactBizId) {
// 更新现有联系人
contactApi.updateContact({ ...contactData }).then((res) => {
if (res.code === 200) {
openModal({
title: '更新联系人成功',
message: '联系人已更新',
})
closeContactModal()
fetchContacts()
} else {
openModal({
title: '更新联系人失败',
message: res.msg || '保存联系人失败',
})
}
})
} else {
// 添加新联系人
await contactApi.addContact(contactData).then((res) => {
if (res.code === 200) {
openModal({
title: '添加联系人成功',
message: '联系人已添加',
})
closeContactModal()
fetchContacts()
} else {
openModal({
title: '添加联系人失败',
message: res.msg || '保存联系人失败',
})
}
})
}
} catch (error) {
console.error('保存联系人失败:', error)
showError(error instanceof Error ? error.message : '保存联系人失败,请稍后重试')
} finally {
isSubmitting.value = false
}
}
// 删除联系人 - 使用api拦截器
const deleteContactModal = async (contact: Contact) => {
form.value = contact
if (form.value) {
openModal({
triggerKey: 'deleteContactModal',
title: '删除联系人',
message: `确定要删除联系人 ${form.value.name} 吗?`,
})
}
}
</script>
<style scoped>
/* 样式与之前保持一致 */
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-up {
animation: slideUp 0.3s ease-out forwards;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
.btn-primary {
@apply bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors font-medium;
}
.btn-outline {
@apply border border-gray-300 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors font-medium;
}
tbody tr:hover {
@apply bg-gray-50;
}
.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-blue-100.text-blue-800 {
@apply transition-all;
}
.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-blue-100.text-blue-800:hover {
@apply bg-blue-200;
}
@media (min-width: 768px) {
.max-w-7xl {
max-width: 1140px;
}
}
@media (min-width: 1200px) {
.max-w-7xl {
max-width: 1320px;
}
}
</style>
...@@ -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>
...@@ -20,11 +20,24 @@ ...@@ -20,11 +20,24 @@
class="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="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
> >
<option value="">全部状态</option> <option value="">全部状态</option>
<option value="sent">已发送</option> <option v-for="item in statusOptions" :key="item.itemValue" :value="item.itemValue">
<option value="scheduled">已定时</option> {{ item.itemLabel }}
<option value="draft">草稿</option> </option>
<option value="failed">发送失败</option>
</select> </select>
<!-- 重置按钮 -->
<button
@click="resetSearch"
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-400 transition-colors"
>
<i class="fas fa-sync mr-2"></i>重置
</button>
<!-- 搜索按钮 -->
<button
@click="getEmailTaskMainList"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<i class="fas fa-search mr-2"></i> 搜索
</button>
</div> </div>
</div> </div>
</div> </div>
...@@ -55,6 +68,11 @@ ...@@ -55,6 +68,11 @@
<th <th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" 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>
<th <th
...@@ -65,90 +83,172 @@ ...@@ -65,90 +83,172 @@
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<tr v-for="email in filteredEmails" :key="email.id"> <tr v-for="email in emails" :key="email.taskBizId">
<td class="px-6 py-4 whitespace-nowrap">{{ email.sender }}</td> <td class="px-6 py-4 whitespace-nowrap">{{ email.sendEmail }}</td>
<td class="px-6 py-4 whitespace-nowrap max-w-xs truncate">{{ email.to }}</td> <td class="px-6 py-4 whitespace-nowrap max-w-xs truncate">{{ email.receiveEmails }}</td>
<td class="px-6 py-4 max-w-xs truncate">{{ email.subject }}</td> <td class="px-6 py-4 max-w-xs truncate">{{ email.subject }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ formatDate(email.sendTime) }} {{ email.sendTime ? formatDate(email.sendTime) : '无' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ email.scheduleTime ? formatDate(email.scheduleTime) : '无' }}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<span <span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
:class=" :class="
email.status === 'sent' email.status.includes('SUCCESS')
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: email.status === 'scheduled' : email.status.includes('ING')
? 'bg-yellow-100 text-yellow-800' ? 'bg-yellow-100 text-yellow-800'
: email.status === 'draft' : email.status.includes('FAIL')
? 'bg-gray-100 text-gray-800' ? 'bg-gray-100 text-gray-800'
: 'bg-red-100 text-red-800' : 'bg-red-100 text-red-800'
" "
> >
{{ {{ email.statusLabel }}
email.status === 'sent'
? '已发送'
: email.status === 'scheduled'
? '已定时'
: email.status === 'draft'
? '草稿'
: '发送失败'
}}
</span> </span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button <button
@click="viewEmailDetail(email)" @click="(viewDetail(email), (detailModalVisible = true))"
class="text-blue-600 hover:text-blue-900 mr-3" class="text-blue-600 hover:text-blue-900 mr-3"
> >
查看 查看
</button> </button>
<button @click="reuseEmailContent(email)" class="text-green-600 hover:text-green-900"> <!-- <button @click="reuseEmailContent(email)" class="text-green-600 hover:text-green-900">
复用 复用
</button> </button> -->
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="filteredEmails.length === 0" class="p-8 text-center text-gray-500"> <div v-if="emails.length === 0" class="p-8 text-center text-gray-500">
<i class="fas fa-history text-4xl mb-3 opacity-30"></i> <i class="fas fa-history text-4xl mb-3 opacity-30"></i>
<p>暂无邮件发送记录</p> <p>暂无邮件发送记录</p>
</div> </div>
<!-- 分页组件 -->
<Pagination
:total="total"
:current="currentPage"
:page-size="pageSize"
@change="handlePageChange"
@update:current="handleCurrentUpdate"
@update:page-size="handlePageSizeUpdate"
/>
</div> </div>
<!-- 查看详情 -->
<EmailDetailModal
:statusOptions="statusOptions"
:visible="detailModalVisible"
:email-data="selectedEmail"
@close="detailModalVisible = false"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, defineProps, defineEmits } from 'vue' import { ref, computed, onMounted } from 'vue'
import { Email } from '../types' import type { EmailTask } from '@/types/index'
import { sendEmailApi, dictApi } from '@/api/api'
const props = defineProps({ import type { DictItem } from '@/types/index'
emails: { import EmailDetailModal from '@/components/EmailDetailModal.vue'
type: Array as () => Email[],
required: true, // 引入分页组件
}, import Pagination from '@/components/Pagination.vue'
})
const emits = defineEmits(['reuse-email']) // 初始数据
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const resetSearch = () => {
searchTerm.value = ''
filterStatus.value = ''
getEmailTaskMainList()
}
// 处理分页变化
const handlePageChange = (page: number, size: number) => {
console.log('分页变化:', page, size)
currentPage.value = page
pageSize.value = size
getEmailTaskMainList()
}
const handleCurrentUpdate = (page: number) => {
currentPage.value = page
}
const handlePageSizeUpdate = (size: number) => {
pageSize.value = size
currentPage.value = 1 // 重置到第一页
getEmailTaskMainList()
}
// 状态 // 状态
const emails = ref<EmailTask[]>([])
const detailModalVisible = ref(false)
const selectedEmail = ref<EmailTask>()
const searchTerm = ref('') const searchTerm = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const statusOptions = ref<DictItem[]>([])
const isDictLoading = ref(false) // 新增:状态选项加载状态
const isEmailListLoading = ref(false) // 新增:邮件列表加载状态
// 计算属性 onMounted(() => {
const filteredEmails = computed(() => { loadDataSequentially()
return props.emails })
.filter((email) => { // 顺序加载数据:先状态选项,后邮件列表
const matchesSearch = const loadDataSequentially = async () => {
email.subject.toLowerCase().includes(searchTerm.value.toLowerCase()) || try {
email.to.toLowerCase().includes(searchTerm.value.toLowerCase()) isDictLoading.value = true
await getDictLists()
isDictLoading.value = false
const matchesStatus = !filterStatus.value || email.status === filterStatus.value // 状态选项加载完成后,自动加载邮件列表
await getEmailTaskMainList()
} catch (error) {
console.error('数据加载失败:', error)
isDictLoading.value = false
isEmailListLoading.value = false
}
}
return matchesSearch && matchesStatus const viewDetail = (item: EmailTask) => {
console.log(item)
selectedEmail.value = { ...item }
// 调用getEmailTaskList查询详情
sendEmailApi
.getEmailTaskList({
taskBizId: item.taskBizId,
}) })
.sort((a, b) => new Date(b.sendTime).getTime() - new Date(a.sendTime).getTime()) .then((res) => {
}) if (res.code === 200) {
selectedEmail.value = {
...item,
records: res.data.records || [],
}
console.log(selectedEmail)
}
})
.catch((err) => {
console.error('获取邮件详情失败:', err)
})
}
// 匹配发送状态
const getDictLists = async () => {
try {
const res = await dictApi.getDictList(['email_task_status'])
if (res.code === 200) {
statusOptions.value = res.data[0].dictItemList || []
console.log('状态选项加载完成:', statusOptions.value)
}
} catch (error) {
console.error('加载状态选项失败:', error)
throw error // 抛出错误以便上层处理
}
}
// 方法 // 方法
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
...@@ -156,17 +256,53 @@ const formatDate = (dateString: string) => { ...@@ -156,17 +256,53 @@ const formatDate = (dateString: string) => {
return date.toLocaleString() return date.toLocaleString()
} }
const viewEmailDetail = (email: Email) => { const reuseEmailContent = (email: EmailTask) => {
// 显示邮件详情 // 触发复用邮件内容事件
alert(`邮件主题: ${email.subject}\n收件人: ${email.to}\n发送时间: ${formatDate(email.sendTime)}`)
// 实际项目中可以打开详情弹窗
} }
const reuseEmailContent = (email: Email) => { // 发送任务列表查询(改进版本)
// 触发复用邮件内容事件 const getEmailTaskMainList = async () => {
emits('reuse-email', { // 如果状态选项正在加载,等待加载完成
subject: email.subject, if (isDictLoading.value) {
content: email.content, console.log('等待状态选项加载完成...')
// 可以添加一个简单的等待机制
await new Promise((resolve) => setTimeout(resolve, 100))
return getEmailTaskMainList() // 递归调用直到状态选项加载完成
}
// 如果状态选项为空,先加载状态选项
if (statusOptions.value.length === 0) {
console.log('状态选项为空,先加载状态选项')
await getDictLists()
}
try {
isEmailListLoading.value = true
const params: EmailTask = {
queryContent: searchTerm.value,
pageNo: currentPage.value,
pageSize: pageSize.value,
status: filterStatus.value,
}
const res = await sendEmailApi.getEmailTaskMainList(params)
if (res.code === 200) {
emails.value = res.data.records || []
total.value = res.data.total || 0
// 为每个邮件设置状态标签
emails.value.forEach((email) => {
email.statusLabel =
statusOptions.value.find((item) => item.itemValue === email.status)?.itemLabel ||
'未知状态'
}) })
console.log('邮件列表加载完成,共', emails.value.length, '条记录')
}
} catch (error) {
console.error('获取邮件列表失败:', error)
} finally {
isEmailListLoading.value = false
}
} }
</script> </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>
<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-3 gap-4">
<div>
<label class="block text-gray-700 mb-1 text-sm">邮件服务商 *</label>
<select
v-model="formData.providerBizId"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option
v-for="provider in providers"
:value="provider.providerBizId"
:key="provider.providerBizId"
>
{{ provider.providerName }}
</option>
</select>
</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"
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">发件人姓名</label>
<input
v-model="formData.displayName"
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.phone"
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>
<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"
>
{{ 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="p-6 border-b border-gray-200 flex flex-col md:flex-row gap-4">
<div class="relative flex-1">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
v-model="searchQuery.displayName"
type="text"
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg"
placeholder="搜索发件人姓名..."
/>
</div>
<!-- 搜索发件人邮箱 -->
<div class="relative flex-1">
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
v-model="searchQuery.email"
type="text"
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg"
placeholder="搜索发件人邮箱..."
/>
</div>
<!-- 搜索和重置按钮在右侧 -->
<div class="flex gap-3 w-full md:w-auto">
<!-- 重置和搜索 -->
<button
@click="resetSearch"
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-400 transition-colors mr-2"
>
<i class="fas fa-sync mr-2"></i>重置
</button>
<button
@click="getSenders"
class="btn-outline px-4 py-2 border border-blue-500 text-blue-500 rounded-md hover:bg-blue-50 transition-colors"
>
<i class="fas fa-search mr-2"></i> 搜索
</button>
</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-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.senderBizId">
<td class="px-6 py-4 whitespace-nowrap">{{ sender.email }}</td>
<td class="px-6 py-4 whitespace-nowrap">
{{
providers.find((i) => i.providerBizId === sender.providerBizId)?.providerName ||
'未知'
}}
</td>
<td class="px-6 py-4 whitespace-nowrap">{{ sender.displayName }}</td>
<td class="px-6 py-4 whitespace-nowrap">{{ sender.phone }}</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.senderBizId || '')"
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>
<!-- 分页组件 -->
<Pagination
:total="total"
:current="currentPage"
:page-size="pageSize"
@change="handlePageChange"
@update:current="handleCurrentUpdate"
@update:page-size="handlePageSizeUpdate"
/>
</div>
<CommonModal
v-model:visible="modalVisible"
:trigger-key="modalConfig.triggerKey"
:title="modalConfig.title"
type="confirm"
:message="modalConfig.message"
:show-cancel-button="modalConfig.showCancel"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { emailProviderApi, senderApi, getEmailSenderConfigList } from '@/api/api'
import type { Sender } from '@/types/index'
// 引入分页组件
import Pagination from '@/components/Pagination.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// 初始数据
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const searchQuery = ref({
displayName: '',
email: '',
})
const resetSearch = () => {
searchQuery.value = {
displayName: '',
email: '',
}
getSenders()
}
// 处理分页变化
const handlePageChange = (page: number, size: number) => {
console.log('分页变化:', page, size)
getSenders()
}
const handleCurrentUpdate = (page: number) => {
currentPage.value = page
}
const handlePageSizeUpdate = (size: number) => {
pageSize.value = size
currentPage.value = 1 // 重置到第一页
}
// 引入弹窗组件
import CommonModal from '@/components/CommonModal.vue'
import type { Action } from 'element-plus'
// 弹窗提示信息对象
const modalVisible = ref(false)
const modalConfig = ref({
showCancel: false,
title: '操作确认',
message: '确定要执行此操作吗?',
triggerKey: 'templateModal',
})
const alertInfo = (
content: string,
title: string = '提示',
type: 'info' | 'success' | 'warning' | 'error' = 'info',
msg: string = '',
) => {
ElMessageBox.alert(content, title, {
type: type,
confirmButtonText: 'OK',
callback: (action: Action) => {
// ElMessage({
// type: type,
// message: msg || '操作成功',
// })
},
})
}
const openModal = (
config: { triggerKey?: string; showCancel?: boolean; title?: string; message?: string } = {},
) => {
modalConfig.value = {
showCancel: config.showCancel ?? false,
title: config.title ?? '操作确认',
message: config.message ?? '确定要执行此操作吗?',
triggerKey: config.triggerKey ?? modalConfig.value.triggerKey,
}
modalVisible.value = true
}
const handleConfirm = (triggerKey: string) => {
modalVisible.value = false
console.log('用户确认操作', triggerKey)
if (triggerKey === 'deleteModal') {
senderApi.deleteEmailSenderConfig(editingSenderId.value).then((res) => {
if (res.code === 200) {
openModal({
title: '删除确认',
message: '发件人删除成功',
triggerKey: '',
})
getSenders()
resetForm()
} else {
openModal({
title: '删除失败',
message: res.msg || '删除失败',
triggerKey: '',
})
return
}
})
}
}
const handleCancel = (triggerKey: string) => {
modalVisible.value = false
console.log('用户取消操作', triggerKey)
}
// 状态
const senders = ref<Sender[]>([])
const editingSenderId = ref('')
const formData = ref<Partial<Sender>>({
email: '',
password: '',
providerBizId: '',
displayName: '',
active: 1,
phone: '',
})
// 服务商列表
const providers = ref<Provider[]>([])
// 方法
const resetForm = () => {
editingSenderId.value = ''
formData.value = {
email: '',
password: '',
providerBizId: '',
displayName: '',
active: 1,
phone: '',
}
}
const getProviders = () => {
emailProviderApi
.getEmailProviderList({
providerName: '',
})
.then((res) => {
providers.value = res.data?.records || []
})
}
const getSenders = () => {
senderApi
.getEmailSenderConfigList({
displayName: searchQuery.value.displayName,
email: searchQuery.value.email,
pageNo: currentPage.value,
pageSize: pageSize.value,
})
.then((res) => {
senders.value = res.data?.records || []
total.value = res.data?.total || 0
})
}
const saveSender = () => {
console.log(formData.value)
if (!formData.value.email || !formData.value.password || !formData.value.providerBizId) {
alertInfo('请填写完整邮箱授权码和服务商', '添加失败', 'error')
return
}
if (editingSenderId.value) {
// 更新现有发件人
senderApi
.editEmailSenderConfig({
senderBizId: editingSenderId.value,
...formData.value,
})
.then((res) => {
if (res.code === 200) {
getSenders()
openModal({
title: '提示',
message: '发件人信息更新成功',
})
} else {
openModal({
title: '提示',
message: res.msg || '更新失败',
})
}
})
} else {
// 添加新发件人
const newSender: Sender = {
email: formData.value.email || '',
password: formData.value.password || '',
providerBizId: formData.value.providerBizId || '',
displayName: formData.value.displayName || '',
active: formData.value.active ?? 1,
phone: formData.value.phone || '',
}
senderApi.addEmailSenderConfig(newSender).then((res) => {
// 补充异常处理
if (res.code === 200) {
getSenders()
openModal({
title: '添加确认',
message: '发件人添加成功',
})
} else {
openModal({
title: '添加失败',
message: res.msg || '添加失败',
})
}
})
}
resetForm()
}
const editSender = (sender: Sender) => {
console.log('编辑发件人', sender)
editingSenderId.value = sender.senderBizId || ''
formData.value = { ...sender }
}
const deleteSender = (id: string) => {
console.log('删除发件人', id)
editingSenderId.value = id
openModal({
showCancel: true,
title: '删除确认',
message: '确定要删除这个发件人吗?',
triggerKey: 'deleteModal',
})
}
onMounted(() => {
// 初始化服务商列表
getProviders()
// 初始化发件人列表
getSenders()
})
</script>
<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>
<template>
<div class="signature-management-container bg-white rounded-lg shadow-md p-6 mb-6">
<!-- 顶部操作区 -->
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold text-gray-800">签名管理</h2>
<el-button type="primary" @click="openAddSignatureModal">
<el-icon><Plus /></el-icon>
添加签名
</el-button>
</div>
<!-- 签名列表 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<el-table
:data="signatureList"
border
stripe
empty-text="暂无签名,系统将自动创建默认签名模板"
:loading="loading"
>
<el-table-column prop="name" label="签名名称" min-width="150">
<template #default="scope">
<div class="flex items-center">
<span>{{ scope.row.name }}</span>
<el-tag v-if="scope.row.isDefault" size="small" type="success" class="ml-2"
>默认</el-tag
>
<el-tag v-if="scope.row.type === 'template'" size="small" type="info" class="ml-2">
公司
</el-tag>
<el-tag v-if="scope.row.type === 'custom'" size="small" type="warning" class="ml-2">
自定义
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="签名预览" min-width="300">
<template #default="scope">
<div class="signature-preview text-sm" v-html="getSignaturePreview(scope.row)"></div>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="300" fixed="right">
<template #default="scope">
<el-button
size="small"
type="primary"
text
@click="setDefaultSignature(scope.row.id)"
:disabled="scope.row.isDefault"
>
{{ scope.row.isDefault ? '已设为默认' : '设为默认' }}
</el-button>
<el-button size="small" type="warning" text @click="editSignature(scope.row)">
编辑
</el-button>
<el-button
size="small"
type="danger"
v-if="scope.row.type !== 'template'"
text
@click="deleteSignature(scope.row.id)"
:disabled="scope.row.isDefault"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 添加/编辑签名弹窗 -->
<el-dialog
v-model="signatureModalVisible"
:title="isEdit ? '编辑签名' : '添加签名'"
width="800px"
>
<el-form
ref="signatureFormRef"
:model="signatureForm"
:rules="signatureRules"
label-width="100px"
>
<!-- 基础信息 -->
<el-form-item label="基础设置" prop="name">
<el-row :gutter="10" class="name-default-row">
<!-- 签名名称输入框(占主要宽度) -->
<el-col :span="18">
<el-input
v-model="signatureForm.name"
placeholder="请输入签名名称(如:默认签名、商务签名)"
maxlength="50"
show-word-limit
style="width: 100%"
/>
</el-col>
<!-- 设为默认签名开关(靠右) -->
<el-col :span="6" class="default-switch-col">
<el-form-item label="设为默认签名" class="default-switch-item">
<el-switch v-model="signatureForm.isDefault" />
</el-form-item>
</el-col>
</el-row>
</el-form-item>
<!-- 模板型签名配置 -->
<div v-if="signatureForm.type === 'template'" class="template-config-section mt-4">
<h4 class="font-medium mb-3 text-gray-700">字段设置</h4>
<!-- 基础信息配置 -->
<el-row :gutter="20" class="mb-4">
<el-col :span="12">
<el-form-item label="姓名">
<el-input
v-model="signatureForm.config.name"
placeholder="请输入姓名"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="职位">
<el-input
v-model="signatureForm.config.title"
placeholder="请输入职位"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="公司名称">
<el-input
v-model="signatureForm.config.companyName"
placeholder="请输入公司名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="电话">
<el-input
v-model="signatureForm.config.phone"
placeholder="请输入联系电话"
maxlength="20"
show-word-limit
/>
</el-form-item>
<el-form-item label="邮箱">
<el-input
v-model="signatureForm.config.email"
placeholder="请输入邮箱地址"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="公司地址">
<el-input
v-model="signatureForm.config.address"
placeholder="请输入公司地址"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
<!-- 字段显示配置 -->
<div class="field-show-config mt-4">
<h4 class="font-medium mb-3 text-gray-700">字段显示配置</h4>
<el-row :gutter="10">
<el-col :span="6">
<el-form-item label="姓名">
<el-switch v-model="signatureForm.config.showName" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="职位">
<el-switch v-model="signatureForm.config.showTitle" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="电话">
<el-switch v-model="signatureForm.config.showPhone" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="邮箱">
<el-switch v-model="signatureForm.config.showEmail" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="公司名称">
<el-switch v-model="signatureForm.config.showCompanyName" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="LOGO">
<el-switch v-model="signatureForm.config.showLogo" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="地址">
<el-switch v-model="signatureForm.config.showAddress" />
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 模板预览 -->
<div class="template-preview mt-4 p-3 border rounded bg-gray-50">
<h4 class="font-medium mb-2 text-gray-700">模板预览</h4>
<div v-html="generateTemplatePreview()" class="preview-content text-sm"></div>
</div>
</div>
<!-- 自定义签名配置 -->
<div v-if="signatureForm.type === 'custom'" class="custom-config-section mt-4">
<el-form-item label="签名内容">
<RichTextEditor v-model="signatureForm.customContent" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="signatureModalVisible = false">取消</el-button>
<el-button type="primary" @click="saveSignature" :loading="submitLoading"> 保存 </el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { format } from 'date-fns'
import RichTextEditor from '@/components/RichTextEditor.vue'
import { Signature } from '@/types/signature'
// 基础状态管理
const loading = ref(false)
const submitLoading = ref(false)
const signatureModalVisible = ref(false)
const isEdit = ref(false)
// 签名列表
const signatureList = ref<Signature[]>([])
// 签名表单
const signatureFormRef = ref()
const signatureForm = reactive<Signature>({
id: '',
name: '',
type: 'custom',
isDefault: false,
config: {
companyName: 'Yindun Insurance Brokers Co..Ltd',
logoUrl: '',
name: '',
phone: '',
email: '',
address:
'Room 3063, Tower No.8 Shuang Xing , No 1 Weigongcun Ave, Haidian District, Beijing. P.R. China',
showCompanyName: true,
showLogo: true,
showName: true,
showPhone: true,
showEmail: true,
showAddress: true,
},
customContent: '',
createTime: '',
updateTime: '',
})
// 签名表单校验规则
const signatureRules = reactive({
name: [
{ required: true, message: '请输入签名名称', trigger: 'blur' },
{ min: 2, max: 50, message: '签名名称长度在 2 到 50 个字符', trigger: 'blur' },
],
type: [{ required: true, message: '请选择签名类型', trigger: 'change' }],
})
// 获取签名列表
const fetchSignatureList = async () => {
try {
loading.value = true
// 实际项目中替换为API请求
// const res = await api.get('/email/signatures');
// signatureList.value = res.data;
// 模拟数据 - 初始化默认模板签名
signatureList.value = [
{
id: 1,
name: '公司签名',
type: 'template',
isDefault: true,
config: {
companyName: 'Yindun Insurance Brokers Co..Ltd',
logoUrl: 'https://m.zuihuibi.cn/ydLife/assets/images/ydinsurance_logo.png',
name: '张三',
title: '产品经理',
phone: '13800138000',
email: 'zhangsan@example.com',
address:
'Room 3063, Tower No.8 Shuang Xing , No 1 Weigongcun Ave, Haidian District, Beijing. P.R. China',
showCompanyName: true,
showLogo: true,
showName: true,
showTitle: true,
showPhone: true,
showEmail: true,
showAddress: true,
},
customContent: '',
createTime: '2025-01-10 00:00:00',
updateTime: '2025-01-10 00:00:00',
},
]
} catch (error) {
ElMessage.error('获取签名列表失败')
console.error(error)
} finally {
loading.value = false
}
}
// 生成模板型签名预览HTML
const generateTemplatePreview = (): string => {
const config = signatureForm.config
let html = '<div style="font-family: Arial, sans-serif; line-height: 1.8; color: #333;">'
// 姓名 + 别名
if (config.showName) {
html += `<p style="margin: 0; font-weight: bold; font-size: 14px;">${config.name}`
if (config.showTitle && config.title) {
html += ` | ${config.title}`
}
html += '</p>'
}
// LOGO
if (config.showLogo && config.logoUrl) {
html += `<img src="${config.logoUrl}" alt="公司LOGO" style="width: 120px; height: auto; margin: 4px 0;" />`
}
// 公司名称
if (config.showCompanyName && config.companyName) {
html += `<p style="margin: 0; font-size: 13px;">${config.companyName}</p>`
}
if (config.showPhone && config.phone) {
html += `<p style="margin: 0; font-size: 12px;">M:+86 ${config.phone}</p>`
}
if (config.showEmail && config.email) {
html += `<p style="margin: 0; font-size: 12px;">E:${config.email}</p>`
}
if (config.showAddress && config.address) {
html += `<p style="margin: 0; font-size: 12px;">A:${config.address}</p>`
}
html += '</div>'
return html
}
// 获取签名预览HTML
const getSignaturePreview = (signature: Signature): string => {
if (signature.type === 'template') {
const config = signature.config
let html =
'<div style="font-family: Arial, sans-serif; line-height: 1.8; color: #333; max-height: 200px; overflow: hidden;">'
if (config.showName) {
html += `<p style="margin: 0; font-weight: bold; font-size: 14px;">${config.name}`
if (config.showTitle && config.title) {
html += ` | ${config.title}`
}
html += '</p>'
}
if (config.showLogo && config.logoUrl) {
html += `<img src="${config.logoUrl}" alt="公司LOGO" style="width: 100px; height: auto; margin: 4px 0;" />`
}
if (config.showCompanyName && config.companyName) {
html += `<p style="margin: 0; font-size: 13px;">${config.companyName}</p>`
}
if (config.showPhone && config.phone) {
html += `<p style="margin: 0; font-size: 12px;">M:+86 ${config.phone}</p>`
}
if (config.showEmail && config.email) {
html += `<p style="margin: 0; font-size: 12px;">E:${config.email}</p>`
}
if (config.showAddress && config.address) {
html += `<p style="margin: 0; font-size: 12px;">A:${config.address}</p>`
}
html += '</div>'
return html
} else {
// 自定义签名预览(限制高度)
return `<div style="max-height: 200px; overflow: hidden;">${signature.customContent}</div>`
}
}
// 重置表单
const resetForm = () => {
signatureForm.id = ''
signatureForm.name = ''
signatureForm.type = 'custom'
signatureForm.isDefault = false
signatureForm.config = {
companyName: 'Yindun Insurance Brokers Co..Ltd',
logoUrl: '',
name: '',
title: '',
phone: '',
email: '',
address:
'Room 3063, Tower No.8 Shuang Xing , No 1 Weigongcun Ave, Haidian District, Beijing. P.R. China',
showCompanyName: true,
showLogo: true,
showName: true,
showTitle: true,
showPhone: true,
showEmail: true,
showAddress: true,
}
signatureForm.customContent = ''
signatureForm.createTime = ''
signatureForm.updateTime = ''
signatureFormRef.value?.resetFields()
}
// 保存签名
const saveSignature = async () => {
try {
await signatureFormRef.value?.validate()
submitLoading.value = true
// 确保只有一个默认签名
if (signatureForm.isDefault) {
signatureList.value.forEach((s) => {
s.isDefault = false
})
}
if (isEdit.value) {
// 编辑签名
// const res = await api.put(`/email/signatures/${signatureForm.id}`, signatureForm);
const index = signatureList.value.findIndex((item) => item.id === signatureForm.id)
if (index > -1) {
signatureList.value[index] = {
...signatureList.value[index],
...signatureForm,
updateTime: format(new Date(), 'yyyy-MM-dd HH:mm:ss'),
}
}
ElMessage.success('签名编辑成功')
} else {
// 添加新签名
// const res = await api.post('/email/signatures', signatureForm);
const newSignature: Signature = {
...signatureForm,
id: Date.now(),
createTime: format(new Date(), 'yyyy-MM-dd HH:mm:ss'),
updateTime: format(new Date(), 'yyyy-MM-dd HH:mm:ss'),
}
signatureList.value.unshift(newSignature)
ElMessage.success('签名添加成功')
}
signatureModalVisible.value = false
fetchSignatureList()
} catch (error) {
console.error(error)
} finally {
submitLoading.value = false
}
}
// 设置默认签名
const setDefaultSignature = async (id: string | number) => {
try {
// await api.post(`/email/signatures/${id}/set-default`);
signatureList.value.forEach((item) => {
item.isDefault = item.id === id
})
ElMessage.success('默认签名设置成功')
} catch (error) {
ElMessage.error('设置默认签名失败')
console.error(error)
}
}
// 编辑签名
const editSignature = (signature: Signature) => {
isEdit.value = true
Object.assign(signatureForm, signature)
signatureModalVisible.value = true
}
// 删除签名
const deleteSignature = async (id: string | number) => {
try {
const confirm = await ElMessageBox.confirm('确定要删除该签名吗?此操作不可撤销', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
if (confirm) {
// await api.delete(`/email/signatures/${id}`);
const index = signatureList.value.findIndex((item) => item.id === id)
if (index > -1) {
signatureList.value.splice(index, 1)
// 如果删除的是默认签名,且还有其他签名,将第一个设为默认
if (signatureList.value.length > 0 && !signatureList.value.some((s) => s.isDefault)) {
signatureList.value[0].isDefault = true
}
}
ElMessage.success('签名删除成功')
}
} catch (error) {
console.error(error)
}
}
// 打开添加签名弹窗
const openAddSignatureModal = () => {
isEdit.value = false
resetForm()
// 初始化默认配置(复制系统默认签名的配置)
const defaultSignature = signatureList.value.find((s) => s.isDefault)
if (defaultSignature && defaultSignature.type === 'template') {
signatureForm.config = { ...defaultSignature.config }
// 取消显示LOGO(避免重复)
signatureForm.config.logoUrl = ''
}
signatureModalVisible.value = true
}
// 初始化
onMounted(() => {
fetchSignatureList()
})
</script>
<style scoped>
.signature-management-container {
@apply bg-gray-50 min-h-[800px];
}
.avatar-uploader {
@apply w-40 h-40 border border-dashed rounded-md flex flex-col items-center justify-center cursor-pointer;
}
.avatar {
@apply w-full h-full object-cover rounded-md;
}
.upload-icon {
@apply flex flex-col items-center justify-center text-gray-400;
}
.upload-icon .text {
@apply mt-2 text-xs;
}
.signature-preview {
@apply max-h-80 overflow-hidden leading-relaxed;
}
.template-preview .preview-content {
@apply max-h-80 overflow-auto;
}
.template-config-section,
.custom-config-section {
@apply border-t pt-4;
}
</style>
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div> <div>
<div class="bg-white rounded-lg shadow-md p-6 mb-6"> <div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">添加变量</h3> <h3 class="text-lg font-semibold">添加/编辑变量</h3>
<button <button
v-if="editingVariableId" v-if="editingVariableId"
@click="resetVariableForm" @click="resetVariableForm"
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
<div> <div>
<label class="block text-gray-700 mb-1 text-sm">变量名称 *</label> <label class="block text-gray-700 mb-1 text-sm">变量名称 *</label>
<input <input
v-model="variableForm.name" v-model="variableForm.variableNameCn"
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="例如:用户名"
...@@ -30,14 +30,20 @@ ...@@ -30,14 +30,20 @@
{{ variablePrefix }} {{ variablePrefix }}
</span> </span>
<input <input
v-model="variableForm.key" v-model="variableForm.variableNameEn"
type="text" type="text"
class="flex-1 px-3 py-2 border border-gray-300 rounded-r-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="flex-1 px-3 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="例如:username" placeholder="例如:username"
/> />
<span
class="inline-flex items-center px-3 py-2 rounded-r-md border border-l-0 border-gray-300 bg-gray-50 text-gray-500"
>
{{ variableNextfix }}
</span>
</div> </div>
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
变量将以 {{ variablePrefix }}key 形式插入邮件内容 变量将以 {{ variablePrefix }}{{ variableForm.variableNameEn
}}{{ variableNextfix }} 形式插入邮件内容
</p> </p>
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
...@@ -54,13 +60,13 @@ ...@@ -54,13 +60,13 @@
<button <button
@click="saveVariable" @click="saveVariable"
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="!variableForm.name || !variableForm.key" :disabled="!variableForm.variableNameCn || !variableForm.variableNameEn"
> >
{{ editingVariableId ? '更新变量' : '添加变量' }} {{ editingVariableId ? '更新变量' : '添加变量' }}
</button> </button>
</div> </div>
</div> </div>
<!-- 变量模板列表 -->
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-6"> <div class="bg-white rounded-lg shadow-md overflow-hidden mb-6">
<div class="p-6 border-b border-gray-200"> <div class="p-6 border-b border-gray-200">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
...@@ -76,20 +82,21 @@ ...@@ -76,20 +82,21 @@
<div class="p-6"> <div class="p-6">
<div <div
v-for="template in variableTemplates" v-for="template in variableTemplates"
:key="template.id" :key="template.variableGroupBizId"
class="border border-gray-200 rounded-lg p-4 mb-4 hover:border-blue-300 transition-colors" class="border border-gray-200 rounded-lg p-4 mb-4 hover:border-blue-300 transition-colors"
> >
<div class="flex justify-between items-start mb-3"> <div class="flex justify-between items-start mb-3">
<h4 class="font-medium">{{ template.name }}</h4> <h4 class="font-medium">{{ template.groupName }}</h4>
<div> <div>
<button <!-- 先不做编辑 -->
<!-- <button
@click="editVariableTemplate(template)" @click="editVariableTemplate(template)"
class="text-blue-600 hover:text-blue-900 mr-3 text-sm" class="text-blue-600 hover:text-blue-900 mr-3 text-sm"
> >
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</button> </button> -->
<button <button
@click="deleteVariableTemplate(template.id)" @click="deleteVariableTemplate(template.variableGroupBizId || '')"
class="text-red-600 hover:text-red-900 text-sm" class="text-red-600 hover:text-red-900 text-sm"
> >
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
...@@ -99,11 +106,11 @@ ...@@ -99,11 +106,11 @@
<p class="text-sm text-gray-600 mb-3">{{ template.description || '无描述' }}</p> <p class="text-sm text-gray-600 mb-3">{{ template.description || '无描述' }}</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
v-for="variableId in template.variableIds" v-for="s in template.variableNameEnList || []"
:key="variableId" :key="s"
class="px-2 py-1 bg-blue-50 text-blue-700 rounded text-xs" class="px-2 py-1 bg-blue-50 text-blue-700 rounded text-xs"
> >
{{ variablePrefix }}{{ getVariableKeyById(variableId) }} {{ variablePrefix }}{{ s }}{{ variableNextfix }}
</span> </span>
</div> </div>
<div class="mt-3 flex justify-end"> <div class="mt-3 flex justify-end">
...@@ -122,7 +129,7 @@ ...@@ -122,7 +129,7 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 变量列表 -->
<div class="bg-white rounded-lg shadow-md overflow-hidden"> <div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="p-6 border-b border-gray-200"> <div class="p-6 border-b border-gray-200">
<h3 class="text-lg font-semibold">变量列表</h3> <h3 class="text-lg font-semibold">变量列表</h3>
...@@ -155,9 +162,9 @@ ...@@ -155,9 +162,9 @@
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<tr v-for="variable in variables" :key="variable.id"> <tr v-for="variable in variables" :key="variable.id">
<td class="px-6 py-4 whitespace-nowrap">{{ variable.name }}</td> <td class="px-6 py-4 whitespace-nowrap">{{ variable.variableNameCn }}</td>
<td class="px-6 py-4 whitespace-nowrap font-mono text-sm"> <td class="px-6 py-4 whitespace-nowrap font-mono text-sm">
{{ variablePrefix }}{{ variable.key }} {{ variablePrefix }}{{ variable.variableNameEn }}{{ variableNextfix }}
</td> </td>
<td class="px-6 py-4">{{ variable.description || '-' }}</td> <td class="px-6 py-4">{{ variable.description || '-' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
...@@ -168,7 +175,7 @@ ...@@ -168,7 +175,7 @@
编辑 编辑
</button> </button>
<button <button
@click="deleteVariable(variable.id)" @click="deleteVariable(variable.variableBizId || '')"
class="text-red-600 hover:text-red-900" class="text-red-600 hover:text-red-900"
> >
删除 删除
...@@ -182,6 +189,16 @@ ...@@ -182,6 +189,16 @@
<i class="fas fa-variable text-4xl mb-3 opacity-30"></i> <i class="fas fa-variable text-4xl mb-3 opacity-30"></i>
<p>暂无变量,请添加变量</p> <p>暂无变量,请添加变量</p>
</div> </div>
<!-- 分页组件 -->
<Pagination
:total="total"
:current="currentPage"
:page-size="pageSize"
@change="handlePageChange"
@update:current="handleCurrentUpdate"
@update:page-size="handlePageSizeUpdate"
/>
</div> </div>
<!-- 变量模板弹窗 --> <!-- 变量模板弹窗 -->
...@@ -202,7 +219,7 @@ ...@@ -202,7 +219,7 @@
<div class="mb-4"> <div class="mb-4">
<label class="block text-gray-700 mb-1 text-sm">模板名称 *</label> <label class="block text-gray-700 mb-1 text-sm">模板名称 *</label>
<input <input
v-model="templateForm.name" v-model="templateForm.groupName"
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"
/> />
...@@ -227,16 +244,16 @@ ...@@ -227,16 +244,16 @@
> >
<input <input
type="checkbox" type="checkbox"
:id="'template-var-' + variable.id" :id="'template-var-' + variable.variableBizId"
:checked="templateForm.variableIds?.includes(variable.id)" :checked="templateForm.variableBizIdList?.includes(variable.variableBizId || '')"
class="mr-3" class="mr-3"
@change="toggleTemplateVariable(variable.id)" @change="toggleTemplateVariable(variable.variableBizId || '')"
/> />
<label for="'template-var-' + variable.id"> <label for="'template-var-' + variable.variableBizId">
<div class="text-sm text-gray-500">{{ variable.variableNameCn }}</div>
<div class="font-medium font-mono text-sm"> <div class="font-medium font-mono text-sm">
{{ variablePrefix }}{{ variable.key }} {{ variablePrefix }}{{ variable.variableNameEn }}{{ variableNextfix }}
</div> </div>
<div class="text-xs text-gray-500">{{ variable.name }}</div>
</label> </label>
</div> </div>
</div> </div>
...@@ -256,9 +273,9 @@ ...@@ -256,9 +273,9 @@
@click="saveVariableTemplate" @click="saveVariableTemplate"
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=" :disabled="
!templateForm.name || !templateForm.groupName ||
!templateForm.variableIds || !templateForm.variableBizIdList ||
templateForm.variableIds.length === 0 templateForm.variableBizIdList.length === 0
" "
> >
{{ editingTemplateId ? '更新模板' : '创建模板' }} {{ editingTemplateId ? '更新模板' : '创建模板' }}
...@@ -267,35 +284,100 @@ ...@@ -267,35 +284,100 @@
</div> </div>
</div> </div>
</div> </div>
<CommonModal
v-model:visible="modalVisible"
:trigger-key="modalConfig.triggerKey"
:title="modalConfig.title"
type="confirm"
:message="modalConfig.message"
:show-cancel-button="modalConfig.showCancel"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, defineProps, defineEmits } from 'vue' import { ref, defineProps, defineEmits, onMounted } from 'vue'
import { Variable, VariableTemplate } from '../types' import type { Variable, VariableTemplate } from '../types'
const props = defineProps({ import { variableApi, variableGroupApi } from '@/api/api'
variables: { import { ElMessage } from 'element-plus'
type: Array as () => Variable[],
required: true, // 引入分页组件
}, import Pagination from '@/components/Pagination.vue'
variableTemplates: {
type: Array as () => VariableTemplate[], // 初始数据
required: true, const total = ref(0)
}, const currentPage = ref(1)
const pageSize = ref(10)
// 处理分页变化
const handlePageChange = (page: number, size: number) => {
console.log('分页变化:', page, size)
fetchVariables()
// 这里可以发起API请求获取新数据
}
const handleCurrentUpdate = (page: number) => {
currentPage.value = page
}
const handlePageSizeUpdate = (size: number) => {
pageSize.value = size
currentPage.value = 1 // 重置到第一页
}
// 引入弹窗组件
import CommonModal from '@/components/CommonModal.vue'
// 弹窗提示信息对象
const modalVisible = ref(false)
const modalConfig = ref({
showCancel: false,
title: '操作确认',
message: '确定要执行此操作吗?',
triggerKey: 'templateModal',
}) })
const emits = defineEmits(['update-variables', 'update-variable-templates']) const openModal = (
config: { triggerKey?: string; showCancel?: boolean; title?: string; message?: string } = {},
) => {
modalConfig.value = {
showCancel: config.showCancel ?? false,
title: config.title ?? '操作确认',
message: config.message ?? '确定要执行此操作吗?',
triggerKey: config.triggerKey ?? modalConfig.value.triggerKey,
}
modalVisible.value = true
}
const handleConfirm = (triggerKey: string) => {
modalVisible.value = false
console.log('用户确认操作', triggerKey)
if (triggerKey === 'deleteModal') {
deleteVariable(editingVariableId.value, 'confirmDelete')
} else if (triggerKey === 'templeteDelete') {
deleteVariableTemplate(editingTemplateId.value, 'confirmDelete')
}
}
const handleCancel = (triggerKey: string) => {
modalVisible.value = false
console.log('用户取消操作', triggerKey)
}
const variables = ref<Variable[]>([])
const variableTemplates = ref<VariableTemplate[]>([])
// 状态 // 状态
const variables = ref<Variable[]>([...props.variables])
const variableTemplates = ref<VariableTemplate[]>([...props.variableTemplates])
const variablePrefix = '{{' const variablePrefix = '{{'
const variableNextfix = '}}'
// 变量表单 // 变量表单
const editingVariableId = ref('') const editingVariableId = ref('')
const variableForm = ref<Partial<Variable>>({ const variableForm = ref<Partial<Variable>>({
name: '', variableBizId: '',
key: '', variableNameCn: '',
variableNameEn: '',
description: '', description: '',
}) })
...@@ -303,82 +385,181 @@ const variableForm = ref<Partial<Variable>>({ ...@@ -303,82 +385,181 @@ const variableForm = ref<Partial<Variable>>({
const showTemplateModal = ref(false) const showTemplateModal = ref(false)
const editingTemplateId = ref('') const editingTemplateId = ref('')
const templateForm = ref<Partial<VariableTemplate>>({ const templateForm = ref<Partial<VariableTemplate>>({
name: '', groupName: '',
description: '', description: '',
variableIds: [], variableBizIdList: [],
})
// 在组件挂载时自动获取变量列表和模版列表
onMounted(() => {
fetchVariables()
fetchVariableTemplates()
}) })
// 方法 - 变量管理 // 方法 - 变量管理
const resetVariableForm = () => { const resetVariableForm = () => {
editingVariableId.value = '' editingVariableId.value = ''
variableForm.value = { variableForm.value = {
name: '', variableBizId: '',
key: '', variableNameCn: '',
variableNameEn: '',
description: '', description: '',
} }
} }
const saveVariable = () => { const saveVariable = () => {
if (!variableForm.value.name || !variableForm.value.key) return if (!variableForm.value.variableNameCn || !variableForm.value.variableNameEn) return
// 检查变量标识是否已存在
const exists = variables.value.some(
(v) => v.key === variableForm.value.key && v.id !== editingVariableId.value,
)
if (exists) {
alert('变量标识已存在,请使用其他标识')
return
}
if (editingVariableId.value) { if (editingVariableId.value) {
// 更新现有变量 // 更新接口
const index = variables.value.findIndex((v) => v.id === editingVariableId.value) variableApi
if (index > -1) { .editEmailVariable({
variables.value[index] = { variableBizId: variableForm.value.variableBizId || '',
...variables.value[index], variableNameCn: variableForm.value.variableNameCn,
...variableForm.value, variableNameEn: variableForm.value.variableNameEn,
} as Variable description: variableForm.value.description,
emits('update-variables', [...variables.value]) })
alert('变量更新成功') .then(() => {
} // 更新本地变量列表
fetchVariables()
openModal({
title: '成功',
message: '变量更新成功',
})
})
} else { } else {
// 添加新变量 // 创建接口
const newVariable: Variable = { variableApi
id: Date.now().toString(), .addEmailVariable({
name: variableForm.value.name || '', variableNameCn: variableForm.value.variableNameCn,
key: variableForm.value.key || '', variableNameEn: variableForm.value.variableNameEn,
description: variableForm.value.description || '', description: variableForm.value.description,
})
.then((res) => {
// 刷新变量列表
fetchVariables()
openModal({
title: '成功',
message: '变量创建成功',
})
})
.catch((error) => {
console.error('创建变量失败:', error)
if (error.response?.data?.message) {
openModal({
title: '错误',
message: error.response.data.message,
})
} else {
openModal({
title: '错误',
message: '创建变量失败',
})
} }
})
variables.value.push(newVariable)
emits('update-variables', [...variables.value])
alert('变量添加成功')
} }
resetVariableForm() resetVariableForm()
} }
const editVariable = (variable: Variable) => { const editVariable = (variable: Variable) => {
editingVariableId.value = variable.id editingVariableId.value = variable.variableBizId || ''
variableForm.value = { ...variable } variableForm.value = { ...variable }
} }
const deleteVariable = (id: string) => { const deleteVariable = (id: string, type?: string) => {
if (confirm('确定要删除这个变量吗?这可能会影响使用该变量的模板。')) { editingVariableId.value = id
// 从变量列表中删除 if (id && type === 'confirmDelete') {
variables.value = variables.value.filter((variable) => variable.id !== id) variableApi
emits('update-variables', [...variables.value]) .deleteEmailVariable(id)
.then(() => {
// 从所有模板中移除该变量 // 刷新变量列表
variableTemplates.value = variableTemplates.value.map((template) => ({ fetchVariables()
...template, openModal({
variableIds: template.variableIds.filter((vid) => vid !== id), triggerKey: '',
})) title: '成功',
emits('update-variable-templates', [...variableTemplates.value]) message: '变量删除成功',
})
})
.catch((error) => {
console.error('删除变量失败:', error)
if (error.response?.data?.message) {
openModal({
triggerKey: '',
title: '错误',
message: error.response.data.message,
})
} else {
openModal({
triggerKey: '',
title: '错误',
message: '删除变量失败',
})
}
})
} else {
openModal({
triggerKey: 'deleteModal',
showCancel: true,
title: '删除确认',
message: '确定要删除这个变量吗?这可能会影响使用该变量的模板。',
})
} }
} }
// 查询变量列表
const fetchVariables = () => {
const params = {
pageNo: currentPage.value,
pageSize: pageSize.value,
}
variableApi
.getEmailVariableList(params)
.then((res) => {
variables.value = res.data.records || []
total.value = res.data.total || 0
})
.catch((error) => {
console.error('查询变量列表失败:', error)
if (error.response?.data?.message) {
openModal({
title: '错误',
message: error.response.data.message,
})
} else {
openModal({
title: '错误',
message: '查询变量列表失败',
})
}
})
}
// 查询变量模版列表
const fetchVariableTemplates = () => {
const params = {
pageNo: 1,
pageSize: 10,
}
variableGroupApi
.getEmailVariableGroupList(params)
.then((res) => {
variableTemplates.value = res.data.records || []
})
.catch((error) => {
console.error('查询变量模版列表失败:', error)
if (error.response?.data?.message) {
openModal({
title: '错误',
message: error.response.data.message,
})
} else {
openModal({
title: '错误',
message: '查询变量模版列表失败',
})
}
})
}
// 方法 - 模板管理 // 方法 - 模板管理
const showCreateTemplateModal = (isNew: boolean) => { const showCreateTemplateModal = (isNew: boolean) => {
showTemplateModal.value = true showTemplateModal.value = true
...@@ -386,16 +567,15 @@ const showCreateTemplateModal = (isNew: boolean) => { ...@@ -386,16 +567,15 @@ const showCreateTemplateModal = (isNew: boolean) => {
if (isNew) { if (isNew) {
editingTemplateId.value = '' editingTemplateId.value = ''
templateForm.value = { templateForm.value = {
name: '', groupName: '',
description: '', description: '',
variableIds: [], variableBizIdList: [],
} }
} }
} }
// 方法 - 编辑变量模版
const editVariableTemplate = (template: VariableTemplate) => { const editVariableTemplate = (template: VariableTemplate) => {
editingTemplateId.value = template.id editingTemplateId.value = template.variableGroupBizId || ''
templateForm.value = { ...template }
showTemplateModal.value = true showTemplateModal.value = true
} }
...@@ -403,82 +583,145 @@ const closeTemplateModal = () => { ...@@ -403,82 +583,145 @@ const closeTemplateModal = () => {
showTemplateModal.value = false showTemplateModal.value = false
editingTemplateId.value = '' editingTemplateId.value = ''
templateForm.value = { templateForm.value = {
name: '', groupName: '',
description: '', description: '',
variableIds: [], variableBizIdList: [],
} }
} }
const toggleTemplateVariable = (variableId: string) => { const toggleTemplateVariable = (variableId: string) => {
if (!templateForm.value.variableIds) { if (!templateForm.value.variableBizIdList) {
templateForm.value.variableIds = [] templateForm.value.variableBizIdList = []
} }
const index = templateForm.value.variableIds.indexOf(variableId) const index = templateForm.value.variableBizIdList.indexOf(variableId)
if (index > -1) { if (index > -1) {
templateForm.value.variableIds.splice(index, 1) templateForm.value.variableBizIdList.splice(index, 1)
} else { } else {
templateForm.value.variableIds.push(variableId) templateForm.value.variableBizIdList.push(variableId)
} }
} }
const saveVariableTemplate = () => { const saveVariableTemplate = () => {
if ( if (
!templateForm.value.name || !templateForm.value.groupName ||
!templateForm.value.variableIds || !templateForm.value.variableBizIdList ||
templateForm.value.variableIds.length === 0 templateForm.value.variableBizIdList.length === 0
) )
return return
if (editingTemplateId.value) { if (editingTemplateId.value) {
console.log('更新变量模版', templateForm.value)
// 更新现有模板 // 更新现有模板
const index = variableTemplates.value.findIndex((t) => t.id === editingTemplateId.value) variableGroupApi
if (index > -1) { .editEmailVariableGroup({
variableTemplates.value[index] = { variableGroupBizId: editingTemplateId.value,
id: editingTemplateId.value, groupName: templateForm.value.groupName || '',
name: templateForm.value.name || '',
description: templateForm.value.description || '', description: templateForm.value.description || '',
variableIds: templateForm.value.variableIds || [], variableBizIdList: templateForm.value.variableBizIdList || [],
} })
emits('update-variable-templates', [...variableTemplates.value]) .then(() => {
// 刷新变量模版列表
fetchVariableTemplates()
})
.catch((error) => {
console.error('更新变量模版失败:', error)
if (error.response?.data?.message) {
openModal({
title: '错误',
message: error.response.data.message,
})
} else {
openModal({
title: '错误',
message: '更新变量模版失败',
})
} }
})
} else { } else {
// 创建新模板 // 调用变量组保存接口
const newTemplate: VariableTemplate = { variableGroupApi
id: Date.now().toString(), .addEmailVariableGroup({
name: templateForm.value.name || '', groupName: templateForm.value.groupName || '',
description: templateForm.value.description || '', description: templateForm.value.description || '',
variableIds: templateForm.value.variableIds || [], variableBizIdList: templateForm.value.variableBizIdList || [],
})
.then(() => {
// 刷新变量模版列表
fetchVariableTemplates()
})
.catch((error) => {
console.error('创建变量模版失败:', error)
if (error.response?.data?.message) {
openModal({
title: '错误',
message: error.response.data.message,
})
} else {
openModal({
title: '错误',
message: '创建变量模版失败',
})
} }
})
variableTemplates.value.push(newTemplate)
emits('update-variable-templates', [...variableTemplates.value])
} }
closeTemplateModal() closeTemplateModal()
alert(editingTemplateId.value ? '模板更新成功' : '模板创建成功')
} }
const deleteVariableTemplate = (id: string) => { const deleteVariableTemplate = (id: string, type?: string) => {
if (confirm('确定要删除这个模板吗?')) { editingTemplateId.value = id
variableTemplates.value = variableTemplates.value.filter((template) => template.id !== id) if (type === 'confirmDelete') {
emits('update-variable-templates', [...variableTemplates.value]) variableGroupApi
.deleteEmailVariableGroup(editingTemplateId.value)
.then(() => {
// 刷新变量模版列表
fetchVariableTemplates()
})
.catch((error) => {
console.error('删除变量模版失败:', error)
if (error.response?.data?.message) {
openModal({
title: '错误',
message: error.response.data.message,
})
} else {
openModal({
title: '错误',
message: '删除变量模版失败',
})
}
})
} else {
openModal({
showCancel: true,
title: '确认删除',
message: '确定删除此变量模版吗?',
triggerKey: 'templeteDelete',
})
} }
} }
const getVariableKeyById = (id: string) => { const getVariableKeyById = (id: string) => {
const variable = variables.value.find((v) => v.id === id) const variable = variables.value.find((v) => v.variableBizId === id)
return variable?.key || '' return variable?.variableNameEn || ''
} }
const generateExcelTemplate = (template: VariableTemplate) => { const generateExcelTemplate = (template: VariableTemplate) => {
// 模拟生成Excel模板 variableGroupApi
const variableNames = template.variableIds.map((id) => { .exportEmailVariableGroup(template.variableGroupBizId || '')
const variable = variables.value.find((v) => v.id === id) .then((response) => {
return variable ? variable.name : '' // 处理成功响应,例如下载文件
}) // 自动下载excel文件, 并命名为变量模版.xlsx
const a = document.createElement('a')
alert(`已生成包含以下变量的Excel模板:\n${variableNames.join(', ')}`) a.href = response.data.url
a.download = `${template.groupName || '变量模版'}.xlsx`
a.click()
// 实际项目中这里应该生成并下载Excel文件 // 实际项目中这里应该生成并下载Excel文件
})
.catch((error) => {
console.error('导出失败:', error)
ElMessage.error(error.response?.data?.message || '导出变量模版失败')
})
} }
</script> </script>
...@@ -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