feat: 重构 mail — Vue 组件化(收件箱/已发送/废纸篓/写邮件/搜索/自动回复)+ 8 测试

This commit is contained in:
李岩岩 2026-07-23 11:46:00 +08:00
parent bd7360784a
commit 47063ba8b5
3 changed files with 217 additions and 20 deletions

View File

@ -1 +1,179 @@
<template><div></div></template>
<template>
<div class="mail-body">
<div class="notes-side mail-col1">
<div class="notes-side-bar">
<input class="text-input notes-search" type="search" placeholder="搜索邮件" v-model="query" @input="onSearch">
<button class="fb-btn" title="新邮件" @click="compose({})"></button>
</div>
<div class="mail-side">
<div v-for="[id, name, ico] in boxes" :key="id"
class="fb-side-item" :class="{ sel: box === id }"
@click="box = id; cur = null"
>
<span class="fb-side-ico">{{ ico }}</span>
<span class="rem-lname">{{ name }}</span>
<span v-if="unreadCount(id)" class="badge">{{ unreadCount(id) }}</span>
</div>
</div>
</div>
<div class="mail-list">
<div v-if="!filtered.length" class="empty-state" style="height:160px">没有邮件</div>
<div v-for="m in filtered" :key="m.id"
class="mail-row" :class="{ sel: cur === m, unread: !m.read }"
@click="openMail(m)"
>
<div class="mail-row-top">
<b>{{ m.from }}</b>
<span class="mail-time">{{ relTime(m.ts) }}</span>
</div>
<div class="mail-subject">
<span v-if="m.star" class="mail-star"></span>
<span>{{ m.subject }}</span>
</div>
<div class="mail-preview">{{ m.body.slice(0, 48) }}</div>
</div>
</div>
<div class="mail-detail">
<template v-if="!cur">
<div class="empty-state">
<div class="es-icon"></div>
<div>选择一封邮件</div>
</div>
</template>
<template v-else>
<div class="mail-detail-head">
<div class="mail-detail-subject">{{ cur.subject }}</div>
<div class="mail-detail-meta">
<span>{{ cur.from }} &lt;{{ cur.addr }}&gt;</span>
<span>{{ new Date(cur.ts).toLocaleString('zh-CN') }}</span>
</div>
<div class="mail-detail-actions">
<button class="fb-btn" @click="cur.star = !cur.star; persist()">{{ cur.star ? '★' : '☆' }}</button>
<button class="fb-btn" @click="deleteMail(cur)">🗑</button>
<button class="btn" @click="compose({ to: cur.addr, name: cur.from, subject: '回复:' + cur.subject, quote: cur.body })">回复</button>
</div>
</div>
<div class="mail-detail-body">{{ cur.body }}</div>
</template>
</div>
<!-- 写邮件弹窗 -->
<div v-if="composing !== undefined" class="modal-mask" @click.self="composing = undefined">
<div class="dialog mail-compose">
<div class="dlg-title">新邮件</div>
<input ref="composeToRef" class="text-input dlg-input" placeholder="收件人" v-model="composeData.to">
<input class="text-input dlg-input" placeholder="主题" v-model="composeData.subject">
<textarea class="text-input mail-compose-body" placeholder="正文…" v-model="composeData.body"></textarea>
<div class="dlg-btns row">
<button class="btn" @click="composing = undefined">取消</button>
<button class="btn primary" @click="sendMail">发送</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { uid } from '../../utils'
import { store } from '../../composables/useStore'
import { Notify } from '../../composables/useNotify'
const props = defineProps<{ win: any }>()
const boxes: [string, string, string][] = [['inbox', '收件箱', '📥'], ['sent', '已发送', '📤'], ['trash', '废纸篓', '🗑']]
const DEFAULT_MAIL = {
inbox: [
{ id: uid(), from: 'Apple', addr: 'no-reply@apple.com', subject: '欢迎使用 macOS 网页版', body: '谢谢你选择 macOS 网页版!\n\n这是一封预置邮件。\n\n—— Apple 团队', ts: Date.now() - 3600000 * 5, read: false, star: true },
{ id: uid(), from: '王小明', addr: 'xiaoming@example.com', subject: '周末聚餐', body: '周六晚上老地方,七点不见不散!\n记得带上 Switch。', ts: Date.now() - 3600000 * 26, read: false, star: false },
{ id: uid(), from: 'GitHub', addr: 'notifications@github.com', subject: '[macos-web] 新的 Star', body: '你的仓库 macos-web 获得了新的 Star\n\n当前 Star 数1024', ts: Date.now() - 86400000 * 2, read: true, star: false },
{ id: uid(), from: '李思颖', addr: 'siying.li@example.com', subject: '设计评审纪要', body: '今天评审的结论:\n1. Dock 放大效果再调一版\n2. 深色模式对比度提升\n3. 下周二前出高保真\n\n辛苦大家', ts: Date.now() - 86400000 * 3, read: true, star: false },
],
sent: [] as any[],
trash: [] as any[],
}
const data = reactive(store.get('mail', DEFAULT_MAIL))
const box = ref('inbox')
const cur = ref<any>(null)
const query = ref('')
const queryDebounced = ref('')
let debounceTimer: any = null
function persist() { store.set('mail', { inbox: data.inbox, sent: data.sent, trash: data.trash }) }
const filtered = computed(() => {
const q = queryDebounced.value.toLowerCase()
return data[box.value].filter((m: any) => !q || (m.subject + m.from + m.body).toLowerCase().includes(q))
.sort((a: any, b: any) => b.ts - a.ts)
})
function unreadCount(boxId: string): number {
return data[boxId].filter((m: any) => !m.read).length
}
function onSearch() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => { queryDebounced.value = query.value.trim() }, 200)
}
function openMail(m: any) {
cur.value = m
if (!m.read) { m.read = true; persist() }
}
function deleteMail(m: any) {
data[box.value] = data[box.value].filter((x: any) => x !== m)
if (box.value !== 'trash') data.trash.push(m)
cur.value = null
persist()
}
const composing = ref<any>(undefined)
const composeData = reactive({ to: '', subject: '', body: '' })
const composeToRef = ref<HTMLInputElement>()
function compose(opts: any = {}) {
Object.assign(composeData, {
to: opts.to || '',
subject: opts.subject || '',
body: opts.quote ? `\n\n—— 原始邮件 ——\n${opts.quote}` : '',
})
composing.value = true
nextTick(() => composeToRef.value?.focus())
}
function sendMail() {
if (!composeData.to.trim()) { composeToRef.value?.focus(); return }
const mail = {
id: uid(), from: '我', addr: composeData.to.trim(),
subject: composeData.subject.trim() || '(无主题)',
body: composeData.body, ts: Date.now(), read: true, star: false,
}
data.sent.push(mail)
persist()
composing.value = undefined
Notify.send({ appId: 'mail', title: '邮件已发送', body: `发送至 ${composeData.to.trim()}`, silent: true })
setTimeout(() => {
data.inbox.push({
id: uid(), from: composeData.to.trim().split('@')[0], addr: composeData.to.trim(),
subject: '回复:' + (mail.subject || '(无主题)'),
body: '收到你的邮件了,谢谢!\n\n这是一条自动回复用于演示离线邮件流程。',
ts: Date.now(), read: false, star: false,
})
persist()
Notify.send({ appId: 'mail', title: composeData.to.trim().split('@')[0] || '新邮件', body: '回复:' + (mail.subject || '(无主题)') })
}, 4000 + Math.random() * 3000)
}
function relTime(ts: number): string {
const diff = Date.now() - ts
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
return Math.floor(diff / 86400000) + '天前'
}
props.win.appState = { compose }
defineExpose({ compose })
</script>

