feat: 重构 messages — Vue 组件化(对话列表/气泡聊天/自动回复/未读标记)+ 8 测试
This commit is contained in:
parent
47063ba8b5
commit
6cb0da5b77
@ -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>
|
||||||
|
|||||||
@ -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) {
|
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'})
|
const vnode = h(MessagesComponent, { win })
|
||||||
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(vnode, win.body)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Apps.register(MessagesApp)
|
Apps.register(MessagesApp)
|
||||||
|
|||||||
27
tests/cases/38-messages.test.ts
Normal file
27
tests/cases/38-messages.test.ts
Normal 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) })
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user