Commit 26f467d0 by Sweet Zhang

初始化项目

parent dd310620
<!DOCTYPE html>
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>银盾邮件系统</title>
</head>
<body>
<div id="app"></div>
......
......@@ -8,6 +8,7 @@
"name": "yd-email",
"version": "0.0.0",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.0.1",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
......@@ -24,13 +25,16 @@
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.31.0",
"eslint-plugin-playwright": "^2.2.0",
"eslint-plugin-vue": "~10.3.0",
"jiti": "^2.4.2",
"jsdom": "^26.1.0",
"npm-run-all2": "^8.0.4",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.0",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0",
......@@ -41,6 +45,19 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
......@@ -1250,6 +1267,15 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fortawesome/fontawesome-free": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/@fortawesome/fontawesome-free/-/fontawesome-free-7.0.1.tgz",
"integrity": "sha512-RLmb9U6H2rJDnGxEqXxzy7ANPrQz7WK2/eTjdZqyU9uRU5W+FkAec9uU5gTYzFBH7aoXIw2WTJSCJR4KPlReQw==",
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
"engines": {
"node": ">=6"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz",
......@@ -2826,6 +2852,34 @@
"node": ">=14"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
......@@ -2843,6 +2897,44 @@
"node": ">=12"
}
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.21.tgz",
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.24.4",
"caniuse-lite": "^1.0.30001702",
"fraction.js": "^4.3.7",
"normalize-range": "^0.1.2",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
......@@ -2860,6 +2952,19 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/birpc": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.5.0.tgz",
......@@ -2969,6 +3074,16 @@
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001743",
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz",
......@@ -3034,6 +3149,44 @@
"node": ">= 16"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
......@@ -3258,6 +3411,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
......@@ -3954,6 +4121,20 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz",
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "patreon",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
......@@ -3969,6 +4150,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz",
......@@ -4060,6 +4251,19 @@
"node": ">=8"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
......@@ -4184,6 +4388,35 @@
"dev": true,
"license": "ISC"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz",
......@@ -4559,6 +4792,26 @@
"node": ">= 0.8.0"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz",
......@@ -4697,6 +4950,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
"object-assign": "^4.0.1",
"thenify-all": "^1.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
......@@ -4745,6 +5010,26 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz",
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/npm-normalize-package-bin": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
......@@ -4884,6 +5169,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz",
......@@ -5046,6 +5351,13 @@
"node": ">=8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz",
......@@ -5125,6 +5437,16 @@
"node": ">=0.10"
}
},
"node_modules/pify": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pinia": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.3.tgz",
......@@ -5146,6 +5468,16 @@
}
}
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz",
"integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.55.0.tgz",
......@@ -5206,6 +5538,112 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-import": {
"version": "15.1.0",
"resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
"read-cache": "^1.0.0",
"resolve": "^1.1.7"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"postcss": "^8.0.0"
}
},
"node_modules/postcss-js": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz",
"integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"camelcase-css": "^2.0.1"
},
"engines": {
"node": "^12 || ^14 || >= 16"
},
"peerDependencies": {
"postcss": "^8.4.21"
}
},
"node_modules/postcss-load-config": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"lilconfig": "^3.0.0",
"yaml": "^2.3.4"
},
"engines": {
"node": ">= 14"
},
"peerDependencies": {
"postcss": ">=8.0.9",
"ts-node": ">=9.0.0"
},
"peerDependenciesMeta": {
"postcss": {
"optional": true
},
"ts-node": {
"optional": true
}
}
},
"node_modules/postcss-nested": {
"version": "6.2.0",
"resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "^6.1.1"
},
"engines": {
"node": ">=12.0"
},
"peerDependencies": {
"postcss": "^8.2.14"
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
......@@ -5220,6 +5658,13 @@
"node": ">=4"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz",
......@@ -5313,6 +5758,16 @@
],
"license": "MIT"
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
}
},
"node_modules/read-package-json-fast": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz",
......@@ -5327,6 +5782,40 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz",
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz",
......@@ -5723,6 +6212,39 @@
"dev": true,
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.0.tgz",
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
"glob": "^10.3.10",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
"ts-interface-checker": "^0.1.9"
},
"bin": {
"sucrase": "bin/sucrase",
"sucrase-node": "bin/sucrase-node"
},
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/sucrase/node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/superjson": {
"version": "2.2.2",
"resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.2.tgz",
......@@ -5748,6 +6270,19 @@
"node": ">=8"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz",
......@@ -5771,6 +6306,77 @@
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
"chokidar": "^3.6.0",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.21.6",
"lilconfig": "^3.1.3",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"object-hash": "^3.0.0",
"picocolors": "^1.1.1",
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.2",
"postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.8",
"sucrase": "^3.35.0"
},
"bin": {
"tailwind": "lib/cli.js",
"tailwindcss": "lib/cli.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss/node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
}
},
"node_modules/thenify-all": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
......@@ -5945,6 +6551,13 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz",
......@@ -6875,6 +7488,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz",
......
......@@ -18,6 +18,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.0.1",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
......@@ -34,13 +35,16 @@
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.31.0",
"eslint-plugin-playwright": "^2.2.0",
"eslint-plugin-vue": "~10.3.0",
"jiti": "^2.4.2",
"jsdom": "^26.1.0",
"npm-run-all2": "^8.0.4",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.0",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0",
......
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
<script setup lang="ts"></script>
<template>
<h1>You did it!</h1>
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
<div id="app" class="min-h-screen bg-gray-50 text-gray-800 flex flex-col">
<!-- 登录页面 -->
<LoginPage v-if="isLoginPage && !isAuthenticated" @login="handleLogin" />
<!-- 主应用布局 -->
<div v-else class="flex flex-1 overflow-hidden">
<!-- 侧边导航 -->
<Sidebar :current-page="currentPage" @change-page="handlePageChange" @logout="handleLogout" />
<!-- 移动端菜单按钮 -->
<button
class="md:hidden fixed top-4 left-4 z-50 bg-blue-600 text-white p-2 rounded shadow-lg"
@click="showMobileMenu = !showMobileMenu"
>
<i class="fas fa-bars"></i>
</button>
<!-- 移动端侧边栏 -->
<MobileSidebar
v-if="showMobileMenu"
:current-page="currentPage"
@change-page="handleMobilePageChange"
@close-menu="showMobileMenu = false"
@logout="handleLogout"
/>
<!-- 主内容区域 -->
<main class="flex-1 overflow-y-auto bg-gray-50 p-4 md:p-6">
<header class="mb-6">
<h2 class="text-2xl font-bold text-gray-800">
{{ pageTitles[currentPage] }}
</h2>
</header>
<!-- 写邮件页面 -->
<ComposeEmail
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>
</div>
</div>
</template>
<style scoped></style>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import LoginPage from './components/LoginPage.vue'
import Sidebar from './components/Sidebar.vue'
import MobileSidebar from './components/MobileSidebar.vue'
import ComposeEmail from './components/ComposeEmail.vue'
import ContactManagement from './components/ContactManagement.vue'
import SenderManagement from './components/SenderManagement.vue'
import VariableManagement from './components/VariableManagement.vue'
import EmailManagement from './components/EmailManagement.vue'
import { Contact, Sender, Variable, VariableTemplate, Email } from './types'
// 状态管理
const isLoginPage = ref(true)
const isAuthenticated = ref(false)
const currentPage = ref('compose')
const showMobileMenu = ref(false)
// 数据存储
const contacts = ref<Contact[]>([])
const senders = ref<Sender[]>([])
const variables = ref<Variable[]>([])
const variableTemplates = ref<VariableTemplate[]>([])
const emails = ref<Email[]>([])
// 页面标题映射
const pageTitles = {
compose: '写邮件',
contacts: '联系人管理',
senders: '发件人管理',
variables: '变量管理',
emails: '邮件记录',
}
// 方法
const handleLogin = () => {
// 模拟登录验证
isAuthenticated.value = true
isLoginPage.value = false
// 登录成功后加载初始数据
loadInitialData()
}
const handleLogout = () => {
isAuthenticated.value = false
isLoginPage.value = true
}
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(() => {
// 检查是否已登录(实际项目中应该检查本地存储或令牌)
})
</script>
<template>
<div class="bg-white rounded-lg shadow-md p-6">
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">发件人</label>
<select
v-model="currentSender"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option v-for="sender in senders" :key="sender.id" :value="sender">
{{ sender.email }}
</option>
</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">
<input
v-model="emailForm.to"
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="showContactSelector = true"
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="showImportContacts = true"
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-upload mr-1"></i> 导入
</button>
</div>
</div>
<div class="mb-4">
<label class="block text-gray-700 mb-2 font-medium">抄送人</label>
<input
v-model="emailForm.cc"
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>
<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>
<select
v-model="selectedVariableTemplate"
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"
@change="applyVariableTemplate"
>
<option value="">-- 选择模板 --</option>
<option v-for="template in variableTemplates" :key="template.id" :value="template">
{{ template.name }}
</option>
</select>
</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-variable mr-1"></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="border-2 border-dashed border-gray-300 rounded-md p-6 text-center hover:border-blue-500 transition-colors"
>
<input type="file" id="attachment" class="hidden" multiple @change="handleFileUpload" />
<label for="attachment" class="cursor-pointer">
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
<p class="text-gray-600">点击或拖拽文件到此处上传</p>
<p class="text-sm text-gray-500 mt-1">支持多种格式文件</p>
</label>
<div v-if="attachments.length > 0" class="mt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">已上传附件:</h4>
<div class="space-y-2">
<div
v-for="(file, index) in attachments"
:key="index"
class="flex items-center p-2 bg-gray-50 rounded"
>
<i class="fas fa-file mr-2 text-gray-500"></i>
<span class="flex-1 text-sm truncate">{{ file.name }}</span>
<button @click="removeAttachment(index)" class="text-red-500 hover:text-red-700">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div 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.sendTime"
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="saveAsDraft"
class="px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
保存草稿
</button>
<button
@click="showPreview = true"
class="px-6 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
>
预览
</button>
<button
@click="sendEmail"
class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700 transition-colors"
>
发送
</button>
</div>
<!-- 联系人选择弹窗 -->
<ContactSelector
v-if="showContactSelector"
:contacts="contacts"
@confirm-selection="confirmContactSelection"
@close="showContactSelector = false"
/>
<!-- 变量选择弹窗 -->
<VariableSelector
v-if="showVariableSelector"
:variables="variables"
@insert-variable="insertVariable"
@close="showVariableSelector = false"
/>
<!-- 邮件预览弹窗 -->
<EmailPreview
v-if="showPreview"
:email-form="emailForm"
:sender="currentSender?.email || ''"
:attachments="attachments"
@confirm-send="confirmSendEmail"
@close="showPreview = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, computed } from 'vue'
import ContactSelector from './ContactSelector.vue'
import VariableSelector from './VariableSelector.vue'
import EmailPreview from './EmailPreview.vue'
import { Sender, Contact, Variable, VariableTemplate, Email, EmailForm } from '../types'
const props = defineProps({
senders: {
type: Array as () => Sender[],
required: true,
},
contacts: {
type: Array as () => Contact[],
required: true,
},
variables: {
type: Array as () => Variable[],
required: true,
},
variableTemplates: {
type: Array as () => VariableTemplate[],
required: true,
},
emails: {
type: Array as () => Email[],
required: true,
},
})
const emits = defineEmits(['save-email'])
// 状态
const currentSender = ref<Sender | null>(props.senders.length > 0 ? props.senders[0] : null)
const emailForm = ref<EmailForm>({
to: '',
cc: '',
subject: '',
content: '',
scheduleSend: false,
sendTime: '',
})
const selectedVariableTemplate = ref<VariableTemplate | null>(null)
const attachments = ref<File[]>([])
const showContactSelector = ref(false)
const showVariableSelector = ref(false)
const showPreview = ref(false)
const showImportContacts = ref(false)
// 计算属性
const variablePrefix = '{{'
// 方法
const handleFileUpload = (e: Event) => {
const input = e.target as HTMLInputElement
if (input.files) {
attachments.value = [...attachments.value, ...Array.from(input.files)]
// 重置输入,允许重复选择同一文件
input.value = ''
}
}
const removeAttachment = (index: number) => {
attachments.value.splice(index, 1)
}
const applyVariableTemplate = () => {
if (!selectedVariableTemplate.value) return
// 根据模板预设内容
const variableKeys = selectedVariableTemplate.value.variableIds.map((id) => {
const variable = props.variables.find((v) => v.id === id)
return variable ? variable.key : ''
})
const variablesText = variableKeys.map((key) => `${variablePrefix}${key}}`).join(', ')
// 在内容前添加模板提示
emailForm.value.content = `【使用了模板变量:${variablesText}】\n\n${emailForm.value.content}`
}
const insertVariable = (variable: Variable) => {
emailForm.value.content += `${variablePrefix}${variable.key}}`
showVariableSelector.value = false
}
const confirmContactSelection = (selected: { to: string; cc: string }) => {
emailForm.value.to = selected.to
emailForm.value.cc = selected.cc
showContactSelector.value = false
}
const saveAsDraft = () => {
if (!currentSender.value) {
alert('请添加并选择发件人')
return
}
// 保存为草稿
const draft: Email = {
id: Date.now().toString(),
sender: currentSender.value.email,
to: emailForm.value.to,
cc: emailForm.value.cc,
subject: emailForm.value.subject || '无主题',
content: emailForm.value.content,
sendTime: new Date().toISOString(),
status: 'draft',
attachments: attachments.value.map((file) => ({ name: file.name })),
}
emits('save-email', draft)
alert('草稿已保存')
}
const sendEmail = () => {
// 显示预览
showPreview.value = true
}
const confirmSendEmail = () => {
if (!currentSender.value) {
alert('请添加并选择发件人')
return
}
if (!emailForm.value.to) {
alert('请填写收件人')
return
}
if (!emailForm.value.subject) {
if (!confirm('确定不填写邮件主题吗?')) {
return
}
}
// 创建邮件记录
const email: Email = {
id: Date.now().toString(),
sender: currentSender.value.email,
to: emailForm.value.to,
cc: emailForm.value.cc,
subject: emailForm.value.subject || '无主题',
content: emailForm.value.content,
sendTime:
emailForm.value.scheduleSend && emailForm.value.sendTime
? emailForm.value.sendTime
: new Date().toISOString(),
status: emailForm.value.scheduleSend ? 'scheduled' : 'sent',
attachments: attachments.value.map((file) => ({ name: file.name })),
}
emits('save-email', email)
// 重置表单
emailForm.value = {
to: '',
cc: '',
subject: '',
content: '',
scheduleSend: false,
sendTime: '',
}
attachments.value = []
selectedVariableTemplate.value = null
showPreview.value = false
alert(emailForm.value.scheduleSend ? '邮件已安排定时发送' : '邮件发送成功')
}
</script>
<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 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">选择联系人</h3>
<button @click="$emit('close')">
<i class="fas fa-times text-gray-500"></i>
</button>
</div>
<div class="p-4 flex-1 overflow-y-auto">
<div class="mb-4">
<input
v-model="searchTerm"
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="space-y-2">
<div
v-for="contact in filteredContacts"
:key="contact.id"
class="flex items-center p-3 border border-gray-200 rounded-md hover:bg-blue-50 cursor-pointer"
@click="toggleSelection(contact)"
>
<input
type="checkbox"
:id="'contact-' + contact.id"
:checked="selectedContacts.includes(contact.id)"
class="mr-3"
/>
<label for="'contact-' + contact.id" class="flex-1">
<div class="font-medium">{{ contact.name }}</div>
<div class="text-sm text-gray-500">{{ contact.email }}</div>
</label>
<div class="text-sm text-gray-500">{{ contact.company || '' }}</div>
</div>
</div>
<div v-if="filteredContacts.length === 0" class="p-6 text-center text-gray-500">
<p>未找到匹配的联系人</p>
</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="confirmSelection"
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 { ref, computed, defineProps, defineEmits } from 'vue'
import { Contact } from '../types'
const props = defineProps({
contacts: {
type: Array as () => Contact[],
required: true,
},
})
const emits = defineEmits(['confirm-selection', 'close'])
// 状态
const searchTerm = ref('')
const selectedContacts = ref<string[]>([])
// 计算属性
const filteredContacts = computed(() => {
return props.contacts.filter(
(contact) =>
contact.name.toLowerCase().includes(searchTerm.value.toLowerCase()) ||
contact.email.toLowerCase().includes(searchTerm.value.toLowerCase()) ||
contact.company.toLowerCase().includes(searchTerm.value.toLowerCase()),
)
})
// 方法
const toggleSelection = (contact: Contact) => {
const index = selectedContacts.value.indexOf(contact.id)
if (index > -1) {
selectedContacts.value.splice(index, 1)
} else {
selectedContacts.value.push(contact.id)
}
}
const confirmSelection = () => {
const selected = props.contacts.filter((contact) => selectedContacts.value.includes(contact.id))
const to = selected.map((contact) => contact.email).join(',')
const cc = selected
.map((contact) => contact.ccEmail)
.filter(Boolean)
.join(',')
emits('confirm-selection', { to, cc })
}
</script>
<template>
<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 flex gap-2">
<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>
<select
v-model="filterStatus"
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="sent">已发送</option>
<option value="scheduled">已定时</option>
<option value="draft">草稿</option>
<option value="failed">发送失败</option>
</select>
</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-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="email in filteredEmails" :key="email.id">
<td class="px-6 py-4 whitespace-nowrap">{{ email.sender }}</td>
<td class="px-6 py-4 whitespace-nowrap max-w-xs truncate">{{ email.to }}</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">
{{ formatDate(email.sendTime) }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full"
:class="
email.status === 'sent'
? 'bg-green-100 text-green-800'
: email.status === 'scheduled'
? 'bg-yellow-100 text-yellow-800'
: email.status === 'draft'
? 'bg-gray-100 text-gray-800'
: 'bg-red-100 text-red-800'
"
>
{{
email.status === 'sent'
? '已发送'
: email.status === 'scheduled'
? '已定时'
: email.status === 'draft'
? '草稿'
: '发送失败'
}}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
@click="viewEmailDetail(email)"
class="text-blue-600 hover:text-blue-900 mr-3"
>
查看
</button>
<button @click="reuseEmailContent(email)" class="text-green-600 hover:text-green-900">
复用
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="filteredEmails.length === 0" class="p-8 text-center text-gray-500">
<i class="fas fa-history text-4xl mb-3 opacity-30"></i>
<p>暂无邮件发送记录</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps, defineEmits } from 'vue'
import { Email } from '../types'
const props = defineProps({
emails: {
type: Array as () => Email[],
required: true,
},
})
const emits = defineEmits(['reuse-email'])
// 状态
const searchTerm = ref('')
const filterStatus = ref('')
// 计算属性
const filteredEmails = computed(() => {
return props.emails
.filter((email) => {
const matchesSearch =
email.subject.toLowerCase().includes(searchTerm.value.toLowerCase()) ||
email.to.toLowerCase().includes(searchTerm.value.toLowerCase())
const matchesStatus = !filterStatus.value || email.status === filterStatus.value
return matchesSearch && matchesStatus
})
.sort((a, b) => new Date(b.sendTime).getTime() - new Date(a.sendTime).getTime())
})
// 方法
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleString()
}
const viewEmailDetail = (email: Email) => {
// 显示邮件详情
alert(`邮件主题: ${email.subject}\n收件人: ${email.to}\n发送时间: ${formatDate(email.sendTime)}`)
// 实际项目中可以打开详情弹窗
}
const reuseEmailContent = (email: Email) => {
// 触发复用邮件内容事件
emits('reuse-email', {
subject: email.subject,
content: email.content,
})
}
</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-[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="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100 p-4 sm:p-6"
>
<!-- 主卡片容器 -->
<div
class="w-full max-w-md bg-white rounded-xl shadow-lg overflow-hidden transform transition-all duration-300 hover:shadow-xl"
>
<!-- 顶部蓝色装饰条,与登录页面保持一致 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-700"></div>
<!-- 内容区域 -->
<div class="p-6 sm:p-8">
<!-- 标题和描述 -->
<div class="text-center mb-8">
<div
class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 mb-4"
>
<i class="fas fa-key text-2xl text-blue-600"></i>
</div>
<h2 class="text-[clamp(1.5rem,3vw,1.8rem)] font-bold text-gray-800">
{{ currentStep === 1 ? '找回密码' : currentStep === 2 ? '验证身份' : '重置密码' }}
</h2>
<p class="text-gray-500 mt-2">
{{
currentStep === 1
? '请输入您的注册邮箱,我们将发送验证码到该邮箱'
: currentStep === 2
? '请输入邮箱收到的6位验证码'
: '请设置新密码并确认'
}}
</p>
</div>
<!-- 步骤指示器 -->
<div class="flex items-center justify-between mb-8 px-4">
<div class="flex flex-col items-center">
<div
:class="[
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium',
currentStep >= 1 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500',
]"
>
1
</div>
<span class="text-xs mt-1 text-gray-500">邮箱验证</span>
</div>
<div class="flex-1 mx-2 h-1">
<div
:class="[
'h-full transition-all duration-500',
currentStep >= 2 ? 'bg-blue-500' : 'bg-gray-200',
]"
></div>
</div>
<div class="flex flex-col items-center">
<div
:class="[
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium',
currentStep >= 2 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500',
]"
>
2
</div>
<span class="text-xs mt-1 text-gray-500">安全验证</span>
</div>
<div class="flex-1 mx-2 h-1">
<div
:class="[
'h-full transition-all duration-500',
currentStep >= 3 ? 'bg-blue-500' : 'bg-gray-200',
]"
></div>
</div>
<div class="flex flex-col items-center">
<div
:class="[
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium',
currentStep >= 3 ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-500',
]"
>
3
</div>
<span class="text-xs mt-1 text-gray-500">设置新密码</span>
</div>
</div>
<!-- 步骤1:输入邮箱 -->
<form v-if="currentStep === 1" @submit.prevent="sendVerificationCode" class="space-y-5">
<div class="space-y-2">
<label for="email" class="block text-sm font-medium text-gray-700"> 注册邮箱 </label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-gray-400"></i>
</div>
<input
id="email"
v-model="email"
type="email"
required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 placeholder-gray-400"
placeholder="请输入您的注册邮箱"
:class="emailFocused ? 'ring-2 ring-blue-200 border-blue-300' : ''"
@focus="emailFocused = true"
@blur="emailFocused = false"
/>
</div>
<p v-if="emailError" class="text-sm text-red-500">{{ emailError }}</p>
</div>
<div class="pt-2">
<button
type="submit"
class="w-full bg-blue-500 hover:bg-blue-700 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98] focus:ring-4 focus:ring-blue-300"
:disabled="isSubmitting"
>
<span v-if="!isSubmitting">发送验证码</span>
<span v-if="isSubmitting" class="flex items-center justify-center">
<i class="fas fa-spinner fa-spin mr-2"></i>
发送中...
</span>
</button>
</div>
</form>
<!-- 步骤2:输入验证码 -->
<form v-if="currentStep === 2" @submit.prevent="verifyCode" class="space-y-5">
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700"> 验证码 </label>
<!-- 验证码输入框组 -->
<div class="flex justify-between gap-2">
<input
v-for="(digit, index) in 6"
:key="index"
v-model="verificationCode[index]"
type="text"
maxlength="1"
class="w-full h-14 text-center text-lg font-bold border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200"
@input="handleCodeInput(index)"
@focus="codeFocused[index] = true"
@blur="codeFocused[index] = false"
:class="codeFocused[index] ? 'ring-2 ring-blue-200 border-blue-300' : ''"
/>
</div>
<div class="flex justify-between items-center">
<p v-if="codeError" class="text-sm text-red-500">{{ codeError }}</p>
<button
type="button"
class="text-sm text-blue-600 hover:text-blue-800 transition-colors"
:disabled="!canResend"
@click="resendCode"
>
{{ canResend ? '重新发送' : `重新发送(${countdown}s)` }}
</button>
</div>
</div>
<div class="pt-2">
<button
type="submit"
class="w-full bg-blue-500 hover:bg-blue-700 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98] focus:ring-4 focus:ring-blue-300"
:disabled="isSubmitting || verificationCode.join('').length < 6"
>
<span v-if="!isSubmitting">验证并继续</span>
<span v-if="isSubmitting" class="flex items-center justify-center">
<i class="fas fa-spinner fa-spin mr-2"></i>
验证中...
</span>
</button>
</div>
</form>
<!-- 步骤3:设置新密码 -->
<form v-if="currentStep === 3" @submit.prevent="resetPassword" class="space-y-5">
<div class="space-y-2">
<label for="newPassword" class="block text-sm font-medium text-gray-700">
新密码
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-gray-400"></i>
</div>
<input
id="newPassword"
v-model="newPassword"
:type="showNewPassword ? 'text' : 'password'"
required
class="block w-full pl-10 pr-10 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 placeholder-gray-400"
placeholder="请设置新密码"
:class="newPasswordFocused ? 'ring-2 ring-blue-200 border-blue-300' : ''"
@focus="newPasswordFocused = true"
@blur="newPasswordFocused = false"
/>
<button
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
@click="showNewPassword = !showNewPassword"
>
<i :class="showNewPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
<div class="flex items-center text-xs text-gray-500">
<i class="fas fa-info-circle mr-1"></i>
<span>密码长度至少8位,包含字母和数字</span>
</div>
<p v-if="passwordError" class="text-sm text-red-500">{{ passwordError }}</p>
</div>
<div class="space-y-2">
<label for="confirmPassword" class="block text-sm font-medium text-gray-700">
确认新密码
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-gray-400"></i>
</div>
<input
id="confirmPassword"
v-model="confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
required
class="block w-full pl-10 pr-10 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 placeholder-gray-400"
placeholder="请再次输入新密码"
:class="confirmPasswordFocused ? 'ring-2 ring-blue-200 border-blue-300' : ''"
@focus="confirmPasswordFocused = true"
@blur="confirmPasswordFocused = false"
/>
<button
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
@click="showConfirmPassword = !showConfirmPassword"
>
<i :class="showConfirmPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
<p v-if="confirmError" class="text-sm text-red-500">{{ confirmError }}</p>
</div>
<div class="pt-2">
<button
type="submit"
class="w-full bg-blue-500 hover:bg-blue-700 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98] focus:ring-4 focus:ring-blue-300"
:disabled="isSubmitting || !isPasswordValid"
>
<span v-if="!isSubmitting">确认重置密码</span>
<span v-if="isSubmitting" class="flex items-center justify-center">
<i class="fas fa-spinner fa-spin mr-2"></i>
处理中...
</span>
</button>
</div>
</form>
<!-- 成功提示 -->
<div v-if="resetSuccess" class="text-center py-6">
<div
class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4"
>
<i class="fas fa-check text-2xl text-green-600"></i>
</div>
<h3 class="text-xl font-bold text-gray-800 mb-2">密码重置成功!</h3>
<p class="text-gray-600 mb-6">您的密码已成功更新,请使用新密码登录</p>
<button
@click="goToLogin"
class="w-full bg-blue-500 hover:bg-blue-700 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200"
>
返回登录
</button>
</div>
<!-- 返回登录链接 -->
<div v-if="currentStep < 3 && !resetSuccess" class="mt-8 text-center">
<button
type="button"
@click="goToLogin"
class="text-blue-600 hover:text-blue-800 font-medium transition-colors"
>
<i class="fas fa-arrow-left mr-1"></i> 返回登录页
</button>
</div>
</div>
<!-- 底部版权信息 -->
<div class="bg-gray-50 px-6 py-4 text-center text-sm text-gray-500 border-t border-gray-100">
<p>© 2024 邮件系统. 保留所有权利.</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { defineEmits } from 'vue'
// 定义事件
const emits = defineEmits(['go-to-login'])
// 状态管理
const currentStep = ref(1)
const isSubmitting = ref(false)
const resetSuccess = ref(false)
// 步骤1:邮箱相关
const email = ref('')
const emailFocused = ref(false)
const emailError = ref('')
// 步骤2:验证码相关
const verificationCode = ref(Array(6).fill(''))
const codeFocused = ref(Array(6).fill(false))
const codeError = ref('')
const countdown = ref(60)
const canResend = ref(false)
let countdownTimer: NodeJS.Timeout | null = null
// 步骤3:密码相关
const newPassword = ref('')
const confirmPassword = ref('')
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
const newPasswordFocused = ref(false)
const confirmPasswordFocused = ref(false)
const passwordError = ref('')
const confirmError = ref('')
// 密码验证
const isPasswordValid = computed(() => {
// 简单验证:至少8位,包含字母和数字
const hasMinLength = newPassword.value.length >= 8
const hasLetters = /[a-zA-Z]/.test(newPassword.value)
const hasNumbers = /\d/.test(newPassword.value)
const passwordsMatch = newPassword.value === confirmPassword.value
// 更新错误信息
if (!hasMinLength) {
passwordError.value = '密码长度至少8位'
} else if (!hasLetters || !hasNumbers) {
passwordError.value = '密码必须包含字母和数字'
} else {
passwordError.value = ''
}
// 确认密码错误信息
confirmError.value = passwordsMatch ? '' : '两次输入的密码不一致'
return hasMinLength && hasLetters && hasNumbers && passwordsMatch
})
// 监听密码变化,实时验证
watch([newPassword, confirmPassword], () => {
// 触发计算属性更新
isPasswordValid.value
})
// 发送验证码
const sendVerificationCode = async () => {
// 简单邮箱验证
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email.value)) {
emailError.value = '请输入有效的邮箱地址'
return
}
emailError.value = ''
isSubmitting.value = true
try {
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 1500))
// 成功后进入下一步
currentStep.value = 2
startCountdown()
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
} catch (error) {
console.error('发送验证码失败:', error)
emailError.value = '发送验证码失败,请稍后重试'
} finally {
isSubmitting.value = false
}
}
// 验证码输入处理
const handleCodeInput = (index: number) => {
// 只允许数字
verificationCode.value[index] = verificationCode.value[index].replace(/[^0-9]/g, '')
// 自动跳到下一个输入框
if (verificationCode.value[index] && index < 5) {
const nextInput = document.querySelector(`input:nth-child(${index + 2})`)
nextInput?.focus()
}
}
// 验证验证码
const verifyCode = async () => {
const code = verificationCode.value.join('')
// 简单验证
if (code.length < 6) {
codeError.value = '请输入完整的验证码'
return
}
codeError.value = ''
isSubmitting.value = true
try {
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 1500))
// 成功后进入下一步
currentStep.value = 3
// 滚动到顶部
window.scrollTo({ top: 0, behavior: 'smooth' })
} catch (error) {
console.error('验证验证码失败:', error)
codeError.value = '验证码不正确或已过期,请重新输入'
} finally {
isSubmitting.value = false
}
}
// 重新发送验证码
const resendCode = () => {
if (!canResend.value) return
isSubmitting.value = true
codeError.value = ''
try {
// 模拟API请求
return new Promise((resolve) =>
setTimeout(() => {
verificationCode.value = Array(6).fill('')
startCountdown()
isSubmitting.value = false
resolve(true)
}, 1000),
)
} catch (error) {
console.error('重新发送验证码失败:', error)
codeError.value = '发送失败,请稍后重试'
isSubmitting.value = false
}
}
// 开始倒计时
const startCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer)
}
countdown.value = 60
canResend.value = false
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(countdownTimer as NodeJS.Timeout)
canResend.value = true
}
}, 1000)
}
// 重置密码
const resetPassword = async () => {
if (!isPasswordValid.value) return
isSubmitting.value = true
try {
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 1500))
// 显示成功信息
resetSuccess.value = true
} catch (error) {
console.error('重置密码失败:', error)
passwordError.value = '重置密码失败,请稍后重试'
} finally {
isSubmitting.value = false
}
}
// 返回登录页面
const goToLogin = () => {
emits('go-to-login')
}
// 清理定时器
onMounted(() => {
return () => {
if (countdownTimer) {
clearInterval(countdownTimer)
}
}
})
</script>
<style scoped>
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bg-white {
animation: fadeIn 0.5s ease-out forwards;
}
/* 输入框聚焦效果 */
input:focus {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
/* 按钮交互效果 */
button:hover {
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* 验证码输入框样式优化 */
input[type='text']:nth-child(-n + 6) {
font-size: 1.25rem;
}
/* 步骤切换动画 */
form {
animation: fadeIn 0.3s ease-out forwards;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
.bg-white {
background-color: #1f2937;
}
.text-gray-800 {
color: #f9fafb;
}
.text-gray-700 {
color: #e5e7eb;
}
.text-gray-600 {
color: #d1d5db;
}
.text-gray-500 {
color: #9ca3af;
}
.border-gray-300 {
border-color: #4b5563;
}
.bg-gray-50 {
background-color: #111827;
}
.bg-blue-100 {
background-color: #1e3a8a;
}
.bg-green-100 {
background-color: #065f46;
}
}
</style>
<template>
<!-- 全屏背景容器,带有渐变效果 -->
<div
class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100 p-4 sm:p-6"
>
<!-- 登录卡片容器,带阴影和动画 -->
<div
class="w-full max-w-md bg-white rounded-xl shadow-lg overflow-hidden transform transition-all duration-300 hover:shadow-xl"
>
<!-- 顶部蓝色装饰条 -->
<div class="h-1.5 bg-gradient-to-r from-blue-500 to-blue-700"></div>
<!-- 登录内容区域 -->
<div class="p-6 sm:p-8">
<!-- 图标和标题区域 -->
<div class="text-center mb-8">
<div
class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 mb-4"
>
<i class="fas fa-envelope text-2xl text-blue-600"></i>
</div>
<h2 class="text-[clamp(1.5rem,3vw,1.8rem)] font-bold text-gray-800">邮件系统</h2>
<p class="text-gray-500 mt-2">请登录您的账号以继续</p>
</div>
<!-- 登录表单 -->
<form @submit.prevent="handleLogin" class="space-y-5">
<!-- 用户名输入框 -->
<div class="space-y-2">
<label for="username" class="block text-sm font-medium text-gray-700"> 用户名 </label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-gray-400"></i>
</div>
<input
id="username"
v-model="username"
type="text"
required
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 placeholder-gray-400"
placeholder="请输入用户名"
:class="usernameFocused ? 'ring-2 ring-blue-200 border-blue-300' : ''"
@focus="usernameFocused = true"
@blur="usernameFocused = false"
/>
</div>
</div>
<!-- 密码输入框 -->
<div class="space-y-2">
<div class="flex justify-between items-center">
<label for="password" class="block text-sm font-medium text-gray-700"> 密码 </label>
<button
type="button"
class="text-sm text-blue-600 hover:text-blue-800 transition-colors"
@click="handleForgotPassword"
>
忘记密码?
</button>
</div>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-gray-400"></i>
</div>
<input
id="password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
required
class="block w-full pl-10 pr-10 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 placeholder-gray-400"
placeholder="请输入密码"
:class="passwordFocused ? 'ring-2 ring-blue-200 border-blue-300' : ''"
@focus="passwordFocused = true"
@blur="passwordFocused = false"
/>
<button
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'"></i>
</button>
</div>
</div>
<!-- 记住我选项 -->
<div class="flex items-center">
<input
id="remember-me"
v-model="rememberMe"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label for="remember-me" class="ml-2 block text-sm text-gray-700"> 记住我 </label>
</div>
<!-- 登录按钮 -->
<button
type="submit"
class="w-full bg-blue-500 hover:bg-blue-700 text-white font-medium py-3 px-4 rounded-lg transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98] focus:ring-4 focus:ring-blue-300"
:disabled="isSubmitting"
>
<span v-if="!isSubmitting">登录系统</span>
<span v-if="isSubmitting" class="flex items-center justify-center">
<i class="fas fa-spinner fa-spin mr-2"></i>
登录中...
</span>
</button>
</form>
<!-- 底部分隔线和其他登录方式 -->
<div class="mt-8">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">其他登录方式</span>
</div>
</div>
<div class="mt-6 grid grid-cols-2 gap-3">
<button
class="flex items-center justify-center py-2 px-4 border border-gray-300 rounded-lg shadow-sm bg-white text-gray-700 hover:bg-gray-50 transition-colors"
>
<i class="fab fa-github mr-2 text-gray-800"></i>
<span>GitHub</span>
</button>
<button
class="flex items-center justify-center py-2 px-4 border border-gray-300 rounded-lg shadow-sm bg-white text-gray-700 hover:bg-gray-50 transition-colors"
>
<i class="fab fa-google mr-2 text-red-500"></i>
<span>Google</span>
</button>
</div>
</div>
</div>
<!-- 底部版权信息 -->
<div class="bg-gray-50 px-6 py-4 text-center text-sm text-gray-500 border-t border-gray-100">
<p>© 2024 邮件系统. 保留所有权利.</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineEmits } from 'vue'
// 定义事件 - 登录成功和跳转到忘记密码页面
const emits = defineEmits(['login', 'go-to-forgot-password'])
// 状态管理
const username = ref('')
const password = ref('')
const rememberMe = ref(false)
const showPassword = ref(false)
const isSubmitting = ref(false)
const usernameFocused = ref(false)
const passwordFocused = ref(false)
// 登录处理
const handleLogin = async () => {
// 模拟登录加载状态
isSubmitting.value = true
try {
// 模拟API请求延迟
await new Promise((resolve) => setTimeout(resolve, 1200))
// 验证用户名和密码(实际项目中应调用后端接口)
if (username.value && password.value) {
// 如果勾选了记住我,保存用户名到本地存储
if (rememberMe.value) {
localStorage.setItem(
'savedEmailUser',
JSON.stringify({
username: username.value,
}),
)
} else {
// 否则清除本地存储
localStorage.removeItem('savedEmailUser')
}
// 触发登录事件,传递用户信息
emits('login', {
username: username.value,
password: password.value,
})
} else {
alert('请输入完整的用户名和密码')
}
} catch (error) {
console.error('登录失败:', error)
alert('登录失败,请稍后重试')
} finally {
// 重置加载状态
isSubmitting.value = false
}
}
// 处理忘记密码点击事件
const handleForgotPassword = () => {
// 触发跳转到忘记密码页面的事件
emits('go-to-forgot-password')
}
// 页面加载时自动填充(如果有记住的用户信息)
const loadSavedCredentials = () => {
const savedUser = localStorage.getItem('savedEmailUser')
if (savedUser) {
const user = JSON.parse(savedUser)
username.value = user.username
rememberMe.value = true
}
}
// 初始化加载
loadSavedCredentials()
</script>
<style scoped>
/* 自定义动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 登录卡片动画 */
.bg-white {
animation: fadeIn 0.5s ease-out forwards;
}
/* 输入框聚焦状态优化 */
input:focus {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
/* 按钮交互效果增强 */
button:hover {
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* 适配暗色模式 */
@media (prefers-color-scheme: dark) {
.bg-white {
background-color: #1f2937;
}
.text-gray-800 {
color: #f9fafb;
}
.text-gray-700 {
color: #e5e7eb;
}
.text-gray-500 {
color: #9ca3af;
}
.border-gray-300 {
border-color: #4b5563;
}
.bg-gray-50 {
background-color: #111827;
}
.bg-blue-100 {
background-color: #1e3a8a;
}
}
</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>
<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>
<template>
<aside
class="bg-sky-700 text-white w-64 flex-shrink-0 hidden md:block transition-all duration-300 ease-in-out"
>
<div class="p-4 border-b border-blue-500">
<h1 class="text-xl font-bold">邮件系统</h1>
</div>
<nav class="p-4">
<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>
</aside>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
currentPage: {
type: String,
required: true,
},
})
const emits = defineEmits(['change-page', 'logout'])
</script>
<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="editingVariableId"
@click="resetVariableForm"
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="variableForm.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="例如:用户名"
/>
</div>
<div>
<label class="block text-gray-700 mb-1 text-sm">变量标识 *</label>
<div class="flex">
<span
class="inline-flex items-center px-3 py-2 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500"
>
{{ variablePrefix }}
</span>
<input
v-model="variableForm.key"
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"
placeholder="例如:username"
/>
</div>
<p class="text-xs text-gray-500 mt-1">
变量将以 {{ variablePrefix }}key 形式插入邮件内容
</p>
</div>
<div class="md:col-span-2">
<label class="block text-gray-700 mb-1 text-sm">变量描述</label>
<input
v-model="variableForm.description"
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="saveVariable"
class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700 transition-colors"
:disabled="!variableForm.name || !variableForm.key"
>
{{ editingVariableId ? '更新变量' : '添加变量' }}
</button>
</div>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-6">
<div class="p-6 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">变量模板</h3>
<button
@click="showCreateTemplateModal(true)"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
>
<i class="fas fa-plus mr-1"></i> 创建模板
</button>
</div>
</div>
<div class="p-6">
<div
v-for="template in variableTemplates"
:key="template.id"
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">
<h4 class="font-medium">{{ template.name }}</h4>
<div>
<button
@click="editVariableTemplate(template)"
class="text-blue-600 hover:text-blue-900 mr-3 text-sm"
>
<i class="fas fa-edit"></i>
</button>
<button
@click="deleteVariableTemplate(template.id)"
class="text-red-600 hover:text-red-900 text-sm"
>
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<p class="text-sm text-gray-600 mb-3">{{ template.description || '无描述' }}</p>
<div class="flex flex-wrap gap-2">
<span
v-for="variableId in template.variableIds"
:key="variableId"
class="px-2 py-1 bg-blue-50 text-blue-700 rounded text-xs"
>
{{ variablePrefix }}{{ getVariableKeyById(variableId) }}
</span>
</div>
<div class="mt-3 flex justify-end">
<button
@click="generateExcelTemplate(template)"
class="text-sm text-blue-600 hover:text-blue-900 flex items-center"
>
<i class="fas fa-file-excel mr-1"></i> 生成Excel模板
</button>
</div>
</div>
<div v-if="variableTemplates.length === 0" class="p-6 text-center text-gray-500">
<i class="fas fa-file-code text-4xl mb-3 opacity-30"></i>
<p>暂无变量模板,请创建模板</p>
</div>
</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"
>
变量标识
</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="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 font-mono text-sm">
{{ variablePrefix }}{{ variable.key }}
</td>
<td class="px-6 py-4">{{ variable.description || '-' }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
@click="editVariable(variable)"
class="text-blue-600 hover:text-blue-900 mr-3"
>
编辑
</button>
<button
@click="deleteVariable(variable.id)"
class="text-red-600 hover:text-red-900"
>
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="variables.length === 0" class="p-8 text-center text-gray-500">
<i class="fas fa-variable text-4xl mb-3 opacity-30"></i>
<p>暂无变量,请添加变量</p>
</div>
</div>
<!-- 变量模板弹窗 -->
<div
v-if="showTemplateModal"
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">
{{ 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.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 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"
>
<div
v-for="variable in variables"
:key="variable.id"
class="flex items-center p-2 hover:bg-blue-50 rounded"
>
<input
type="checkbox"
:id="'template-var-' + variable.id"
:checked="templateForm.variableIds?.includes(variable.id)"
class="mr-3"
@change="toggleTemplateVariable(variable.id)"
/>
<label for="'template-var-' + variable.id">
<div class="font-medium font-mono text-sm">
{{ variablePrefix }}{{ variable.key }}
</div>
<div class="text-xs text-gray-500">{{ variable.name }}</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"
:disabled="
!templateForm.name ||
!templateForm.variableIds ||
templateForm.variableIds.length === 0
"
>
{{ editingTemplateId ? '更新模板' : '创建模板' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits } from 'vue'
import { Variable, VariableTemplate } from '../types'
const props = defineProps({
variables: {
type: Array as () => Variable[],
required: true,
},
variableTemplates: {
type: Array as () => VariableTemplate[],
required: true,
},
})
const emits = defineEmits(['update-variables', 'update-variable-templates'])
// 状态
const variables = ref<Variable[]>([...props.variables])
const variableTemplates = ref<VariableTemplate[]>([...props.variableTemplates])
const variablePrefix = '{{'
// 变量表单
const editingVariableId = ref('')
const variableForm = ref<Partial<Variable>>({
name: '',
key: '',
description: '',
})
// 模板相关
const showTemplateModal = ref(false)
const editingTemplateId = ref('')
const templateForm = ref<Partial<VariableTemplate>>({
name: '',
description: '',
variableIds: [],
})
// 方法 - 变量管理
const resetVariableForm = () => {
editingVariableId.value = ''
variableForm.value = {
name: '',
key: '',
description: '',
}
}
const saveVariable = () => {
if (!variableForm.value.name || !variableForm.value.key) return
// 检查变量标识是否已存在
const exists = variables.value.some(
(v) => v.key === variableForm.value.key && v.id !== editingVariableId.value,
)
if (exists) {
alert('变量标识已存在,请使用其他标识')
return
}
if (editingVariableId.value) {
// 更新现有变量
const index = variables.value.findIndex((v) => v.id === editingVariableId.value)
if (index > -1) {
variables.value[index] = {
...variables.value[index],
...variableForm.value,
} as Variable
emits('update-variables', [...variables.value])
alert('变量更新成功')
}
} else {
// 添加新变量
const newVariable: Variable = {
id: Date.now().toString(),
name: variableForm.value.name || '',
key: variableForm.value.key || '',
description: variableForm.value.description || '',
}
variables.value.push(newVariable)
emits('update-variables', [...variables.value])
alert('变量添加成功')
}
resetVariableForm()
}
const editVariable = (variable: Variable) => {
editingVariableId.value = variable.id
variableForm.value = { ...variable }
}
const deleteVariable = (id: string) => {
if (confirm('确定要删除这个变量吗?这可能会影响使用该变量的模板。')) {
// 从变量列表中删除
variables.value = variables.value.filter((variable) => variable.id !== id)
emits('update-variables', [...variables.value])
// 从所有模板中移除该变量
variableTemplates.value = variableTemplates.value.map((template) => ({
...template,
variableIds: template.variableIds.filter((vid) => vid !== id),
}))
emits('update-variable-templates', [...variableTemplates.value])
}
}
// 方法 - 模板管理
const showCreateTemplateModal = (isNew: boolean) => {
showTemplateModal.value = true
if (isNew) {
editingTemplateId.value = ''
templateForm.value = {
name: '',
description: '',
variableIds: [],
}
}
}
const editVariableTemplate = (template: VariableTemplate) => {
editingTemplateId.value = template.id
templateForm.value = { ...template }
showTemplateModal.value = true
}
const closeTemplateModal = () => {
showTemplateModal.value = false
editingTemplateId.value = ''
templateForm.value = {
name: '',
description: '',
variableIds: [],
}
}
const toggleTemplateVariable = (variableId: string) => {
if (!templateForm.value.variableIds) {
templateForm.value.variableIds = []
}
const index = templateForm.value.variableIds.indexOf(variableId)
if (index > -1) {
templateForm.value.variableIds.splice(index, 1)
} else {
templateForm.value.variableIds.push(variableId)
}
}
const saveVariableTemplate = () => {
if (
!templateForm.value.name ||
!templateForm.value.variableIds ||
templateForm.value.variableIds.length === 0
)
return
if (editingTemplateId.value) {
// 更新现有模板
const index = variableTemplates.value.findIndex((t) => t.id === editingTemplateId.value)
if (index > -1) {
variableTemplates.value[index] = {
id: editingTemplateId.value,
name: templateForm.value.name || '',
description: templateForm.value.description || '',
variableIds: templateForm.value.variableIds || [],
}
emits('update-variable-templates', [...variableTemplates.value])
}
} else {
// 创建新模板
const newTemplate: VariableTemplate = {
id: Date.now().toString(),
name: templateForm.value.name || '',
description: templateForm.value.description || '',
variableIds: templateForm.value.variableIds || [],
}
variableTemplates.value.push(newTemplate)
emits('update-variable-templates', [...variableTemplates.value])
}
closeTemplateModal()
alert(editingTemplateId.value ? '模板更新成功' : '模板创建成功')
}
const deleteVariableTemplate = (id: string) => {
if (confirm('确定要删除这个模板吗?')) {
variableTemplates.value = variableTemplates.value.filter((template) => template.id !== id)
emits('update-variable-templates', [...variableTemplates.value])
}
}
const getVariableKeyById = (id: string) => {
const variable = variables.value.find((v) => v.id === id)
return variable?.key || ''
}
const generateExcelTemplate = (template: VariableTemplate) => {
// 模拟生成Excel模板
const variableNames = template.variableIds.map((id) => {
const variable = variables.value.find((v) => v.id === id)
return variable ? variable.name : ''
})
alert(`已生成包含以下变量的Excel模板:\n${variableNames.join(', ')}`)
// 实际项目中这里应该生成并下载Excel文件
}
</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-md 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 flex-1 overflow-y-auto">
<div class="mb-4">
<input
v-model="searchTerm"
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="grid grid-cols-1 gap-2">
<button
v-for="variable in filteredVariables"
:key="variable.id"
class="p-3 border border-gray-200 rounded-md hover:bg-blue-50 text-left transition-colors"
@click="selectVariable(variable)"
>
<div class="font-medium font-mono">{{ variablePrefix }}{{ variable.key }}</div>
<div class="text-sm text-gray-500">{{ variable.name }}</div>
</button>
</div>
<div v-if="filteredVariables.length === 0" class="p-6 text-center text-gray-500">
<p>未找到匹配的变量</p>
</div>
</div>
<div class="p-4 border-t border-gray-200 flex justify-end">
<button
@click="$emit('close')"
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 { ref, computed, defineProps, defineEmits } from 'vue'
import { Variable } from '../types'
const props = defineProps({
variables: {
type: Array as () => Variable[],
required: true,
},
})
const emits = defineEmits(['insert-variable', 'close'])
// 状态
const searchTerm = ref('')
const variablePrefix = '{{'
// 计算属性
const filteredVariables = computed(() => {
return props.variables.filter(
(variable) =>
variable.name.toLowerCase().includes(searchTerm.value.toLowerCase()) ||
variable.key.toLowerCase().includes(searchTerm.value.toLowerCase()),
)
})
// 方法
const selectVariable = (variable: Variable) => {
emits('insert-variable', variable)
}
</script>
/* 导入Tailwind CSS */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 导入Font Awesome */
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css');
/* 自定义样式 */
#app {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.prose {
max-width: 100%;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}
......@@ -4,6 +4,9 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
// 引入全部样式
import './index.css'
const app = createApp(App)
app.use(createPinia())
......
// 联系人类型
export interface Contact {
id: string
name: string
title: string
company: string
email: string
ccEmail: string
other: string
}
// 发件人类型
export interface Sender {
id: string
email: string
password: string
smtpServer: string
smtpPort: string
}
// 变量类型
export interface Variable {
id: string
name: string
key: string
description: string
}
// 变量模板类型
export interface VariableTemplate {
id: string
name: string
description: string
variableIds: 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
}
// 忘记密码表单类型
export interface ForgotPasswordForm {
email: string
newPassword: string
confirmPassword: string
}
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}
......@@ -7,14 +7,13 @@ import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
vueDevTools(),
],
// 关键配置:设置基础路径为子目录 yd-email
// 生产环境(部署到服务器)用 '/yd-email/',本地开发用 '/'(避免开发时路径错误)
base: process.env.NODE_ENV === 'production' ? '/yd-email/' : '/',
plugins: [vue(), vueJsx(), vueDevTools()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})
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