View File

@ -1,28 +1,20 @@
// 邮件应用 — Vue 组件化
import { h, render } from 'vue'
// 邮件应用
import { el, $$, uid, relTime, debounce } from '../../utils'
import { store as Store } from '../../composables/useStore'
import { Apps, stdMenus } from '../../composables/useApps'
import MailComponent from './Mail.vue'
import { Notify } from '../../composables/useNotify'
export const MailApp = {
id: 'mail', name: '邮件', icon: '/assets/icons/mail.png',
w: 900, h: 580, minW: 640, minH: 420,
menus(win:any){return stdMenus(this,{file:[{label:'新邮件',key:'⌘N',action:()=>win?.appState?.compose({})}]})},
store:{get(){return Store.get('mail',{inbox:[{id:uid(),from:'Apple',addr:'no-reply@apple.com',subject:'欢迎使用 macOS 网页版',body:'谢谢你选择 macOS 网页版!\n\n这是一封预置邮件。\n\n—— Apple 团队',ts:Date.now()-3600000*5,read:false,star:true},{id:uid(),from:'王小明',addr:'xiaoming@example.com',subject:'周末聚餐',body:'周六晚上老地方,七点不见不散!\n记得带上 Switch。',ts:Date.now()-3600000*26,read:false,star:false},{id:uid(),from:'GitHub',addr:'notifications@github.com',subject:'[macos-web] 新的 Star',body:'你的仓库 macos-web 获得了新的 Star\n\n当前 Star 数1024',ts:Date.now()-86400000*2,read:true,star:false},{id:uid(),from:'李思颖',addr:'siying.li@example.com',subject:'设计评审纪要',body:'今天评审的结论:\n1. Dock 放大效果再调一版\n2. 深色模式对比度提升\n3. 下周二前出高保真\n\n辛苦大家',ts:Date.now()-86400000*3,read:true,star:false}],sent:[],trash:[]})},set(v:any){Store.set('mail',v)}},
onArgs(args:any,win:any){if(args.compose)win.appState?.compose(args.compose)},
render(win:any){
const st=win.appState={data:this.store.get(),box:'inbox',cur:null as any,q:''};win.body.classList.add('mail-body')
const boxes=[['inbox','收件箱','📥'],['sent','已发送','📤'],['trash','废纸篓','🗑']];const sideEl=el('div',{class:'mail-side'}),listEl=el('div',{class:'mail-list'}),detailEl=el('div',{class:'mail-detail'}),newBtn=el('button',{class:'fb-btn',html:'✏️',title:'新邮件'}),search=el('input',{class:'text-input notes-search',type:'search',placeholder:'搜索邮件'})
win.body.append(el('div',{class:'notes-side mail-col1'},el('div',{class:'notes-side-bar'},search,newBtn),sideEl),listEl,detailEl)
const save=()=>this.store.set(st.data)
const renderSide=()=>{sideEl.innerHTML='';boxes.forEach(([id,name,ico])=>{const unread=st.data[id].filter((m:any)=>!m.read).length;const row=el('div',{class:'fb-side-item'+(st.box===id?' sel':'')},el('span',{class:'fb-side-ico',text:ico}),el('span',{class:'rem-lname',text:name}),unread?el('span',{class:'badge',text:String(unread)}):null);row.addEventListener('click',()=>{st.box=id;st.cur=null;renderAll()});sideEl.append(row)})}
const renderList=()=>{listEl.innerHTML='';const items=st.data[st.box].filter((m:any)=>!st.q||(m.subject+m.from+m.body).toLowerCase().includes(st.q.toLowerCase())).sort((a:any,b:any)=>b.ts-a.ts);if(!items.length)listEl.append(el('div',{class:'empty-state',style:{height:'160px'},text:'没有邮件'}));items.forEach((m:any)=>{const row=el('div',{class:'mail-row'+(m===st.cur?' sel':'')+(m.read?'':' unread')},el('div',{class:'mail-row-top'},el('b',{text:m.from}),el('span',{class:'mail-time',text:relTime(m.ts)})),el('div',{class:'mail-subject'},m.star?el('span',{class:'mail-star',text:'★'}):null,el('span',{text:m.subject})),el('div',{class:'mail-preview',text:m.body.slice(0,48)}));row.addEventListener('click',()=>{st.cur=m;if(!m.read){m.read=true;save()};renderAll()});listEl.append(row)})}
const renderDetail=()=>{detailEl.innerHTML='';const m=st.cur;if(!m){detailEl.append(el('div',{class:'empty-state'},el('div',{class:'es-icon',text:'✉️'}),el('div',{text:'选择一封邮件'})));return};const star=el('button',{class:'fb-btn',text:m.star?'★':'☆'});star.addEventListener('click',()=>{m.star=!m.star;save();renderAll()});const del=el('button',{class:'fb-btn',html:'🗑'});del.addEventListener('click',()=>{st.data[st.box]=st.data[st.box].filter((x:any)=>x!==m);if(st.box!=='trash')st.data.trash.push(m);st.cur=null;save();renderAll()});const reply=el('button',{class:'btn',text:'回复'});reply.addEventListener('click',()=>st.compose({to:m.addr,name:m.from,subject:'回复:'+m.subject,quote:m.body}));detailEl.append(el('div',{class:'mail-detail-head'},el('div',{class:'mail-detail-subject',text:m.subject}),el('div',{class:'mail-detail-meta'},el('span',{text:`${m.from} <${m.addr}>`}),el('span',{text:new Date(m.ts).toLocaleString('zh-CN')})),el('div',{class:'mail-detail-actions'},star,del,reply)),el('div',{class:'mail-detail-body',text:m.body}))}
st.compose=({to='',name='',subject='',quote=''}:any={})=>{const mask=el('div',{class:'modal-mask'});const toIn=el('input',{class:'text-input dlg-input',placeholder:'收件人',value:to});const subIn=el('input',{class:'text-input dlg-input',placeholder:'主题',value:subject});const bodyIn=el('textarea',{class:'text-input mail-compose-body',placeholder:'正文…'});(bodyIn as HTMLTextAreaElement).value=quote?`\n\n—— 原始邮件 ——\n${quote}`:'';const send=()=>{if(!(toIn as HTMLInputElement).value.trim()){(toIn as HTMLInputElement).focus();return};st.data.sent.push({id:uid(),from:'我',addr:(toIn as HTMLInputElement).value.trim(),subject:(subIn as HTMLInputElement).value.trim()||'(无主题)',body:(bodyIn as HTMLTextAreaElement).value,ts:Date.now(),read:true,star:false});save();mask.remove();Notify.send({appId:'mail',title:'邮件已发送',body:`发送至 ${(toIn as HTMLInputElement).value.trim()}`,silent:true});setTimeout(()=>{st.data.inbox.push({id:uid(),from:name||(toIn as HTMLInputElement).value.trim().split('@')[0],addr:(toIn as HTMLInputElement).value.trim(),subject:'回复:'+(subIn.value.trim()||'(无主题)'),body:'收到你的邮件了,谢谢!\n\n这是一条自动回复用于演示离线邮件流程。',ts:Date.now(),read:false,star:false});save();Notify.send({appId:'mail',title:name||'新邮件',body:'回复:'+(subIn.value.trim()||'(无主题)')});if(document.body.contains(win.el))renderAll()},4000+Math.random()*3000)};const dlg=el('div',{class:'dialog mail-compose'},el('div',{class:'dlg-title',text:'新邮件'}),toIn,subIn,bodyIn,el('div',{class:'dlg-btns row'},el('button',{class:'btn',text:'取消',onclick:()=>mask.remove()}),el('button',{class:'btn primary',text:'发送',onclick:send})));mask.append(dlg);document.body.append(mask);(toIn as HTMLInputElement).focus()}
newBtn.addEventListener('click',()=>st.compose({}));search.addEventListener('input',debounce(()=>{st.q=(search as HTMLInputElement).value.trim();renderList()},200))
const renderAll=()=>{renderSide();renderList();renderDetail()};renderAll()
}
menus(win: any) {
return stdMenus(this, {
file: [{ label: '新邮件', key: '⌘N', action: () => win?.appState?.compose({}) }]
})
},
onArgs(args: any, win: any) { if (args.compose) win.appState?.compose(args.compose) },
render(win: any) {
const vnode = h(MailComponent, { win })
render(vnode, win.body)
},
}
Apps.register(MailApp)

