From 47063ba8b5dcdf5a5b89cda987bb4d209bdfa818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Thu, 23 Jul 2026 11:46:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20mail=20=E2=80=94?= =?UTF-8?q?=20Vue=20=E7=BB=84=E4=BB=B6=E5=8C=96=EF=BC=88=E6=94=B6=E4=BB=B6?= =?UTF-8?q?=E7=AE=B1/=E5=B7=B2=E5=8F=91=E9=80=81/=E5=BA=9F=E7=BA=B8?= =?UTF-8?q?=E7=AF=93/=E5=86=99=E9=82=AE=E4=BB=B6/=E6=90=9C=E7=B4=A2/?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=9B=9E=E5=A4=8D=EF=BC=89+=208=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apps/mail/Mail.vue | 180 +++++++++++++++++++++++++++++++++++- src/apps/mail/index.ts | 30 +++--- tests/cases/37-mail.test.ts | 27 ++++++ 3 files changed, 217 insertions(+), 20 deletions(-) create mode 100644 tests/cases/37-mail.test.ts diff --git a/src/apps/mail/Mail.vue b/src/apps/mail/Mail.vue index 0ada1d8..b1e008b 100644 --- a/src/apps/mail/Mail.vue +++ b/src/apps/mail/Mail.vue @@ -1 +1,179 @@ - + + + diff --git a/src/apps/mail/index.ts b/src/apps/mail/index.ts index ba4c705..51ba051 100644 --- a/src/apps/mail/index.ts +++ b/src/apps/mail/index.ts @@ -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) diff --git a/tests/cases/37-mail.test.ts b/tests/cases/37-mail.test.ts new file mode 100644 index 0000000..4ef1554 --- /dev/null +++ b/tests/cases/37-mail.test.ts @@ -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) }) +})