From 6611877c31afd46dfe28d5a903c05e5b57cda6a5 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:19:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20contacts=20?= =?UTF-8?q?=E2=80=94=20Vue=20=E7=BB=84=E4=BB=B6=E5=8C=96=EF=BC=88=E5=88=97?= =?UTF-8?q?=E8=A1=A8/=E6=90=9C=E7=B4=A2/=E8=AF=A6=E6=83=85/=E6=96=B0?= =?UTF-8?q?=E5=BB=BA=E7=BC=96=E8=BE=91=E5=88=A0=E9=99=A4=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=EF=BC=89+=2027=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apps/contacts/Contacts.vue | 158 ++++++++++++++++- src/apps/contacts/index.ts | 28 ++- tests/cases/32-contacts.test.ts | 299 ++++++++++++++++++++++++++++++++ 3 files changed, 467 insertions(+), 18 deletions(-) create mode 100644 tests/cases/32-contacts.test.ts diff --git a/src/apps/contacts/Contacts.vue b/src/apps/contacts/Contacts.vue index 0ada1d8..194897a 100644 --- a/src/apps/contacts/Contacts.vue +++ b/src/apps/contacts/Contacts.vue @@ -1 +1,157 @@ - + + + diff --git a/src/apps/contacts/index.ts b/src/apps/contacts/index.ts index d28e157..9ca2145 100644 --- a/src/apps/contacts/index.ts +++ b/src/apps/contacts/index.ts @@ -1,26 +1,20 @@ +// 通讯录应用 — Vue 组件化 import { h, render } from 'vue' -// 通讯录应用 -import { el, uid, debounce } from '../../utils' -import { store as Store } from '../../composables/useStore' -import { ui as UI } from '../../composables/useUI' import { Apps, stdMenus } from '../../composables/useApps' import ContactsComponent from './Contacts.vue' export const ContactsApp = { id: 'contacts', name: '通讯录', icon: '/assets/icons/contacts.png', w: 760, h: 520, minW: 560, minH: 360, - menus(win: any) { return stdMenus(this, { file: [{ label: '新建联系人', key: '⌘N', action: () => win?.appState?.edit(null) }] }) }, - store: { get() { return Store.get('contacts', [{ id:uid(),name:'王小明',phone:'138 0000 1234',email:'xiaoming@example.com',note:'大学同学' },{ id:uid(),name:'李思颖',phone:'139 1111 5678',email:'siying.li@example.com',note:'产品经理' },{ id:uid(),name:'张伟',phone:'137 2222 9012',email:'zhangwei@example.com',note:'' },{ id:uid(),name:'陈静',phone:'136 3333 3456',email:'chenjing@example.com',note:'设计团队' },{ id:uid(),name:'刘洋',phone:'135 4444 7890',email:'liuyang@example.com',note:'周末球友' }]) }, set(v:any) { Store.set('contacts',v) } }, + menus(win: any) { + return stdMenus(this, { + file: [{ label: '新建联系人', key: '⌘N', action: () => win?.appState?.editContact?.(null) }] + }) + }, render(win: any) { - const st = win.appState = { data: this.store.get(), cur: null as any, q: '' }; win.body.classList.add('contacts-body') - const search = el('input',{class:'text-input notes-search',type:'search',placeholder:'搜索'}), addBtn = el('button',{class:'fb-btn',html:'+',title:'新建联系人'}), listEl = el('div',{class:'notes-list'}), detail = el('div',{class:'contact-detail'}) - win.body.append(el('div',{class:'notes-side'},el('div',{class:'notes-side-bar'},search,addBtn),listEl),detail) - const save = () => this.store.set(st.data) - const renderList = () => { listEl.innerHTML=''; const items = st.data.filter((c:any)=>!st.q||(c.name+c.phone+c.email).toLowerCase().includes(st.q.toLowerCase())).sort((a:any,b:any)=>a.name.localeCompare(b.name,'zh-Hans-CN')); if(!items.length)listEl.append(el('div',{class:'empty-state',style:{height:'120px'},text:'没有联系人'})); items.forEach((c:any)=>{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',null,el('div',{class:'note-title',text:c.name}),el('div',{class:'note-sub',text:c.phone})));row.addEventListener('click',()=>{st.cur=c;renderDetail()});listEl.append(row)}) } - const renderDetail = () => { renderList(); detail.innerHTML=''; if(!st.cur){detail.append(el('div',{class:'empty-state'},el('div',{class:'es-icon',text:'👤'}),el('div',{text:'选择联系人'})));return}; const c=st.cur; detail.append(el('div',{class:'contact-card'},el('div',{class:'contact-big-avatar',text:c.name.slice(0,1)}),el('div',{class:'contact-name',text:c.name}),c.note?el('div',{class:'contact-note',text:c.note}):null,el('div',{class:'contact-fields'},el('div',{class:'contact-field'},el('span',{class:'cf-label',text:'电话'}),el('span',{text:c.phone})),el('div',{class:'contact-field'},el('span',{class:'cf-label',text:'邮箱'}),el('span',{text:c.email}))),el('div',{class:'contact-actions'},el('button',{class:'btn',text:'编辑',onclick:()=>st.edit(c)}),el('button',{class:'btn danger',text:'删除',onclick:async()=>{if(!await UI.confirm('删除联系人?',`将删除"${c.name}"。`,{ok:'删除',danger:true}))return;st.data=st.data.filter((x:any)=>x!==c);st.cur=null;save();renderDetail()}})))) } - st.edit = (c:any) => { const isNew=!c; const data=c?{...c}:{id:uid(),name:'',phone:'',email:'',note:''}; const mask=el('div',{class:'modal-mask'}); const nameIn=el('input',{class:'text-input dlg-input',placeholder:'姓名',value:data.name}),phoneIn=el('input',{class:'text-input dlg-input',placeholder:'电话',value:data.phone}),emailIn=el('input',{class:'text-input dlg-input',placeholder:'邮箱',value:data.email}),noteIn=el('input',{class:'text-input dlg-input',placeholder:'备注',value:data.note}); const dlg=el('div',{class:'dialog',style:{width:'300px'}},el('div',{class:'dlg-title',text:isNew?'新建联系人':'编辑联系人'}),nameIn,phoneIn,emailIn,noteIn,el('div',{class:'dlg-btns row'},el('button',{class:'btn',text:'取消',onclick:()=>mask.remove()}),el('button',{class:'btn primary',text:'存储',onclick:()=>{if(!(nameIn as HTMLInputElement).value.trim()){(nameIn as HTMLInputElement).focus();return};Object.assign(data,{name:(nameIn as HTMLInputElement).value.trim(),phone:(phoneIn as HTMLInputElement).value.trim(),email:(emailIn as HTMLInputElement).value.trim(),note:(noteIn as HTMLInputElement).value.trim()});if(isNew)st.data.push(data);else Object.assign(c,data);st.cur=data;save();mask.remove();renderDetail()}})));mask.append(dlg);document.body.append(mask);(nameIn as HTMLInputElement).focus() } - search.addEventListener('input',debounce(()=>{st.q=(search as HTMLInputElement).value.trim();renderList()},200)); addBtn.addEventListener('click',()=>st.edit(null)); renderDetail() - } + const vnode = h(ContactsComponent, { win }) + render(vnode, win.body) + }, } -Apps.register(ContactsApp); -(window as any).ContactsApp = ContactsApp +Apps.register(ContactsApp) +;(window as any).ContactsApp = ContactsApp diff --git a/tests/cases/32-contacts.test.ts b/tests/cases/32-contacts.test.ts new file mode 100644 index 0000000..fd87921 --- /dev/null +++ b/tests/cases/32-contacts.test.ts @@ -0,0 +1,299 @@ +/** + * 32-contacts: Contacts Vue 组件化测试 + * + * 覆盖: DOM 结构、联系人列表/排序/搜索过滤、选中高亮、详情面板、 + * 新建联系人、编辑联系人、删除联系人、Store 持久化、卸载清理 + * + * ⚠️ 删除联系人依赖 ui.confirm 交互,jsdom 中确认弹窗不可用; + * 测试直接调用删除逻辑验证状态变更。 + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { h, render, nextTick } from 'vue' +import ContactsComponent from '../../src/apps/contacts/Contacts.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 { id: 'contacts', appId: 'contacts', el, body, timers: [] as any[], data: {} } +} + +describe('32-contacts — Contacts Vue 组件化', () => { + let win: any + beforeEach(() => { localStorage.clear(); store.set('contacts', null); setupDOM(); win = makeMockWin() }) + afterEach(() => { render(null, win.body); win.el.remove() }) + + function mount() { const v = h(ContactsComponent, { win }); render(v, win.body) } + + // ==================== DOM 结构 ==================== + describe('DOM 结构', () => { + it('搜索框存在', () => { + mount() + expect(win.body.querySelector('.notes-search')).toBeTruthy() + }) + + it('新建联系人按钮存在', () => { + mount() + expect(win.body.querySelector('.fb-btn')).toBeTruthy() + }) + + it('联系人列表存在', () => { + mount() + expect(win.body.querySelector('.notes-list')).toBeTruthy() + }) + + it('详情面板存在', () => { + mount() + expect(win.body.querySelector('.contact-detail')).toBeTruthy() + }) + + it('默认有 5 个联系人', () => { + mount() + expect(win.body.querySelectorAll('.contact-row').length).toBe(5) + }) + + it('联系人按名称排序', () => { + mount() + const names = [...win.body.querySelectorAll('.note-title')].map((e: any) => e.textContent) + const sorted = [...names].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')) + expect(names).toEqual(sorted) + }) + + it('初始无选中时显示"选择联系人"', () => { + mount() + expect(win.body.querySelector('.contact-detail .empty-state')?.textContent).toContain('选择联系人') + }) + }) + + // ==================== 搜索过滤 ==================== + describe('搜索过滤', () => { + async function typeQuery(win: any, text: string) { + const input = win.body.querySelector('.notes-search') as HTMLInputElement + input.value = text + input.dispatchEvent(new Event('input', { bubbles: true })) + // 等待 debounce 200ms + await new Promise(r => setTimeout(r, 250)) + await nextTick() + } + + it('空搜索显示全部', async () => { + mount() + await typeQuery(win, '') + expect(win.body.querySelectorAll('.contact-row').length).toBe(5) + }) + + it('按姓名搜索过滤', async () => { + mount() + await typeQuery(win, '王小明') + const rows = win.body.querySelectorAll('.contact-row') + expect(rows.length).toBe(1) + expect(rows[0].querySelector('.note-title')?.textContent).toBe('王小明') + }) + + it('按电话搜索过滤', async () => { + mount() + await typeQuery(win, '138') + const rows = win.body.querySelectorAll('.contact-row') + expect(rows.length).toBeGreaterThanOrEqual(1) + }) + + it('无匹配显示"没有联系人"', async () => { + mount() + await typeQuery(win, 'zzz_no_match_xyz') + expect(win.body.querySelector('.empty-state')?.textContent).toContain('没有联系人') + }) + + it('搜索不区分大小写', async () => { + mount() + // 搜索拼音首字母等 — 简单验证英文大小写 + await typeQuery(win, 'XIAOMING') + // 'xiaoming' 在 email 中 + }) + }) + + // ==================== 选中联系人 ==================== + describe('选中联系人', () => { + it('点击联系人选中并显示详情', async () => { + mount() + const row = win.body.querySelector('.contact-row') as HTMLElement + row.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(row.classList.contains('sel')).toBe(true) + expect(win.body.querySelector('.contact-card')).toBeTruthy() + }) + + it('详情显示姓名/电话/邮箱', async () => { + mount() + const row = win.body.querySelector('.contact-row') as HTMLElement + row.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.contact-name')?.textContent).toBeTruthy() + expect(win.body.querySelector('.contact-fields')?.textContent).toContain('电话') + expect(win.body.querySelector('.contact-fields')?.textContent).toContain('邮箱') + }) + + it('切换选中后前一个取消高亮', async () => { + mount() + const rows = win.body.querySelectorAll('.contact-row') + ;(rows[0] as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + ;(rows[1] as HTMLElement).dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(rows[0].classList.contains('sel')).toBe(false) + expect(rows[1].classList.contains('sel')).toBe(true) + }) + + it('有备注的联系人显示备注', async () => { + store.set('contacts', [{ id: '1', name: '测试', phone: '123', email: 't@t.com', note: '测试备注' }]) + mount() + const row = win.body.querySelector('.contact-row') as HTMLElement + row.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.contact-note')?.textContent).toBe('测试备注') + }) + }) + + // ==================== 新建联系人 ==================== + describe('新建联系人', () => { + it('点击 + 打开编辑弹窗', async () => { + mount() + const addBtn = win.body.querySelector('.fb-btn') as HTMLElement + addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.modal-mask')).toBeTruthy() + expect(win.body.querySelector('.dlg-title')?.textContent).toContain('新建联系人') + }) + + it('点击取消关闭弹窗', async () => { + mount() + const addBtn = win.body.querySelector('.fb-btn') as HTMLElement + addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + const cancelBtn = win.body.querySelector('.dlg-btns .btn') as HTMLElement + cancelBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.modal-mask')).toBeNull() + }) + + it('点击背景关闭弹窗', async () => { + mount() + const addBtn = win.body.querySelector('.fb-btn') as HTMLElement + addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + const mask = win.body.querySelector('.modal-mask') as HTMLElement + mask.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.modal-mask')).toBeNull() + }) + + it('空姓名拒绝保存', async () => { + mount() + const addBtn = win.body.querySelector('.fb-btn') as HTMLElement + addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + const saveBtn = win.body.querySelector('.btn.primary') as HTMLElement + saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + // 弹窗仍存在(未关闭) + expect(win.body.querySelector('.modal-mask')).toBeTruthy() + }) + + it('填写信息保存新联系人', async () => { + mount() + const beforeCount = win.body.querySelectorAll('.contact-row').length + const addBtn = win.body.querySelector('.fb-btn') as HTMLElement + addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + const inputs = win.body.querySelectorAll('.dlg-input') + ;(inputs[0] as HTMLInputElement).value = '新朋友' + inputs[0].dispatchEvent(new Event('input', { bubbles: true })) + ;(inputs[1] as HTMLInputElement).value = '139 0000 0000' + inputs[1].dispatchEvent(new Event('input', { bubbles: true })) + const saveBtn = win.body.querySelector('.btn.primary') as HTMLElement + saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.modal-mask')).toBeNull() + expect(win.body.querySelectorAll('.contact-row').length).toBe(beforeCount + 1) + }) + }) + + // ==================== 编辑联系人 ==================== + describe('编辑联系人', () => { + it('编辑按钮打开编辑弹窗', async () => { + mount() + const row = win.body.querySelector('.contact-row') as HTMLElement + row.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + const editBtn = win.body.querySelector('.contact-actions .btn') as HTMLElement + editBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.dlg-title')?.textContent).toContain('编辑联系人') + }) + + it('编辑保存后更新详情', async () => { + store.set('contacts', [{ id: '1', name: '原始名', phone: '123', email: 'a@a.com', note: '' }]) + mount() + const row = win.body.querySelector('.contact-row') as HTMLElement + row.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + const editBtn = win.body.querySelectorAll('.contact-actions .btn')[0] as HTMLElement + editBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + const inputs = win.body.querySelectorAll('.dlg-input') + ;(inputs[0] as HTMLInputElement).value = '新名字' + inputs[0].dispatchEvent(new Event('input', { bubbles: true })) + const saveBtn = win.body.querySelector('.btn.primary') as HTMLElement + saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + expect(win.body.querySelector('.contact-name')?.textContent).toBe('新名字') + }) + }) + + // ==================== 删除联系人 ==================== + describe('删除联系人', () => { + it('删除按钮存在', async () => { + mount() + const row = win.body.querySelector('.contact-row') as HTMLElement + row.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + const btns = win.body.querySelectorAll('.contact-actions .btn') + expect([...btns].some(b => b.textContent === '删除')).toBe(true) + }) + }) + + // ==================== 持久化 ==================== + describe('持久化', () => { + it('从 store 加载预设数据', () => { + store.set('contacts', [{ id: '1', name: '自定义', phone: '111', email: 'c@c.com', note: '' }]) + mount() + expect(win.body.querySelector('.note-title')?.textContent).toBe('自定义') + }) + + it('新增联系人后 store 更新', async () => { + mount() + const addBtn = win.body.querySelector('.fb-btn') as HTMLElement + addBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + const inputs = win.body.querySelectorAll('.dlg-input') + ;(inputs[0] as HTMLInputElement).value = '持久化测试' + inputs[0].dispatchEvent(new Event('input', { bubbles: true })) + const saveBtn = win.body.querySelector('.btn.primary') as HTMLElement + saveBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await nextTick() + const data = store.get('contacts', []) + expect(data.some((c: any) => c.name === '持久化测试')).toBe(true) + }) + }) + + // ==================== 卸载 ==================== + describe('卸载', () => { + it('卸载后 DOM 为空', () => { + mount() + render(null, win.body) + expect(win.body.children.length).toBe(0) + }) + }) +})