feat: 重构 messages — Vue 组件化(对话列表/气泡聊天/自动回复/未读标记)+ 8 测试

This commit is contained in:
李岩岩 2026-07-23 11:46:01 +08:00
parent 47063ba8b5
commit 6cb0da5b77
3 changed files with 163 additions and 17 deletions

View File

@ -1 +1,126 @@
<template><div></div></template> <template>
<div class="msg-body">
<div class="notes-side msg-side">
<div class="notes-side-bar">
<span class="fb-side-title">对话</span>
</div>
<div class="notes-list">
<div v-for="c in sortedConvs" :key="c.id"
class="note-row contact-row" :class="{ sel: cur === c }"
@click="selectConv(c)"
>
<span class="contact-avatar">{{ c.name.slice(0, 1) }}</span>
<div style="flex:1;min-width:0">
<div class="note-title">{{ c.name }}</div>
<div class="note-sub">{{ lastMsg(c) }}</div>
</div>
<span v-if="c.unread" class="badge">{{ c.unread }}</span>
</div>
</div>
</div>
<div class="msg-chat">
<template v-if="!cur">
<div class="empty-state">
<div class="es-icon">💬</div>
<div>选择对话</div>
</div>
</template>
<template v-else>
<div class="msg-header">
<span class="contact-avatar">{{ cur.name.slice(0, 1) }}</span>
<b>{{ cur.name }}</b>
</div>
<div ref="scrollEl" class="msg-scroll">
<template v-for="(m, i) in cur.msgs" :key="i">
<div v-if="i === 0 || m.ts - cur.msgs[i-1].ts > 300000" class="msg-time">{{ relTime(m.ts) }}</div>
<div class="msg-bubble-row" :class="m.from">
<div class="msg-bubble">{{ m.text }}</div>
</div>
</template>
</div>
<div class="msg-input-row">
<input
class="text-input msg-input" type="text"
placeholder="iMessage 信息"
v-model="inputText"
@keydown.stop="onKeydown"
>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick, watch } from 'vue'
import { uid } from '../../utils'
import { store } from '../../composables/useStore'
import { Notify } from '../../composables/useNotify'
const props = defineProps<{ win: any }>()
const REPLIES = ['哈哈好的', '收到!', '稍等,我马上看', '真的吗?太好了', '没问题 👌', '那就这么定了', '哈哈哈哈', '嗯嗯,知道了']
const data = reactive(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 },
],
}))
const cur = ref<any>(data.convs[0] || null)
const inputText = ref('')
const scrollEl = ref<HTMLElement>()
function persist() { store.set('messages', { convs: data.convs }) }
const sortedConvs = computed(() => [...data.convs].sort((a, b) => (b.msgs[b.msgs.length - 1]?.ts || 0) - (a.msgs[a.msgs.length - 1]?.ts || 0)))
function lastMsg(c: any): string {
const last = c.msgs[c.msgs.length - 1]
return last ? last.text : '开始聊天吧'
}
function selectConv(c: any) {
cur.value = c
c.unread = 0
persist()
nextTick(() => { if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight })
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && inputText.value.trim()) send()
}
function send() {
const v = inputText.value.trim()
if (!v || !cur.value) return
cur.value.msgs.push({ from: 'me', text: v, ts: Date.now() })
inputText.value = ''
persist()
nextTick(() => { if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight })
//
setTimeout(() => {
if (!data.convs.includes(cur.value)) return
cur.value.msgs.push({ from: 'them', text: REPLIES[Math.floor(Math.random() * REPLIES.length)], ts: Date.now() })
persist()
if (cur.value.unread === 0) {
nextTick(() => { if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight })
} else {
Notify.send({ appId: 'messages', title: cur.value.name, body: cur.value.msgs[cur.value.msgs.length - 1].text })
}
}, 1200 + Math.random() * 2200)
}
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 = { newChat: () => { /* handled by menu */ } }
defineExpose({ cur })
</script>

View File

@ -1,25 +1,19 @@
// 信息应用 — Vue 组件化
import { h, render } from '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 { Apps, stdMenus } from '../../composables/useApps'
import MessagesComponent from './Messages.vue' import MessagesComponent from './Messages.vue'
import { Notify } from '../../composables/useNotify'
export const MessagesApp = { export const MessagesApp = {
id: 'messages', name: '信息', icon: '/assets/icons/messages.png', id: 'messages', name: '信息', icon: '/assets/icons/messages.png',
w: 760, h: 540, minW: 560, minH: 380, w: 760, h: 540, minW: 560, minH: 380,
menus(win:any){return stdMenus(this,{file:[{label:'新信息',key:'⌘N',action:()=>win?.appState?.newChat()}]})}, menus(win: any) {
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)}}, return stdMenus(this, {
replies:['哈哈好的','收到!','稍等,我马上看','真的吗?太好了','没问题 👌','那就这么定了','哈哈哈哈','嗯嗯,知道了'], file: [{ label: '新信息', key: '⌘N', action: () => win?.appState?.newChat() }]
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) render(win: any) {
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 vnode = h(MessagesComponent, { win })
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)})} render(vnode, win.body)
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()
}
} }
Apps.register(MessagesApp) Apps.register(MessagesApp)

View File

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