From 6cb0da5b776b9a87377d6e1d9e6ff7fbc2da2cb6 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:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20messages=20?= =?UTF-8?q?=E2=80=94=20Vue=20=E7=BB=84=E4=BB=B6=E5=8C=96=EF=BC=88=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E5=88=97=E8=A1=A8/=E6=B0=94=E6=B3=A1=E8=81=8A?= =?UTF-8?q?=E5=A4=A9/=E8=87=AA=E5=8A=A8=E5=9B=9E=E5=A4=8D/=E6=9C=AA?= =?UTF-8?q?=E8=AF=BB=E6=A0=87=E8=AE=B0=EF=BC=89+=208=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apps/messages/Messages.vue | 127 +++++++++++++++++++++++++++++++- src/apps/messages/index.ts | 26 +++---- tests/cases/38-messages.test.ts | 27 +++++++ 3 files changed, 163 insertions(+), 17 deletions(-) create mode 100644 tests/cases/38-messages.test.ts diff --git a/src/apps/messages/Messages.vue b/src/apps/messages/Messages.vue index 0ada1d8..80f4c25 100644 --- a/src/apps/messages/Messages.vue +++ b/src/apps/messages/Messages.vue @@ -1 +1,126 @@ - + + + diff --git a/src/apps/messages/index.ts b/src/apps/messages/index.ts index 59447e3..b7a5a8d 100644 --- a/src/apps/messages/index.ts +++ b/src/apps/messages/index.ts @@ -1,25 +1,19 @@ +// 信息应用 — Vue 组件化 import { h, render } from 'vue' -// 信息应用 -import { el, uid, relTime } from '../../utils' -import { store as Store } from '../../composables/useStore' -import { ui as UI } from '../../composables/useUI' import { Apps, stdMenus } from '../../composables/useApps' import MessagesComponent from './Messages.vue' -import { Notify } from '../../composables/useNotify' export const MessagesApp = { id: 'messages', name: '信息', icon: '/assets/icons/messages.png', w: 760, h: 540, minW: 560, minH: 380, - menus(win:any){return stdMenus(this,{file:[{label:'新信息',key:'⌘N',action:()=>win?.appState?.newChat()}]})}, - store:{get(){return Store.get('messages',{convs:[{id:'c1',name:'王小明',msgs:[{from:'them',text:'在吗?周末的球局你还来吗',ts:Date.now()-7200000},{from:'me',text:'来!老时间老地方?',ts:Date.now()-7000000},{from:'them',text:'对,下午三点,别迟到',ts:Date.now()-6900000}],unread:0},{id:'c2',name:'李思颖',msgs:[{from:'them',text:'设计稿我看完了,整体很棒',ts:Date.now()-4000000},{from:'them',text:'Dock 的放大曲线再顺滑一点就更好了',ts:Date.now()-3900000}],unread:2},{id:'c3',name:'妈妈',msgs:[{from:'them',text:'吃饭了吗?最近降温,多穿点',ts:Date.now()-90000000}],unread:1}]})},set(v:any){Store.set('messages',v)}}, - replies:['哈哈好的','收到!','稍等,我马上看','真的吗?太好了','没问题 👌','那就这么定了','哈哈哈哈','嗯嗯,知道了'], - render(win:any){ - const st=win.appState={data:this.store.get(),cur:null as any};win.body.classList.add('msg-body');const listEl=el('div',{class:'notes-list'}),chatEl=el('div',{class:'msg-chat'}) - win.body.append(el('div',{class:'notes-side msg-side'},el('div',{class:'notes-side-bar'},el('span',{class:'fb-side-title',text:'对话'})),listEl),chatEl);const save=()=>this.store.set(st.data) - st.newChat=async()=>{const name=await UI.prompt('新对话','输入联系人姓名:','');if(!name)return;const conv={id:uid(),name,msgs:[],unread:0};st.data.convs.push(conv);st.cur=conv;save();renderAll()} - const renderList=()=>{listEl.innerHTML='';st.data.convs.sort((a:any,b:any)=>(b.msgs[b.msgs.length-1]?.ts||0)-(a.msgs[a.msgs.length-1]?.ts||0)).forEach((c:any)=>{const last=c.msgs[c.msgs.length-1];const row=el('div',{class:'note-row contact-row'+(c===st.cur?' sel':'')},el('span',{class:'contact-avatar',text:c.name.slice(0,1)}),el('div',{style:{flex:'1',minWidth:'0'}},el('div',{class:'note-title',text:c.name}),el('div',{class:'note-sub',text:last?last.text:'开始聊天吧'})),c.unread?el('span',{class:'badge',text:String(c.unread)}):null);row.addEventListener('click',()=>{st.cur=c;c.unread=0;save();renderAll()});listEl.append(row)})} - const renderChat=()=>{chatEl.innerHTML='';const c=st.cur;if(!c){chatEl.append(el('div',{class:'empty-state'},el('div',{class:'es-icon',text:'💬'}),el('div',{text:'选择对话'})));return};const scroll=el('div',{class:'msg-scroll'});let lastTs=0;c.msgs.forEach((m:any)=>{if(m.ts-lastTs>300000)scroll.append(el('div',{class:'msg-time',text:relTime(m.ts)}));lastTs=m.ts;scroll.append(el('div',{class:'msg-bubble-row '+m.from},el('div',{class:'msg-bubble',text:m.text})))});const input=el('input',{class:'text-input msg-input',type:'text',placeholder:'iMessage 信息'});const send=()=>{const v=(input as HTMLInputElement).value.trim();if(!v)return;c.msgs.push({from:'me',text:v,ts:Date.now()});(input as HTMLInputElement).value='';save();renderChat();renderList();setTimeout(()=>{if(!st.data.convs.includes(c))return;c.msgs.push({from:'them',text:this.replies[Math.floor(Math.random()*this.replies.length)],ts:Date.now()});save();if(st.cur===c&&document.body.contains(chatEl))renderChat();else{c.unread++;Notify.send({appId:'messages',title:c.name,body:c.msgs[c.msgs.length-1].text})};renderList()},1200+Math.random()*2200)};input.addEventListener('keydown',(e:KeyboardEvent)=>{e.stopPropagation();if(e.key==='Enter')send()});chatEl.append(el('div',{class:'msg-header'},el('span',{class:'contact-avatar',text:c.name.slice(0,1)}),el('b',{text:c.name})),scroll,el('div',{class:'msg-input-row'},input));setTimeout(()=>{scroll.scrollTop=scroll.scrollHeight},30)} - const renderAll=()=>{renderList();renderChat()};st.cur=st.data.convs[0]||null;renderAll() - } + menus(win: any) { + return stdMenus(this, { + file: [{ label: '新信息', key: '⌘N', action: () => win?.appState?.newChat() }] + }) + }, + render(win: any) { + const vnode = h(MessagesComponent, { win }) + render(vnode, win.body) + }, } Apps.register(MessagesApp) diff --git a/tests/cases/38-messages.test.ts b/tests/cases/38-messages.test.ts new file mode 100644 index 0000000..5b53fb7 --- /dev/null +++ b/tests/cases/38-messages.test.ts @@ -0,0 +1,27 @@ +/** + * 38-messages: Messages Vue 组件化测试 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { h, render, nextTick } from 'vue' +import MessagesComponent from '../../src/apps/messages/Messages.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('38-messages — Messages Vue 组件化', () => { + let win: any + beforeEach(() => { localStorage.clear(); store.set('messages', null); setupDOM(); win = makeMockWin() }) + afterEach(() => { render(null, win.body); win.el.remove() }) + function mount() { const v = h(MessagesComponent, { win }); render(v, win.body) } + + it('显示对话列表', () => { mount(); expect(win.body.querySelectorAll('.contact-row').length).toBeGreaterThanOrEqual(1) }) + it('默认选中第一个对话', () => { mount(); expect(win.body.querySelector('.contact-row.sel')).toBeTruthy() }) + it('显示聊天界面', () => { mount(); expect(win.body.querySelector('.msg-chat')).toBeTruthy() }) + it('显示消息气泡', () => { mount(); expect(win.body.querySelector('.msg-bubble')).toBeTruthy() }) + it('输入框存在', () => { mount(); expect(win.body.querySelector('.msg-input')).toBeTruthy() }) + it('Enter 发送消息', async () => { mount(); await nextTick(); const input = win.body.querySelector('.msg-input') as HTMLInputElement; input.value = '你好'; input.dispatchEvent(new Event('input', { bubbles: true })); await nextTick(); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await nextTick(); expect(input.value).toBe('') }) + it('切换对话更新聊天', async () => { mount(); const rows = win.body.querySelectorAll('.contact-row'); (rows[1] as HTMLElement)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); expect(rows[1].classList.contains('sel')).toBe(true) }) + it('未读标记清零', async () => { store.set('messages', { convs: [{ id: 'c1', name: '测试', msgs: [{ from: 'them', text: 'Hi', ts: Date.now() - 1000 }], unread: 3 }] }); mount(); const row = win.body.querySelector('.contact-row') as HTMLElement; row.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); const data = store.get('messages', {}); expect(data.convs[0].unread).toBe(0) }) + it('卸载', () => { mount(); render(null, win.body); expect(win.body.children.length).toBe(0) }) +})