Compare commits

..

No commits in common. 'main' and 'develop' have entirely different histories.

36
.gitignore vendored

@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

@ -1,2 +1,38 @@
# vue # mail-system
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

@ -0,0 +1,13 @@
<!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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

2683
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,24 @@
{
"name": "mail-system",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.25",
"vue-router": "^4.6.4",
"vuex": "^4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"vite": "^7.2.4",
"vite-plugin-vue-devtools": "^8.0.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

@ -0,0 +1,82 @@
<template>
<div id="app" class="mail-app">
<!-- 只有非登录页面才显示头部和侧边栏 -->
<template v-if="!$route.meta?.hideLayout">
<AppHeader @compose="handleCompose" @search="handleSearch" />
<div class="app-container">
<Sidebar />
<main class="main-content">
<router-view ref="currentView" />
</main>
</div>
<ComposeModal v-if="showComposeModal" @close="showComposeModal = false" />
</template>
<!-- 登录页面直接显示路由视图 -->
<template v-else>
<router-view />
</template>
</div>
</template>
<script>import AppHeader from './components/layout/AppHeader.vue'
import Sidebar from './components/layout/Sidebar.vue'
import ComposeModal from './components/mail/ComposeModal.vue'
import { ref } from 'vue'
export default {
name: 'App',
components: {
AppHeader,
Sidebar,
ComposeModal
},
setup() {
const showComposeModal = ref(false)
const currentView = ref(null)
const handleCompose = () => {
showComposeModal.value = true
}
const handleSearch = (query) => {
//
const trimmedQuery = query.trim()
if (currentView.value && typeof currentView.value.setSearchQuery === 'function') {
currentView.value.setSearchQuery(trimmedQuery)
}
}
return {
showComposeModal,
currentView,
handleCompose,
handleSearch
}
}
}
</script>
<style scoped>
.mail-app {
height: 100vh;
display: flex;
flex-direction: column;
}
.app-container {
display: flex;
flex: 1;
overflow: hidden;
}
.main-content {
flex: 1;
overflow: hidden;
background: white;
border-radius: 4px;
margin: 8px 8px 8px 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
</style>

@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

@ -0,0 +1,35 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}

@ -0,0 +1,97 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 工具类 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
/* 响应式设计 */
@media (max-width: 768px) {
.app-container {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
border-right: none;
border-bottom: 1px solid #e0e0e0;
}
.sidebar-content {
display: flex;
overflow-x: auto;
padding: 10px 0;
}
.folder-nav {
display: flex;
gap: 10px;
}
.nav-item {
flex-direction: column;
padding: 8px 12px;
min-width: 80px;
}
.nav-text {
font-size: 12px;
margin-top: 4px;
}
.nav-count {
position: absolute;
top: 4px;
right: 4px;
}
.labels-section,
.sidebar-stats {
display: none;
}
}

@ -0,0 +1,44 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

@ -0,0 +1,95 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
>Vue - Official</a
>. If you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

@ -0,0 +1,190 @@
<template>
<header class="app-header">
<div class="header-left">
<button class="menu-toggle" @click="toggleSidebar">
<span class="icon-menu"></span>
</button>
<div class="logo">
<span class="logo-icon">Test</span>
<span class="logo-text">模拟邮箱</span>
</div>
</div>
<div class="header-center">
<div class="search-box">
<input
type="text"
placeholder="搜索邮件、联系人、标签"
v-model="localSearchQuery"
@input="onSearchInput"
@keyup.enter="onSearchInput"
>
<button class="search-btn" @click="onSearchInput">
<span class="icon-search">🔍</span>
</button>
</div>
</div>
<div class="header-right">
<button class="compose-btn" @click="$emit('compose')">
<span class="icon-compose">📝</span>
<span>写信</span>
</button>
<div class="user-avatar">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Vue" alt="用户头像">
</div>
</div>
</header>
</template>
<script>
import { ref } from 'vue'
export default {
name: 'AppHeader',
emits: ['compose', 'search'], // search
setup(_, { emit }) {
const localSearchQuery = ref('') //
const isSidebarCollapsed = ref(false)
const toggleSidebar = () => {
isSidebarCollapsed.value = !isSidebarCollapsed.value
document.body.classList.toggle('sidebar-collapsed')
}
//
const onSearchInput = () => {
emit('search', localSearchQuery.value)
}
return {
localSearchQuery,
toggleSidebar,
onSearchInput
}
}
}
</script>
<style scoped>
/* 样式保持不变 */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
background: #666666;
color: white;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.header-left {
display: flex;
align-items: center;
gap: 15px;
}
.menu-toggle {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 5px;
border-radius: 4px;
}
.menu-toggle:hover {
background: rgba(255,255,255,0.1);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: white;
color: #666666;
border-radius: 8px;
font-weight: bold;
font-size: 18px;
}
.logo-text {
font-size: 20px;
font-weight: 500;
}
.header-center {
flex: 1;
max-width: 600px;
margin: 0 20px;
}
.search-box {
display: flex;
background: rgba(255,255,255,0.9);
border-radius: 20px;
overflow: hidden;
}
.search-box input {
flex: 1;
padding: 10px 15px;
border: none;
background: transparent;
font-size: 14px;
}
.search-box input:focus {
outline: none;
}
.search-btn {
padding: 0 15px;
background: #dddddd;
border: none;
color: #333333;
cursor: pointer;
}
.header-right {
display: flex;
align-items: center;
gap: 15px;
}
.compose-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: #dddddd;
color: #333333;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.3s;
}
.compose-btn:hover {
background: #cccccc;
}
.user-avatar img {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.3);
}
</style>

