feat: 重构 contacts — Vue 组件化(列表/搜索/详情/新建编辑删除弹窗)+ 27 测试

This commit is contained in:
李岩岩 2026-07-23 11:19:49 +08:00
parent 9b233a0c86
commit 6611877c31
3 changed files with 467 additions and 18 deletions

View File

@ -1 +1,157 @@
<template><div></div></template>
<template>
<div class="contacts-body">
<div class="notes-side">
<div class="notes-side-bar">
<input class="text-input notes-search" type="search" placeholder="搜索" v-model="query" @input="onSearch">
<button class="fb-btn" title="新建联系人" @click="editContact(null)"></button>
</div>
<div class="notes-list">
<div v-if="!filtered.length" class="empty-state" style="height:120px">没有联系人</div>
<div
v-for="c in filtered" :key="c.id"
class="note-row contact-row" :class="{ sel: cur === c }"
@click="selectContact(c)"
>
<span class="contact-avatar">{{ c.name.slice(0, 1) }}</span>
<div>
<div class="note-title">{{ c.name }}</div>
<div class="note-sub">{{ c.phone }}</div>
</div>
</div>
</div>
</div>
<div class="contact-detail">
<template v-if="!cur">
<div class="empty-state">
<div class="es-icon">👤</div>
<div>选择联系人</div>
</div>
</template>
<template v-else>
<div class="contact-card">
<div class="contact-big-avatar">{{ cur.name.slice(0, 1) }}</div>
<div class="contact-name">{{ cur.name }}</div>
<div v-if="cur.note" class="contact-note">{{ cur.note }}</div>
<div class="contact-fields">
<div class="contact-field">
<span class="cf-label">电话</span>
<span>{{ cur.phone }}</span>
</div>
<div class="contact-field">
<span class="cf-label">邮箱</span>
<span>{{ cur.email }}</span>
</div>
</div>
<div class="contact-actions">
<button class="btn" @click="editContact(cur)">编辑</button>
<button class="btn danger" @click="deleteContact(cur)">删除</button>
</div>
</div>
</template>
</div>
<!-- 编辑弹窗 -->
<div v-if="editing !== undefined" class="modal-mask" @click.self="closeEdit">
<div class="dialog" style="width:300px">
<div class="dlg-title">{{ editing ? '编辑联系人' : '新建联系人' }}</div>
<input ref="editNameRef" class="text-input dlg-input" placeholder="姓名" v-model="editData.name">
<input class="text-input dlg-input" placeholder="电话" v-model="editData.phone">
<input class="text-input dlg-input" placeholder="邮箱" v-model="editData.email">
<input class="text-input dlg-input" placeholder="备注" v-model="editData.note">
<div class="dlg-btns row">
<button class="btn" @click="closeEdit">取消</button>
<button class="btn primary" @click="saveContact">存储</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { uid } from '../../utils'
import { store } from '../../composables/useStore'
const props = defineProps<{ win: any }>()
const DEFAULT_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: '周末球友' },
]
const data = reactive(store.get('contacts', DEFAULT_CONTACTS))
const cur = ref<any>(null)
const query = ref('')
const queryDebounced = ref('')
let debounceTimer: any = null
const editing = ref<any>(undefined)
const editData = reactive({ id: '', name: '', phone: '', email: '', note: '' })
const editNameRef = ref<HTMLInputElement>()
function persist() { store.set('contacts', data) }
const filtered = computed(() => {
const q = queryDebounced.value.toLowerCase()
return data
.filter((c: any) => !q || (c.name + c.phone + c.email).toLowerCase().includes(q))
.sort((a: any, b: any) => a.name.localeCompare(b.name, 'zh-Hans-CN'))
})
function onSearch() {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => { queryDebounced.value = query.value.trim() }, 200)
}
function selectContact(c: any) { cur.value = c }
function editContact(c: any | null) {
if (c) {
Object.assign(editData, { id: c.id, name: c.name, phone: c.phone, email: c.email, note: c.note })
editing.value = c
} else {
Object.assign(editData, { id: uid(), name: '', phone: '', email: '', note: '' })
editing.value = null
}
nextTick(() => editNameRef.value?.focus())
}
function closeEdit() { editing.value = undefined }
function saveContact() {
if (!editData.name.trim()) { editNameRef.value?.focus(); return }
const payload = {
id: editData.id,
name: editData.name.trim(),
phone: editData.phone.trim(),
email: editData.email.trim(),
note: editData.note.trim(),
}
if (editing.value) {
const idx = data.findIndex((c: any) => c === editing.value)
if (idx !== -1) Object.assign(data[idx], payload)
cur.value = data[idx]
} else {
data.push(payload)
cur.value = payload
}
persist()
closeEdit()
}
async function deleteContact(c: any) {
const ui = (window as any).ui
if (ui) {
const confirmed = await ui.confirm('删除联系人?', `将删除"${c.name}"。`, { ok: '删除', danger: true })
if (!confirmed) return
}
data.splice(data.indexOf(c), 1)
if (cur.value === c) cur.value = null
persist()
}
defineExpose({ editContact })
</script>

View File

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

View File

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