email-unlimit/frontend/src/views/Home.vue

437 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<main id="app-main">
<section class="hero-section">
<h1>{{ $t('home.title') }}</h1>
<p>{{ $t('home.subtitle') }}<code>@{{ domain }}</code>{{ $t('home.subtitleAfter') }}</p>
<form @submit.prevent="fetchMessages" class="input-group">
<div class="input-wrapper">
<input
type="email"
v-model="recipient"
class="email-input"
:placeholder="$t('home.placeholder')"
required
/>
<button @click="copyEmail" type="button" class="btn-copy" :title="$t('home.copyTitle')">
<el-icon v-if="copyStatus === 'copied'"><Check /></el-icon>
<el-icon v-else><CopyDocument /></el-icon>
</button>
</div>
<button @click="generateRandomEmail" type="button" class="btn btn-secondary">{{ $t('home.random') }}</button>
<button type="submit" class="btn btn-primary">{{ $t('home.checkInbox') }}</button>
</form>
</section>
<section class="inbox-section">
<div class="inbox-container">
<div class="message-list">
<div class="message-list-header">
<h2>{{ $t('home.inbox') }}</h2>
<div class="header-actions">
<button @click="clearInbox" :title="$t('home.clearInbox')" :disabled="messages.length === 0">
<el-icon><Delete /></el-icon>
</button>
<button @click="fetchMessages" :title="$t('home.refresh')">
<el-icon><Refresh /></el-icon>
</button>
</div>
</div>
<div v-if="newMailNotification" class="new-mail-notification">
{{ $t('home.newMailNotification') }}
</div>
<div v-if="loading" class="loading-state">{{ $t('home.loading') }}</div>
<div v-else-if="messages.length === 0" class="empty-state">{{ $t('home.noMail') }}</div>
<div v-else>
<div
v-for="message in messages"
:key="message.id"
class="message-item"
:class="{ selected: selectedMessage && selectedMessage.id === message.id }"
@click="selectMessage(message)"
>
<div class="from">{{ message.sender }}</div>
<div class="subject">{{ message.subject }}</div>
</div>
</div>
</div>
<div class="message-detail">
<div v-if="!selectedMessage" class="empty-state">
<p>{{ $t('home.selectMail') }}</p>
</div>
<div v-else>
<div class="message-content-header">
<div class="header-text">
<h3>{{ selectedMessage.subject }}</h3>
<p class="from-line"><strong>{{ $t('home.from') }}:</strong> {{ selectedMessage.sender }}</p>
<p><strong>{{ $t('home.to') }}:</strong> {{ selectedMessage.recipient }}</p>
</div>
<div class="header-actions">
<button @click="deleteMessage(selectedMessage.id)" :title="$t('home.delete')">
<el-icon><Delete /></el-icon>
</button>
</div>
</div>
<div class="message-body" v-html="renderedBody">
</div>
<div v-if="attachmentsLoading" class="loading-state">{{ $t('home.loading') }}</div>
<div v-if="attachments.length > 0" class="attachments-section">
<h4>{{ $t('home.attachments') }}</h4>
<ul>
<li v-for="attachment in attachments" :key="attachment.id">
<a :href="`/api/attachments/${attachment.id}`" :download="attachment.filename">{{ attachment.filename }}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</section>
</main>
</template>
<script setup>
import { ref, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Delete, Refresh, CopyDocument, Check } from '@element-plus/icons-vue';
import { marked } from 'marked';
const { t } = useI18n();
const recipient = ref('');
const messages = ref([]);
const selectedMessage = ref(null);
const domain = import.meta.env.VITE_APP_DOMAIN;
if (import.meta.env.DEV) {
selectedMessage.value = {
id: 'sample-1',
sender: 'demo@example.com',
recipient: `you@${domain}`,
subject: 'Markdown 样式测试邮件',
body: `# 会议议程:项目启动会\n\n大家好\n\n这是关于 **“Email-Unlimit”** 项目启动会的议程安排。\n\n---\n\n## 会议详情\n\n- **日期**: 2025年7月30日\n- **时间**: 上午10:00\n- **地点**: 线上会议室 (链接稍后提供)\n\n## 议程\n\n1. **项目介绍** - 介绍项目目标和范围。\n2. **团队分工** - 明确各自的职责。\n3. **技术选型** - 讨论并确认技术栈。\n4. **Q&A** - 自由提问环节。\n\n请准时参加。\n\n谢谢\n\n> 这是一条重要的提醒:请提前准备好您的问题。`
};
}
const loading = ref(false);
const copyStatus = ref('idle'); // 'idle' | 'copied'
const attachments = ref([]);
const attachmentsLoading = ref(false);
const newMailNotification = ref(false);
const renderedBody = computed(() => {
if (selectedMessage.value && selectedMessage.value.body) {
return marked(selectedMessage.value.body);
}
return '';
});
let ws = null;
const setupWebSocket = () => {
if (ws) {
ws.close();
}
if (!recipient.value) return;
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws?recipient=${encodeURIComponent(recipient.value)}`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connection established');
};
ws.onmessage = (event) => {
const newEmail = JSON.parse(event.data);
messages.value.unshift(newEmail);
ElMessage.success(t('home.newMailNotification'));
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket connection closed');
};
};
onUnmounted(() => {
if (ws) {
ws.close();
}
});
const fetchMessages = async () => {
if (!recipient.value) {
ElMessage.warning(t('home.alerts.enterEmail'));
return;
}
ElMessage.info(t('home.alerts.checkInboxStart'));
loading.value = true;
selectedMessage.value = null;
attachments.value = [];
try {
const response = await fetch(`/api/messages?recipient=${recipient.value}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
messages.value = data;
if (messages.value.length > 0) {
selectMessage(messages.value[0]);
}
setupWebSocket(); // Setup WebSocket after fetching messages
} catch (error) {
console.error('Failed to fetch messages:', error);
ElMessage.error(t('home.alerts.fetchFailed'));
} finally {
loading.value = false;
}
};
const fetchAttachments = async (messageId) => {
attachmentsLoading.value = true;
attachments.value = [];
try {
const response = await fetch(`/api/messages/${messageId}/attachments`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
attachments.value = data;
} catch (error) {
console.error('Failed to fetch attachments:', error);
} finally {
attachmentsLoading.value = false;
}
};
const selectMessage = (message) => {
selectedMessage.value = message;
if (message) {
fetchAttachments(message.id);
}
};
const deleteMessage = async (messageId) => {
try {
await ElMessageBox.confirm(
t('home.alerts.confirmDelete'),
t('home.delete'),
{
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning',
}
);
const response = await fetch(`/api/messages/${messageId}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Network response was not ok');
}
const index = messages.value.findIndex(m => m.id === messageId);
if (index !== -1) {
messages.value.splice(index, 1);
}
if (selectedMessage.value && selectedMessage.value.id === messageId) {
const newIndex = Math.max(0, index - 1);
selectedMessage.value = messages.value.length > 0 ? messages.value[newIndex] : null;
if (selectedMessage.value) {
selectMessage(selectedMessage.value);
} else {
attachments.value = [];
}
}
ElMessage.success(t('home.alerts.deleteSuccess'));
} catch (action) {
if (action === 'cancel') {
ElMessage.info(t('home.alerts.actionCancelled'));
} else {
console.error('Failed to delete message:', action);
ElMessage.error(t('home.alerts.deleteFailed'));
}
}
};
const clearInbox = async () => {
if (!recipient.value || messages.value.length === 0) return;
try {
await ElMessageBox.confirm(
t('home.alerts.confirmClearAll'),
t('home.clearInbox'),
{
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning',
}
);
const response = await fetch(`/api/messages?recipient=${recipient.value}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Network response was not ok');
}
messages.value = [];
selectedMessage.value = null;
attachments.value = [];
ElMessage.success(t('home.alerts.clearSuccess'));
} catch (action) {
if (action === 'cancel') {
ElMessage.info(t('home.alerts.actionCancelled'));
} else {
console.error('Failed to clear inbox:', action);
ElMessage.error(t('home.alerts.clearFailed'));
}
}
};
const generateRandomEmail = () => {
const names = [
'alex', 'casey', 'morgan', 'jordan', 'taylor', 'jamie', 'ryan', 'drew', 'jesse', 'pat',
'chris', 'dylan', 'aaron', 'blake', 'cameron', 'devon', 'elliot', 'finn', 'gray', 'harper',
'kai', 'logan', 'max', 'noah', 'owen', 'quinn', 'riley', 'rowan', 'sage', 'skyler'
];
const places = [
'tokyo', 'paris', 'london', 'cairo', 'sydney', 'rio', 'moscow', 'rome', 'nile', 'everest',
'sahara', 'amazon', 'gobi', 'andes', 'pacific', 'kyoto', 'berlin', 'dubai', 'seoul', 'milan',
'vienna', 'prague', 'athens', 'lisbon', 'oslo', 'helsinki', 'zürich', 'geneva', 'brussels', 'amsterdam'
];
const concepts = [
'apollo', 'artemis', 'athena', 'zeus', 'thor', 'loki', 'odin', 'freya', 'phoenix', 'dragon',
'griffin', 'sphinx', 'pyramid', 'colossus', 'acropolis', 'obelisk', 'pagoda', 'castle', 'cyberspace', 'matrix',
'protocol', 'algorithm', 'pixel', 'vector', 'photon', 'quark', 'nova', 'pulsar', 'saga', 'voyage',
'enigma', 'oracle', 'cipher', 'vortex', 'helix', 'axiom', 'zenith', 'epoch', 'nexus', 'trinity'
];
const allLists = [names, places, concepts];
const getRandomItem = (arr) => arr[Math.floor(Math.random() * arr.length)];
const listA = getRandomItem(allLists);
const listB = getRandomItem(allLists);
const word1 = getRandomItem(listA);
const word2 = getRandomItem(listB);
const number = Math.floor(Math.random() * 9000) + 1000;
const separators = ['.', '-', '_', ''];
const separator = getRandomItem(separators);
let prefix;
if (word1 === word2) {
prefix = `${word1}${number}`;
} else {
prefix = `${word1}${separator}${word2}${number}`;
}
ElMessage.success(t('home.alerts.generateRandomSuccess'));
recipient.value = `${prefix}@${domain}`;
};
const copyEmail = async () => {
if (!recipient.value) return;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(recipient.value);
} else {
// fallback 方案
const textArea = document.createElement('textarea');
textArea.value = recipient.value;
textArea.style.position = 'fixed'; // 防止滚动时跳动
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const success = document.execCommand('copy');
document.body.removeChild(textArea);
if (!success) throw new Error('Fallback copy failed');
}
copyStatus.value = 'copied';
ElMessage.success(t('home.alerts.copySuccess'));
setTimeout(() => copyStatus.value = 'idle', 2000);
} catch (err) {
console.error('Copy failed:', err);
ElMessage.error(t('home.alerts.copyFailed'));
}
};
</script>
<style scoped>
.message-body {
border: 1px solid #ccc;
padding: 1rem;
margin-top: 1rem;
line-height: 1.6;
white-space: normal; /* 覆盖 pre-wrap 样式 */
}
/*
为动态渲染的 HTML 内容设置样式
使用 :deep() 来穿透 Vue 的作用域限制
*/
.message-body :deep(> :first-child) {
margin-top: 0; /* 移除第一个元素的上边距 */
}
.message-body :deep(> :last-child) {
margin-bottom: 0; /* 移除最后一个元素的下边距 */
}
.message-body :deep(h1),
.message-body :deep(h2),
.message-body :deep(h3),
.message-body :deep(h4),
.message-body :deep(h5),
.message-body :deep(h6) {
margin-top: 1.2em; /* 为标题设置合适的上边距 */
margin-bottom: 0.6em; /* 减小标题和下方内容的间距 */
font-weight: 600;
}
.message-body :deep(p) {
margin-top: 0;
margin-bottom: 0.8em; /* 为段落设置合适的下边距 */
}
.message-body :deep(ul),
.message-body :deep(ol) {
padding-left: 1.5em; /* 调整列表的缩进 */
margin-bottom: 0.8em;
}
.message-body :deep(blockquote) {
margin-left: 0;
padding-left: 1em;
border-left: 3px solid #ccc;
color: #666;
}
.message-body :deep(hr) {
margin: 1.5em 0;
border: 0;
border-top: 1px solid #eee;
}
.message-content-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.header-text {
flex-grow: 1;
}
.header-actions {
flex-shrink: 0;
}
</style>