@ -0,0 +1,213 @@
<template>
<aside class="sidebar" :class="{ collapsed: isCollapsed }">
<div class="sidebar-content">
<nav class="folder-nav">
<div
v-for="folder in folders"
:key="folder.id"
class="nav-item"
:class="{ active: currentFolder === folder.id }"
@click="selectFolder(folder.id)"
>
<span class="nav-icon">{{ getIcon(folder.icon) }}</span>
<span class="nav-text">{{ folder.name }}</span>
<span class="nav-count" v-if="folder.count > 0">{{ folder.count }}</span>
</div>
</nav>
<div class="sidebar-divider"></div>
<div class="labels-section">
<div class="section-title">标签</div>
<div
v-for="label in labels"
:key="label.id"
class="label-item"
>
<span class="label-color" :style="{ backgroundColor: label.color }"></span>
<span class="label-name">{{ label.name }}</span>
</div>
</div>
<div class="sidebar-stats">
<div class="storage-info">
<div class="storage-bar">
<div class="storage-progress" :style="{ width: '65%' }"></div>
</div>
<div class="storage-text">15.2GB / 16GB</div>
</div>
</div>
</div>
</aside>
</template>
<script>
import { computed, ref } from 'vue'
import { useStore } from 'vuex'
import { useRouter, useRoute } from 'vue-router'
export default {
name: 'Sidebar',
setup() {
const store = useStore()
const router = useRouter()
const route = useRoute()
const isCollapsed = ref(false)
const folders = computed(() => store.state.folders)
const labels = computed(() => store.state.labels)
const currentFolder = computed(() => {
return route.name?.toLowerCase()
})
const selectFolder = (folderId) => {
router.push({ name: folderId.charAt(0).toUpperCase() + folderId.slice(1) })
}
const getIcon = (iconName) => {
const icons = {
inbox: '📥',
star: '⭐',
send: '📤',
draft: '📝',
trash: '🗑️'
}
return icons[iconName] || '📁'
}
return {
folders,
labels,
currentFolder,
isCollapsed,
selectFolder,
getIcon
}
}
}
</script>
<style scoped>
.sidebar {
width: 220px;
background: white;
border-right: 1px solid #e0e0e0;
overflow-y: auto;
transition: width 0.3s ease;
}
.sidebar.collapsed {
width: 60px;
}
.sidebar-content {
padding: 20px 0;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 20px;
cursor: pointer;
color: #555;
transition: background 0.2s;
position: relative;
}
.nav-item:hover {
background: #f5f5f5;
}
.nav-item.active {
background: #E3F2FD;
color: #1E88E5;
border-right: 3px solid #1E88E5;
}
.nav-icon {
font-size: 18px;
width: 24px;
text-align: center;
margin-right: 12px;
}
.nav-text {
flex: 1;
font-size: 14px;
}
.nav-count {
background: #FF5722;
color: white;
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.sidebar-divider {
height: 1px;
background: #e0e0e0;
margin: 15px 20px;
}
.section-title {
padding: 0 20px 10px;
font-size: 12px;
color: #999;
text-transform: uppercase;
font-weight: 500;
}
.label-item {
display: flex;
align-items: center;
padding: 8px 20px;
cursor: pointer;
font-size: 14px;
}
.label-item:hover {
background: #f5f5f5;
}
.label-color {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 10px;
}
.sidebar-stats {
padding: 20px;
margin-top: 20px;
}
.storage-info {
background: #F5F5F5;
padding: 15px;
border-radius: 8px;
}
.storage-bar {
height: 6px;
background: #E0E0E0;
border-radius: 3px;
overflow: hidden;
margin-bottom: 8px;
}
.storage-progress {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #8BC34A);
border-radius: 3px;
}
.storage-text {
font-size: 12px;
color: #666;
text-align: center;
}
</style>

@ -0,0 +1,295 @@
<template>
<div class="compose-modal-overlay" @click.self="close">
<div class="compose-modal">
<div class="modal-header">
<span class="modal-title">写邮件</span>
<div class="modal-actions">
<button class="action-btn" @click="minimize"></button>
<button class="action-btn" @click="close"></button>
</div>
</div>
<form class="compose-form" @submit.prevent="sendMail">
<div class="form-field">
<input
type="text"
v-model="form.to"
placeholder="收件人"
required
>
</div>
<div class="form-field">
<input
type="text"
v-model="form.cc"
placeholder="抄送"
>
</div>
<div class="form-field">
<input
type="text"
v-model="form.subject"
placeholder="主题"
required
>
</div>
<div class="form-field">
<textarea
v-model="form.content"
placeholder="请输入邮件内容..."
rows="15"
></textarea>
</div>
<div class="form-toolbar">
<button type="submit" class="send-btn">发送</button>
<button type="button" class="secondary-btn" @click="saveDraft">稿</button>
<button type="button" class="secondary-btn" @click="addAttachment">
<span class="icon">📎</span> 添加附件
</button>
<button type="button" class="secondary-btn">更多选项</button>
</div>
</form>
</div>
</div>
</template>
<script>
import { ref, watch } from 'vue'
import { useStore } from 'vuex'
export default {
name: 'ComposeModal',
emits: ['close'],
props: {
// draftMode
draftMode: {
type: Boolean,
default: false
}
},
setup(props, { emit }) {
const store = useStore()
const form = ref({
to: '',
cc: '',
subject: '',
content: ''
})
// 稿稿
watch(
() => store.state.currentDraft,
(newDraft) => {
if (newDraft && props.draftMode) {
form.value = {
to: newDraft.to || '',
cc: newDraft.cc || '',
subject: newDraft.subject || '',
content: newDraft.content || ''
}
}
},
{ immediate: true }
)
const close = () => {
// 稿
if (props.draftMode) {
store.dispatch('clearCurrentDraft')
}
emit('close')
}
const minimize = () => {
//
console.log('Minimize compose window')
}
const sendMail = () => {
console.log('Sending mail:', form.value)
//
alert('邮件发送成功!')
// 稿稿
if (props.draftMode && store.state.currentDraft) {
store.dispatch('deleteDraft', store.state.currentDraft.id)
}
close()
}
// 稿
const saveDraft = () => {
if (form.value.subject || form.value.content) {
const draftData = {
to: form.value.to,
cc: form.value.cc,
subject: form.value.subject,
content: form.value.content
}
// 稿 ID
if (props.draftMode && store.state.currentDraft) {
draftData.id = store.state.currentDraft.id
}
store.dispatch('saveDraft', draftData)
alert('草稿已保存!')
close()
} else {
alert('请输入邮件主题或内容后再保存草稿')
}
}
const addAttachment = () => {
const input = document.createElement('input')
input.type = 'file'
input.multiple = true
input.onchange = (e) => {
console.log('Files selected:', e.target.files)
}
input.click()
}
return {
form,
close,
minimize,
sendMail,
saveDraft,
addAttachment
}
}
}
</script>
<style scoped>
.compose-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: flex-end;
justify-content: flex-end;
z-index: 1000;
padding: 20px;
}
.compose-modal {
width: 600px;
height: 80vh;
background: white;
border-radius: 8px 8px 0 0;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
border-radius: 8px 8px 0 0;
}
.modal-title {
font-size: 16px;
font-weight: 500;
color: #333;
}
.modal-actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 28px;
height: 28px;
border: none;
background: #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.action-btn:hover {
background: #ccc;
}
.compose-form {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
}
.form-field {
margin-bottom: 15px;
}
.form-field input,
.form-field textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-field input:focus,
.form-field textarea:focus {
outline: none;
border-color: #1E88E5;
}
.form-field textarea {
resize: none;
font-family: inherit;
}
.form-toolbar {
display: flex;
gap: 10px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.send-btn,
.secondary-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.send-btn {
background: #1E88E5;
color: white;
}
.send-btn:hover {
background: #0D47A1;
}
.secondary-btn {
background: #f5f5f5;
color: #333;
}
.secondary-btn:hover {
background: #e0e0e0;
}
</style>

@ -0,0 +1,450 @@
<template>
<div class="mail-list">
<div class="mail-toolbar">
<div class="toolbar-left">
<button class="toolbar-btn" @click="toggleSelectAll">
<span class="icon"></span>
</button>
<button class="toolbar-btn" @click="refresh">
<span class="icon">🔄</span>
</button>
<button class="toolbar-btn" @click="markAsRead" :disabled="!hasSelected">
<span class="icon">📖</span>
</button>
<button class="toolbar-btn" @click="deleteSelected" :disabled="!hasSelected">
<span class="icon">🗑</span>
</button>
</div>
<div class="toolbar-right">
<span class="mail-count">{{ currentCount }} 封邮件</span>
</div>
</div>
<div class="mail-items">
<div
v-for="mail in displayedMails"
:key="mail.id"
class="mail-item"
:class="{
selected: selectedMails.includes(mail.id),
unread: !mail.read
}"
@click="selectMail(mail)"
>
<div class="mail-select">
<input
type="checkbox"
:checked="selectedMails.includes(mail.id)"
@click.stop="toggleSelect(mail.id)"
>
</div>
<div class="mail-star" @click.stop="toggleStar(mail.id)">
<span class="star-icon" :class="{ starred: mail.starred }">
{{ mail.starred ? '★' : '☆' }}
</span>
</div>
<div class="mail-sender">
<span class="sender-name">{{ mail.sender }}</span>
</div>
<div class="mail-content">
<div class="mail-subject">
<span class="subject-text">{{ mail.subject }}</span>
<span class="mail-labels">
<span
v-for="label in mail.labels"
:key="label"
class="label-badge"
:style="{
backgroundColor: getLabelColor(label),
color: 'white'
}"
>
{{ label }}
</span>
<span v-if="mail.hasAttachment" class="attachment-icon">📎</span>
</span>
</div>
<div class="mail-preview">{{ mail.content }}</div>
</div>
<div class="mail-time">
<span>{{ mail.time }}</span>
<span v-if="mail.date !== today" class="mail-date">{{ mail.date }}</span>
</div>
<!-- 添加编辑按钮仅在草稿箱中显示 -->
<div class="mail-actions" v-if="isDraftFolder">
<button class="edit-btn" @click.stop="editDraft(mail)">编辑</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { computed, ref, watch } from 'vue'
import { useStore } from 'vuex'
import { useRouter, useRoute } from 'vue-router'
import { eventBus } from '@/utils/eventBus'
export default {
name: 'MailList',
props: {
mails: {
type: Array,
default: () => []
},
folder: {
type: String,
default: 'inbox'
},
searchQuery: {
type: String,
default: ''
}
},
setup(props) {
const store = useStore()
const router = useRouter()
const route = useRoute()
const selectedMails = ref([])
const today = new Date().toISOString().split('T')[0]
// 稿
const isDraftFolder = computed(() => route.name === 'Drafts')
// displayedMails 使
const displayedMails = computed(() => {
let result = []
//
if (route.name === 'Sent') {
result = store.getters.sentMails || []
} else if (route.name === 'Starred') {
result = store.getters.starredMails || []
} else if (route.name === 'Trash') {
result = store.getters.trashMails || []
} else if (route.name === 'Drafts') {
result = store.getters.draftMails || []
} else {
result = props.mails
}
//
if (props.folder === 'starred' && route.name !== 'Starred') {
result = result.filter(mail => mail.starred)
}
//
if (props.searchQuery && props.searchQuery.trim()) {
const query = props.searchQuery.toLowerCase()
result = result.filter(mail => {
return (
mail.sender.toLowerCase().includes(query) ||
mail.subject.toLowerCase().includes(query) ||
mail.content.toLowerCase().includes(query) ||
mail.labels.some(label => label.toLowerCase().includes(query))
)
})
}
return result
})
// 稿
const editDraft = (draft) => {
store.commit('SET_CURRENT_DRAFT', draft)
eventBus.emit('openComposeModal', { draftMode: true, draftData: draft })
}
//
watch(() => route.name, () => {
selectedMails.value = []
})
const currentCount = computed(() => displayedMails.value.length)
const hasSelected = computed(() => selectedMails.value.length > 0)
const selectMail = (mail) => {
store.commit('SET_CURRENT_MAIL', mail)
router.push({ name: 'MailDetail', params: { id: mail.id } })
if (!mail.read && route.name !== 'Sent') {
store.commit('MARK_AS_READ', mail.id)
}
}
const toggleSelect = (mailId) => {
const index = selectedMails.value.indexOf(mailId)
if (index > -1) {
selectedMails.value.splice(index, 1)
} else {
selectedMails.value.push(mailId)
}
}
const toggleSelectAll = () => {
if (selectedMails.value.length === displayedMails.value.length) {
selectedMails.value = []
} else {
selectedMails.value = displayedMails.value.map(mail => mail.id)
}
}
const toggleStar = (mailId) => {
store.commit('TOGGLE_STAR', mailId)
}
const markAsRead = () => {
selectedMails.value.forEach(id => {
store.commit('MARK_AS_READ', id)
})
selectedMails.value = []
}
const deleteSelected = () => {
selectedMails.value.forEach(id => {
if (route.name === 'Drafts') {
// 稿稿
store.dispatch('deleteDraft', id)
} else {
//
store.commit('DELETE_MAIL', id)
}
})
selectedMails.value = []
}
//
const refresh = async () => {
if (route.name === 'Drafts') {
// 稿稿
await store.dispatch('loadDrafts')
} else {
await store.dispatch('fetchMails')
}
//
selectedMails.value = []
}
const getLabelColor = (labelName) => {
const label = store.state.labels.find(l => l.name === labelName)
return label ? label.color : '#757575'
}
return {
selectedMails,
today,
displayedMails,
currentCount,
hasSelected,
selectMail,
toggleSelect,
toggleSelectAll,
toggleStar,
markAsRead,
deleteSelected,
refresh,
getLabelColor,
isDraftFolder,
editDraft
}
}
}
</script>
<style scoped>
.mail-list {
height: 100%;
display: flex;
flex-direction: column;
}
.mail-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #e0e0e0;
background: white;
}
.toolbar-left {
display: flex;
gap: 10px;
}
.toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid #e0e0e0;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.toolbar-btn:hover:not(:disabled) {
background: #f5f5f5;
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbar-right {
font-size: 14px;
color: #666;
}
.mail-items {
flex: 1;
overflow-y: auto;
}
.mail-item {
display: flex;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
min-height: 70px;
}
.mail-item:hover {
background: #f9f9f9;
}
.mail-item.selected {
background: #E3F2FD;
}
.mail-item.unread {
background: #F5F5F5;
}
.mail-item.unread .sender-name,
.mail-item.unread .subject-text {
font-weight: 600;
}
.mail-select {
width: 40px;
}
.mail-select input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.mail-star {
width: 40px;
text-align: center;
}
.star-icon {
font-size: 20px;
color: #ccc;
cursor: pointer;
transition: color 0.2s;
}
.star-icon.starred {
color: #FFC107;
}
.mail-sender {
width: 150px;
padding-right: 15px;
}
.sender-name {
font-size: 14px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mail-content {
flex: 1;
min-width: 0;
padding-right: 15px;
}
.mail-subject {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.subject-text {
font-size: 14px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mail-labels {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.label-badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
line-height: 1;
}
.attachment-icon {
color: #666;
font-size: 14px;
}
.mail-preview {
font-size: 13px;
color: #666;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mail-time {
width: 80px;
text-align: right;
font-size: 12px;
color: #999;
}
.mail-date {
display: block;
margin-top: 2px;
font-size: 11px;
}
.mail-actions {
margin-left: 10px;
}
.edit-btn {
padding: 4px 8px;
background: #1E88E5;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.edit-btn:hover {
background: #0D47A1;
}
</style>

@ -0,0 +1,19 @@
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import './assets/styles/main.css'
// 创建应用实例
const app = createApp(App)
// 使用插件
app.use(store)
app.use(router)
// 初始化邮件数据
store.dispatch('fetchMails').then(() => {
// 数据加载完成后挂载应用
app.mount('#app')
})

@ -0,0 +1,70 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
redirect: '/login' // 将默认路径重定向到登录页
},
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
meta: {
hideLayout: true // 添加这行标记登录页面隐藏布局
}
},
{
path: '/register',
name: 'Register',
component: () => import('../views/Register.vue'),
meta: {
hideLayout: true // 添加这行标记登录页面隐藏布局
}
},
{
path: '/forgot-password',
name: 'ForgotPassword',
component: () => import('../views/ForgotPassword.vue'),
meta: {
hideLayout: true
}
},
{
path: '/inbox',
name: 'Inbox',
component: () => import('../views/Inbox.vue')
},
{
path: '/starred',
name: 'Starred',
component: () => import('../views/Starred.vue')
},
{
path: '/sent',
name: 'Sent',
component: () => import('../views/Sent.vue')
},
{
path: '/drafts',
name: 'Drafts',
component: () => import('../views/Drafts.vue')
},
{
path: '/trash',
name: 'Trash',
component: () => import('../views/Trash.vue')
},
{
path: '/mail/:id',
name: 'MailDetail',
component: () => import('../views/MailDetail.vue'),
props: true
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

@ -0,0 +1,240 @@
import { createStore } from 'vuex'
import { draftStorage } from '@/utils/draftStorage'
export default createStore({
state: {
mails: [],
sentMails: [],
trashMails: [],
draftMails: [],
currentDraft: null,
folders: [
{ id: 'inbox', name: '收件箱', count: 12, icon: 'inbox' },
{ id: 'starred', name: '星标邮件', count: 5, icon: 'star' },
{ id: 'sent', name: '已发送', count: 42, icon: 'send' },
{ id: 'drafts', name: '草稿箱', count: 3, icon: 'draft' },
{ id: 'trash', name: '垃圾箱', count: 7, icon: 'trash' }
],
labels: [
{ id: 1, name: '工作', color: '#4CAF50' },
{ id: 2, name: '重要', color: '#F44336' },
{ id: 3, name: '个人', color: '#2196F3' }
],
currentMail: null
},
mutations: {
SET_MAILS(state, mails) {
state.mails = mails
},
SET_SENT_MAILS(state, mails) {
state.sentMails = mails
},
SET_TRASH_MAILS(state, mails) {
state.trashMails = mails
},
SET_CURRENT_MAIL(state, mail) {
state.currentMail = mail
},
SET_CURRENT_DRAFT(state, draft) {
state.currentDraft = draft
},
TOGGLE_STAR(state, mailId) {
// 在所有邮件集合中查找并切换星标状态
const mail = state.mails.find(m => m.id === mailId) ||
state.sentMails.find(m => m.id === mailId) ||
state.trashMails.find(m => m.id === mailId)
if (mail) {
mail.starred = !mail.starred
}
},
DELETE_MAIL(state, mailId) {
// 从原位置移除邮件并添加到垃圾箱
let deletedMail = null
// 检查收件箱
const inboxIndex = state.mails.findIndex(m => m.id === mailId)
if (inboxIndex > -1) {
deletedMail = state.mails.splice(inboxIndex, 1)[0]
}
// 检查已发送
const sentIndex = state.sentMails.findIndex(m => m.id === mailId)
if (sentIndex > -1) {
deletedMail = state.sentMails.splice(sentIndex, 1)[0]
}
// 如果找到邮件,添加到垃圾箱
if (deletedMail) {
deletedMail.deleted = true // 标记为已删除
state.trashMails.push(deletedMail)
}
},
RESTORE_MAIL(state, mailId) {
// 从垃圾箱恢复邮件
const trashIndex = state.trashMails.findIndex(m => m.id === mailId)
if (trashIndex > -1) {
const restoredMail = state.trashMails.splice(trashIndex, 1)[0]
restoredMail.deleted = false
// 根据发件人判断恢复到哪个邮箱
if (restoredMail.sender === '我') {
state.sentMails.push(restoredMail)
} else {
state.mails.push(restoredMail)
}
}
},
PERMANENTLY_DELETE_MAIL(state, mailId) {
// 永久删除邮件
state.trashMails = state.trashMails.filter(m => m.id !== mailId)
},
SET_DRAFT_MAILS(state, drafts) {
state.draftMails = drafts
},
ADD_DRAFT(state, draft) {
state.draftMails.push(draft)
},
REMOVE_DRAFT(state, draftId) {
state.draftMails = state.draftMails.filter(draft => draft.id !== draftId)
}
},
actions: {
async fetchMails({ commit }) {
// 模拟API调用
const mockMails = [
{
id: 1,
sender: '腾讯客服',
senderEmail: 'service@tencent.com',
subject: '关于账号安全的重要通知',
content: '尊敬的QQ邮箱用户为了保障您的账号安全请保证自己的邮箱安全。\n不乱点击陌生链接',
time: '10:30',
date: '2024-01-15',
starred: true,
read: true,
hasAttachment: true,
labels: ['工作', '重要']
},
{
id: 2,
sender: '支付宝',
senderEmail: 'alipay@service.alibaba.com',
subject: '月度账单已生成',
content: '您本月的支付宝账单已经生成,请及时查看...',
time: '昨天',
date: '2024-01-14',
starred: false,
read: false,
hasAttachment: false,
labels: ['个人']
},
{
id: 3,
sender: 'GitHub',
senderEmail: 'notifications@github.com',
subject: '仓库有新的 Pull Request',
content: 'Your repository has a new pull request...',
time: '昨天',
date: '2024-01-14',
starred: true,
read: true,
hasAttachment: true,
labels: ['工作']
}
]
const mockSentMails = [
{
id: 101,
sender: '我',
senderEmail: 'me@example.com',
subject: '项目进度报告',
content: '这是我们本周的项目进度报告,请查收。',
time: '14:20',
date: '2024-01-15',
starred: false,
read: true,
hasAttachment: true,
labels: ['工作']
},
{
id: 102,
sender: '我',
senderEmail: 'me@example.com',
subject: '会议邀请',
content: '邀请您参加下周三的项目评审会议。',
time: '11:30',
date: '2024-01-14',
starred: true,
read: true,
hasAttachment: false,
labels: ['重要']
},
{
id: 103,
sender: '我',
senderEmail: 'me@example.com',
subject: '假期申请',
content: '我想申请下个月的年假,请审批。',
time: '09:15',
date: '2024-01-13',
starred: false,
read: true,
hasAttachment: false,
labels: ['个人']
}
]
commit('SET_MAILS', mockMails)
commit('SET_SENT_MAILS', mockSentMails)
},
loadDrafts({ commit }) {
const drafts = draftStorage.getAllDrafts()
commit('SET_DRAFT_MAILS', drafts)
},
saveDraft({ commit }, draftData) {
// 添加必要字段
const draft = {
id: draftStorage.generateId(),
sender: '我',
senderEmail: 'me@example.com',
subject: draftData.subject || '(无主题)',
content: draftData.content || '',
to: draftData.to || '',
cc: draftData.cc || '',
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
date: new Date().toISOString().split('T')[0],
starred: false,
read: true,
hasAttachment: false,
labels: [],
...draftData
}
draftStorage.saveDraft(draft)
commit('ADD_DRAFT', draft)
return draft
},
deleteDraft({ commit }, draftId) {
draftStorage.deleteDraft(draftId)
commit('REMOVE_DRAFT', draftId)
},
clearCurrentDraft({ commit }) {
commit('SET_CURRENT_DRAFT', null)
}
},
getters: {
inboxMails: (state) => state.mails.filter(mail => !mail.deleted),
unreadCount: (state) => state.mails.filter(mail => !mail.read).length,
sentMails: (state) => state.sentMails,
trashMails: (state) => state.trashMails,
starredMails: (state) => {
// 合并收件箱和已发送邮件中的星标邮件
const inboxStarred = state.mails.filter(mail => mail.starred)
const sentStarred = state.sentMails.filter(mail => mail.starred)
return [...inboxStarred, ...sentStarred]
},
draftMails: (state) => state.draftMails
}
})

@ -0,0 +1,43 @@
// src/utils/draftStorage.js
const DRAFT_STORAGE_KEY = 'mail_drafts'
export const draftStorage = {
// 获取所有草稿
getAllDrafts() {
try {
const drafts = localStorage.getItem(DRAFT_STORAGE_KEY)
return drafts ? JSON.parse(drafts) : []
} catch (error) {
console.error('Failed to parse drafts from localStorage:', error)
return []
}
},
// 保存草稿
saveDraft(draft) {
const drafts = this.getAllDrafts()
// 如果草稿已存在则更新,否则新增
const existingIndex = drafts.findIndex(d => d.id === draft.id)
if (existingIndex > -1) {
drafts[existingIndex] = draft
} else {
drafts.push(draft)
}
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(drafts))
return draft
},
// 删除草稿
deleteDraft(draftId) {
const drafts = this.getAllDrafts()
const filteredDrafts = drafts.filter(draft => draft.id !== draftId)
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(filteredDrafts))
},
// 生成新草稿ID
generateId() {
return 'draft_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
}
}

