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) })
+})