View File

@ -0,0 +1,27 @@
/**
* 37-mail: Mail Vue
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { h, render, nextTick } from 'vue'
import MailComponent from '../../src/apps/mail/Mail.vue'
import { store } from '../../src/composables/useStore'
import { setupDOM } from '../helpers'
function makeMockWin() { const el = document.createElement('div'); el.className = 'window'; document.getElementById('window-layer')?.appendChild(el); const body = document.createElement('div'); body.className = 'win-body'; el.appendChild(body); return { el, body, timers: [] as any[], data: {} } }
describe('37-mail — Mail Vue 组件化', () => {
let win: any
beforeEach(() => { localStorage.clear(); store.set('mail', null); setupDOM(); win = makeMockWin() })
afterEach(() => { render(null, win.body); win.el.remove() })
function mount() { const v = h(MailComponent, { win }); render(v, win.body) }
it('显示 3 个邮箱', () => { mount(); expect(win.body.querySelectorAll('.mail-side .fb-side-item').length).toBe(3) })
it('收件箱有邮件列表', () => { mount(); expect(win.body.querySelectorAll('.mail-row').length).toBeGreaterThanOrEqual(1) })
it('默认选中收件箱', () => { mount(); expect(win.body.querySelector('.mail-side .fb-side-item.sel')?.textContent).toContain('收件箱') })
it('搜索框存在', () => { mount(); expect(win.body.querySelector('.notes-search')).toBeTruthy() })
it('新邮件按钮存在', () => { mount(); expect(win.body.querySelector('.fb-btn')).toBeTruthy() })
it('点击邮件显示详情', async () => { mount(); const row = win.body.querySelector('.mail-row') as HTMLElement; row.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); expect(win.body.querySelector('.mail-detail-subject')).toBeTruthy() })
it('点击新邮件打开弹窗', async () => { mount(); (win.body.querySelector('.fb-btn') as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); expect(win.body.querySelector('.mail-compose')).toBeTruthy() })
it('切换邮箱清除选中', async () => { mount(); const row = win.body.querySelector('.mail-row') as HTMLElement; row.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); const side = win.body.querySelectorAll('.mail-side .fb-side-item')[1] as HTMLElement; side.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); expect(win.body.querySelector('.mail-detail .empty-state')).toBeTruthy() })
it('卸载', () => { mount(); render(null, win.body); expect(win.body.children.length).toBe(0) })
})