@ -0,0 +1,14 @@
export const eventBus = {
events: {},
on(event, callback) {
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(callback)
},
emit(event, data) {
if (this.events[event]) {
this.events[event].forEach(callback => callback(data))
}
}
}

@ -0,0 +1,243 @@
<template>
<div class="forgot-password-container">
<div class="forgot-password-box">
<div class="forgot-password-header">
<h1>模拟邮箱</h1>
<p>重置您的密码</p>
</div>
<form class="forgot-password-form" @submit.prevent="handleResetPassword">
<div class="form-group">
<input
type="email"
v-model="resetForm.email"
placeholder="邮箱地址"
class="form-input"
required
/>
</div>
<div class="form-group">
<input
type="text"
v-model="resetForm.specialCode"
placeholder="特权码"
class="form-input"
required
/>
</div>
<div class="form-group">
<input
type="password"
v-model="resetForm.newPassword"
placeholder="新密码"
class="form-input"
required
/>
</div>
<div class="form-group">
<input
type="password"
v-model="resetForm.confirmPassword"
placeholder="确认新密码"
class="form-input"
required
/>
</div>
<button type="submit" class="reset-button" :disabled="loading">
{{ loading ? '重置中...' : '重置密码' }}
</button>
</form>
<div class="forgot-password-footer">
<p><a href="#" @click="goToLogin"></a></p>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
export default {
name: 'ForgotPassword',
setup() {
const router = useRouter()
const loading = ref(false)
const resetForm = ref({
email: '',
specialCode: '',
newPassword: '',
confirmPassword: ''
})
const validateEmail = () => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(resetForm.value.email)
}
const handleResetPassword = async () => {
loading.value = true
try {
//
if (!validateEmail()) {
alert('请输入有效的邮箱地址')
loading.value = false
return
}
//
if (resetForm.value.specialCode !== '123') {
alert('特权码错误,请重新输入')
loading.value = false
return
}
//
if (resetForm.value.newPassword.length < 6) {
alert('密码长度至少6位')
loading.value = false
return
}
if (resetForm.value.newPassword !== resetForm.value.confirmPassword) {
alert('两次输入的密码不一致')
loading.value = false
return
}
//
await new Promise(resolve => setTimeout(resolve, 1000))
alert('密码重置成功!')
router.push('/login')
} catch (error) {
console.error('重置失败:', error)
alert('重置失败,请重试')
} finally {
loading.value = false
}
}
const goToLogin = () => {
router.push('/login')
}
return {
resetForm,
loading,
handleResetPassword,
goToLogin
}
}
}
</script>
<style scoped>
.forgot-password-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-image: url('/images/Login_back.jpg');
background-size: 100%;
background-position: center;
background-color: #f8f9fa;
padding: 20px;
}
.forgot-password-box {
width: 100%;
max-width: 400px;
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
overflow: hidden;
border: 1px solid #e9ecef;
}
.forgot-password-header {
padding: 30px 30px 20px;
text-align: center;
background: #ffffff;
color: #333333;
}
.forgot-password-header h1 {
margin: 0 0 10px;
font-size: 24px;
font-weight: normal;
color: #333333;
}
.forgot-password-header p {
margin: 0;
opacity: 0.8;
color: #666666;
}
.forgot-password-form {
padding: 30px;
}
.form-group {
margin-bottom: 20px;
}
.form-input {
width: 100%;
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
transition: border-color 0.3s;
}
.form-input:focus {
outline: none;
border-color: #ddd;
}
.reset-button {
width: 100%;
padding: 12px;
background: #666666;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
margin-top: 10px;
}
.reset-button:hover:not(:disabled) {
background: #dddddd;
}
.reset-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.forgot-password-footer {
padding: 20px 30px;
text-align: center;
border-top: 1px solid #eee;
font-size: 13px;
}
.forgot-password-footer a {
color: #333333;
text-decoration: none;
}
.forgot-password-footer a:hover {
text-decoration: underline;
}
</style>

