437 lines
14 KiB
Vue
437 lines
14 KiB
Vue
<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>
|