@ -0,0 +1,46 @@
<!-- src/views/Inbox.vue -->
<template>
<div class="inbox-view">
<MailList
:mails="mails"
folder="inbox"
:search-query="searchQuery"
/>
</div>
</template>
<script>
import { computed, onMounted, ref } from 'vue'
import { useStore } from 'vuex'
import MailList from '../components/mail/MailList.vue'
export default {
name: 'Inbox',
components: {
MailList
},
setup() {
const store = useStore()
const searchQuery = ref('')
const mails = computed(() => store.state.mails)
onMounted(() => {
if (mails.value.length === 0) {
store.dispatch('fetchMails')
}
})
//
const setSearchQuery = (query) => {
searchQuery.value = query
}
return {
mails,
searchQuery,
setSearchQuery
}
}
}
</script>

@ -0,0 +1,240 @@
<template>
<div class="login-container">
<div class="login-box">
<div class="login-header">
<h1>模拟邮箱</h1>
<p>登录到您的邮箱账户</p>
</div>
<form class="login-form" @submit.prevent="handleLogin">
<div class="form-group">
<input
type="text"
v-model="loginForm.username"
placeholder="号码/邮箱地址"
class="form-input"
required
/>
</div>
<div class="form-group">
<input
type="password"
v-model="loginForm.password"
placeholder="密码"
class="form-input"
required
/>
</div>
<div class="form-options">
<label class="remember-me">
<input type="checkbox" v-model="loginForm.rememberMe" />
<span>记住登录状态</span>
</label>
<a href="#" class="forgot-password" @click="goToForgotPassword"></a>
</div>
<button type="submit" class="login-button" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<div class="login-footer">
<p>还没有账号<a href="#" @click="goToRegister"></a></p>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
export default {
name: 'Login',
setup() {
const router = useRouter()
const loading = ref(false)
const loginForm = ref({
username: '',
password: '',
rememberMe: false
})
const goToRegister = () => {
router.push('/register')
}
const goToForgotPassword = () => {
router.push('/forgot-password')
}
const handleLogin = async () => {
loading.value = true
try {
//
await new Promise(resolve => setTimeout(resolve, 1000))
//
router.push('/inbox')
} catch (error) {
console.error('登录失败:', error)
} finally {
loading.value = false
}
}
return {
loginForm,
loading,
handleLogin,
goToRegister,
goToForgotPassword
}
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-image: url('/images/Login_back.jpg');
background-size: 100%;
background-position: center;
background-color: #f8f9fa;;
padding: 20px;
}
.login-box {
width: 100%;
max-width: 400px;
background: rgba(255, 255, 255, 0.95); /* 半透明白色,保持纸张质感 */
border-radius: 8px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
overflow: hidden;
border: 1px solid #e9ecef; /* 浅灰色边框 */
}
.login-header {
padding: 30px 30px 20px;
text-align: center;
background: #ffffff; /* 纯白色背景 */
color: #333333; /* 深灰色文字,提高可读性 */
}
.login-header h1 {
margin: 0 0 10px;
font-size: 24px;
font-weight: normal;
color: #333333;
}
.login-header p {
margin: 0;
opacity: 0.8;
color: #666666;
}
.login-header h1 {
margin: 0 0 10px;
font-size: 24px;
font-weight: normal;
}
.login-header p {
margin: 0;
opacity: 0.9;
}
.login-form {
padding: 30px;
}
.form-group {
margin-bottom: 20px;
}
.form-input {
width: 100%;
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
transition: border-color 0.3s;
}
.form-input:focus {
outline: none;
border-color: #ddd;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
font-size: 13px;
}
.remember-me {
display: flex;
align-items: center;
cursor: pointer;
}
.remember-me input {
margin-right: 8px;
}
.forgot-password {
color: #333333;
text-decoration: none;
}
.forgot-password:hover {
text-decoration: underline;
}
.login-button {
width: 100%;
padding: 12px;
background: #666666;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
.login-button:hover:not(:disabled) {
background: #dddddd;
}
.login-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.login-footer {
padding: 20px 30px;
text-align: center;
border-top: 1px solid #eee;
font-size: 13px;
}
.login-footer a {
color: #333333;
text-decoration: none;
}
.login-footer a:hover {
text-decoration: underline;
}
</style>

@ -0,0 +1,331 @@
<!-- src/views/MailDetail.vue -->
<template>
<div class="mail-detail">
<div class="mail-header">
<button class="back-button" @click="goBack">
<span class="icon"></span> 返回
</button>
<div class="mail-actions">
<button class="action-button" @click="markAsUnread" v-if="currentMail && currentMail.read">
<span class="icon"></span> 标为未读
</button>
<button class="action-button" @click="deleteMail" v-if="currentMail">
<span class="icon">🗑</span> 删除
</button>
</div>
</div>
<div class="mail-content-container" v-if="currentMail">
<div class="mail-subject">
<h1>{{ currentMail.subject }}</h1>
<span
v-if="currentMail.starred"
class="star-icon starred"
@click="toggleStar(currentMail.id)"
>
</span>
</div>
<div class="mail-info">
<div class="sender-info">
<div class="sender-details">
<span class="sender-name">{{ currentMail.sender }}</span>
<span class="sender-email">&lt;{{ currentMail.senderEmail }}&gt;</span>
</div>
<div class="mail-time-info">
<span class="mail-time">{{ currentMail.time }}</span>
<span class="mail-date">{{ currentMail.date }}</span>
</div>
</div>
<div class="mail-recipients">
<div class="recipient-item">
<span class="label">收件人:</span>
<span class="value"></span>
</div>
</div>
</div>
<div class="mail-body">
<div class="mail-content">
{{ currentMail.content }}
</div>
<div class="mail-attachments" v-if="currentMail.hasAttachment">
<div class="attachments-header">
<span class="icon">📎</span> 附件 (1)
</div>
<div class="attachment-item">
<span class="file-icon">📄</span>
<span class="file-name">document.pdf</span>
<span class="file-size">(1.2 MB)</span>
</div>
</div>
</div>
</div>
<div class="no-mail-selected" v-else>
<p>请选择一封邮件查看</p>
</div>
</div>
</template>
<script>
import { computed } from 'vue'
import { useStore } from 'vuex'
import { useRouter } from 'vue-router'
export default {
name: 'MailDetail',
setup() {
const store = useStore()
const router = useRouter()
const currentMail = computed(() => store.state.currentMail)
const goBack = () => {
router.go(-1)
}
const markAsUnread = () => {
if (currentMail.value) {
store.commit('MARK_AS_READ', currentMail.value.id)
}
}
const deleteMail = () => {
if (currentMail.value) {
store.commit('DELETE_MAIL', currentMail.value.id)
router.push({ name: 'MailList' })
}
}
const toggleStar = (mailId) => {
store.commit('TOGGLE_STAR', mailId)
}
return {
currentMail,
goBack,
markAsUnread,
deleteMail,
toggleStar
}
}
}
</script>
<style scoped>
.mail-detail {
height: 100%;
display: flex;
flex-direction: column;
background: white;
}
.mail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #e0e0e0;
background: white;
}
.back-button {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 12px;
border: 1px solid #e0e0e0;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.back-button:hover {
background: #f5f5f5;
}
.mail-actions {
display: flex;
gap: 10px;
}
.action-button {
display: flex;
align-items: center;
gap: 5px;
padding: 8px 12px;
border: 1px solid #e0e0e0;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.action-button:hover {
background: #f5f5f5;
}
.mail-content-container {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.mail-subject {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.mail-subject h1 {
margin: 0;
font-size: 24px;
font-weight: normal;
color: #333;
}
.star-icon {
font-size: 24px;
color: #ccc;
cursor: pointer;
transition: color 0.2s;
margin-top: 5px;
}
.star-icon.starred {
color: #FFC107;
}
.mail-info {
border-bottom: 1px solid #eee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.sender-info {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
}
.sender-details {
display: flex;
flex-direction: column;
}
.sender-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.sender-email {
font-size: 14px;
color: #666;
}
.mail-time-info {
text-align: right;
}
.mail-time {
display: block;
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
.mail-date {
display: block;
font-size: 12px;
color: #999;
}
.mail-recipients {
display: flex;
flex-direction: column;
gap: 8px;
}
.recipient-item {
display: flex;
gap: 10px;
}
.recipient-item .label {
font-size: 14px;
color: #666;
min-width: 50px;
}
.recipient-item .value {
font-size: 14px;
color: #333;
}
.mail-body {
display: flex;
flex-direction: column;
gap: 30px;
}
.mail-content {
font-size: 14px;
line-height: 1.6;
color: #333;
white-space: pre-wrap;
}
.mail-attachments {
border-top: 1px solid #eee;
padding-top: 20px;
}
.attachments-header {
font-size: 14px;
color: #333;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.attachment-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid #eee;
border-radius: 4px;
width: fit-content;
}
.file-icon {
font-size: 16px;
}
.file-name {
font-size: 14px;
color: #333;
}
.file-size {
font-size: 12px;
color: #666;
}
.no-mail-selected {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: #999;
}
</style>

@ -0,0 +1,256 @@
<template>
<div class="register-container">
<div class="register-box">
<div class="register-header">
<h1>模拟邮箱</h1>
<p>创建您的邮箱账户</p>
</div>
<form class="register-form" @submit.prevent="handleRegister">
<div class="form-group">
<input
type="text"
v-model="registerForm.username"
placeholder="用户名"
class="form-input"
required
/>
</div>
<div class="form-group">
<input
type="email"
v-model="registerForm.email"
placeholder="邮箱地址"
class="form-input"
required
/>
</div>
<div class="form-group">
<input
type="password"
v-model="registerForm.password"
placeholder="密码"
class="form-input"
required
/>
</div>
<div class="form-group">
<input
type="password"
v-model="registerForm.confirmPassword"
placeholder="确认密码"
class="form-input"
required
/>
</div>
<div class="form-options">
<label class="terms-agreement">
<input type="checkbox" v-model="registerForm.agreeTerms" required />
<span>我同意 <a href="#" class="terms-link">服务条款</a> <a href="#" class="terms-link">隐私政策</a></span>
</label>
</div>
<button type="submit" class="register-button" :disabled="loading">
{{ loading ? '注册中...' : '注册' }}
</button>
</form>
<div class="register-footer">
<p>已有账号<a href="#" @click="goToLogin"></a></p>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
export default {
name: 'Register',
setup() {
const router = useRouter()
const loading = ref(false)
const registerForm = ref({
username: '',
email: '',
password: '',
confirmPassword: '',
agreeTerms: false
})
const handleRegister = async () => {
if (registerForm.value.password !== registerForm.value.confirmPassword) {
alert('两次输入的密码不一致')
return
}
if (!registerForm.value.agreeTerms) {
alert('请同意服务条款和隐私政策')
return
}
loading.value = true
try {
//
await new Promise(resolve => setTimeout(resolve, 1000))
//
alert('注册成功!请登录')
router.push('/login')
} catch (error) {
console.error('注册失败:', error)
alert('注册失败,请重试')
} finally {
loading.value = false
}
}
const goToLogin = () => {
router.push('/login')
}
return {
registerForm,
loading,
handleRegister,
goToLogin
}
}
}
</script>
<style scoped>
.register-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-image: url('/images/Login_back.jpg');
background-size: 100%;
background-position: center;
background-color: #f8f9fa;
padding: 20px;
}
.register-box {
width: 100%;
max-width: 400px;
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
overflow: hidden;
border: 1px solid #e9ecef;
}
.register-header {
padding: 30px 30px 20px;
text-align: center;
background: #ffffff;
color: #333333;
}
.register-header h1 {
margin: 0 0 10px;
font-size: 24px;
font-weight: normal;
color: #333333;
}
.register-header p {
margin: 0;
opacity: 0.8;
color: #666666;
}
.register-form {
padding: 30px;
}
.form-group {
margin-bottom: 20px;
}
.form-input {
width: 100%;
padding: 12px 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
transition: border-color 0.3s;
}
.form-input:focus {
outline: none;
border-color: #ddd;
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
font-size: 13px;
}
.terms-agreement {
display: flex;
align-items: center;
cursor: pointer;
}
.terms-agreement input {
margin-right: 8px;
}
.terms-link {
color: #333333;
text-decoration: none;
}
.terms-link:hover {
text-decoration: underline;
}
.register-button {
width: 100%;
padding: 12px;
background: #666666;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
.register-button:hover:not(:disabled) {
background: #dddddd;
}
.register-button:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.register-footer {
padding: 20px 30px;
text-align: center;
border-top: 1px solid #eee;
font-size: 13px;
}
.register-footer a {
color: #333333;
text-decoration: none;
}
.register-footer a:hover {
text-decoration: underline;
}
</style>

@ -0,0 +1,38 @@
<!-- src/views/Trash.vue -->
<template>
<div class="trash-view">
<MailList
:mails="trashMails"
folder="trash"
/>
</div>
</template>
<script>
import { computed } from 'vue'
import { useStore } from 'vuex'
import MailList from '@/components/mail/MailList.vue'
export default {
name: 'Trash',
components: {
MailList
},
setup() {
const store = useStore()
//
const trashMails = computed(() => store.getters.trashMails || [])
return {
trashMails
}
}
}
</script>
<style scoped>
.trash-view {
height: 100%;
}
</style>

@ -0,0 +1,59 @@
<!-- 修改 Drafts.vue 模板 -->
<template>
<div class="drafts-view">
<MailList
:mails="draftMails"
folder="drafts"
/>
<!-- 添加撰写邮件模态框 -->
<ComposeModal
v-if="showComposeModal"
@close="closeComposeModal"
:draft-mode="true"
/>
</div>
</template>
<script>
import { computed, onMounted, ref } from 'vue'
import { useStore } from 'vuex'
import MailList from '@/components/mail/MailList.vue'
import ComposeModal from '@/components/mail/ComposeModal.vue'
import { eventBus } from '@/utils/eventBus'
export default {
name: 'Drafts',
components: {
MailList,
ComposeModal
},
setup() {
const store = useStore()
const showComposeModal = ref(false)
// 稿
const draftMails = computed(() => store.getters.draftMails || [])
// 稿
onMounted(() => {
store.dispatch('loadDrafts')
//
eventBus.on('openComposeModal', () => {
showComposeModal.value = true
})
})
// 稿
const closeComposeModal = () => {
showComposeModal.value = false
store.dispatch('loadDrafts')
}
return {
draftMails,
showComposeModal,
closeComposeModal
}
}
}
</script>

@ -0,0 +1,38 @@
<!-- src/views/Sent.vue -->
<template>
<div class="sent-view">
<MailList
:mails="sentMails"
folder="sent"
/>
</div>
</template>
<script>
import { computed } from 'vue'
import { useStore } from 'vuex'
import MailList from '@/components/mail/MailList.vue'
export default {
name: 'Sent',
components: {
MailList
},
setup() {
const store = useStore()
//
const sentMails = computed(() => store.getters.sentMails || [])
return {
sentMails
}
}
}
</script>
<style scoped>
.sent-view {
height: 100%;
}
</style>

@ -0,0 +1,30 @@
<template>
<div class="starred-view">
<MailList
:mails="starredMails"
folder="starred"
/>
</div>
</template>
<script>import { computed } from 'vue'
import { useStore } from 'vuex'
import MailList from '@/components/mail/MailList.vue'
export default {
name: 'Starred',
components: {
MailList
},
setup() {
const store = useStore()
//
const starredMails = computed(() => store.getters.starredMails || [])
return {
starredMails
}
}
}
</script>

@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
Loading…
Cancel
Save