feat: 重构 facetime — 声明式模板(联系人/呼叫/静音/结束)+ 7 测试
This commit is contained in:
parent
8bf9c54d2e
commit
421660ab11
@ -1,75 +1,91 @@
|
||||
<template>
|
||||
<div class="ft-body">
|
||||
<div ref="stageEl" class="ft-stage"></div>
|
||||
<div class="ft-stage">
|
||||
<!-- 空闲:联系人选择 -->
|
||||
<template v-if="phase === 'idle'">
|
||||
<div class="ft-title">选择联系人开始视频通话</div>
|
||||
<div class="ft-grid">
|
||||
<div v-for="c in contacts" :key="c.id" class="ft-cell">
|
||||
<span class="contact-big-avatar">{{ c.name.slice(0, 1) }}</span>
|
||||
<div>{{ c.name }}</div>
|
||||
<button class="btn primary" @click="startCall(c)">📹 呼叫</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 通话中 -->
|
||||
<template v-else-if="phase !== 'idle'">
|
||||
<div class="ft-video" :class="{ live: phase === 'connected' }">
|
||||
<span class="contact-big-avatar ft-avatar">{{ contact?.name?.slice(0, 1) }}</span>
|
||||
</div>
|
||||
<div class="ft-status">{{ statusText }}</div>
|
||||
<div v-if="phase !== 'ended'" class="ft-self" :class="{ hidden: camOff }">
|
||||
<span>🧑</span>
|
||||
</div>
|
||||
<div class="ft-controls">
|
||||
<button class="ft-ctl" :class="{ off: micMuted }" title="静音" @click="micMuted = !micMuted">🎙</button>
|
||||
<button class="ft-ctl" :class="{ off: camOff }" title="关闭视频" @click="camOff = !camOff">📷</button>
|
||||
<button class="ft-ctl end" title="结束通话" @click="endCall">📞</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { el } from '../../utils'
|
||||
import { ContactsApp } from '../contacts/index'
|
||||
import { ref, reactive, onMounted, onUnmounted, nextTick, computed } from 'vue'
|
||||
import { store } from '../../composables/useStore'
|
||||
|
||||
const props = defineProps<{ win: any }>()
|
||||
const stageEl = ref<HTMLElement>()
|
||||
const st = reactive({ phase: 'idle' as string, contact: null as any, _t: null as any })
|
||||
|
||||
function renderIdle() {
|
||||
if (!stageEl.value) return
|
||||
stageEl.value.innerHTML = ''
|
||||
const grid = el('div', { class: 'ft-grid' })
|
||||
ContactsApp.store.get().forEach((c: any) => {
|
||||
const cell = el('div', { class: 'ft-cell' },
|
||||
el('span', { class: 'contact-big-avatar', text: c.name.slice(0, 1) }),
|
||||
el('div', { text: c.name }),
|
||||
el('button', { class: 'btn primary', text: '📹 呼叫', onclick: () => startCall(c) }))
|
||||
grid.append(cell)
|
||||
})
|
||||
stageEl.value.append(el('div', { class: 'ft-title', text: '选择联系人开始视频通话' }), grid)
|
||||
}
|
||||
const phase = ref('idle') // idle | calling | connected | ended
|
||||
const contact = ref<any>(null)
|
||||
const micMuted = ref(false)
|
||||
const camOff = ref(false)
|
||||
const callSeconds = ref(0)
|
||||
let callTimer: any = null
|
||||
let connectTimer: any = null
|
||||
|
||||
const contacts = computed(() => store.get('contacts', []))
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!contact.value) return ''
|
||||
if (phase.value === 'calling') return `正在呼叫 ${contact.value.name}…`
|
||||
if (phase.value === 'connected') {
|
||||
return `${contact.value.name} · ${String(Math.floor(callSeconds.value / 60)).padStart(2, '0')}:${String(callSeconds.value % 60).padStart(2, '0')}`
|
||||
}
|
||||
if (phase.value === 'ended') return '通话已结束'
|
||||
return ''
|
||||
})
|
||||
|
||||
function startCall(c: any) {
|
||||
st.contact = c; st.phase = 'calling'; renderCall()
|
||||
st._t = setTimeout(() => { if (st.phase === 'calling') { st.phase = 'connected'; renderCall() } }, 2500)
|
||||
contact.value = c
|
||||
phase.value = 'calling'
|
||||
connectTimer = setTimeout(() => {
|
||||
if (phase.value === 'calling') {
|
||||
phase.value = 'connected'
|
||||
callSeconds.value = 0
|
||||
callTimer = setInterval(() => { callSeconds.value++ }, 1000)
|
||||
}
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
function renderCall() {
|
||||
if (!stageEl.value) return
|
||||
const c = st.contact
|
||||
stageEl.value.innerHTML = ''
|
||||
let secs = 0
|
||||
const statusEl = el('div', { class: 'ft-status' })
|
||||
const video = el('div', { class: 'ft-video' + (st.phase === 'connected' ? ' live' : '') },
|
||||
el('span', { class: 'contact-big-avatar ft-avatar', text: c.name.slice(0, 1) }))
|
||||
if (st.phase === 'calling') statusEl.textContent = `正在呼叫 ${c.name}…`
|
||||
else if (st.phase === 'connected') statusEl.textContent = `${c.name} · 00:00`
|
||||
|
||||
const selfView = el('div', { class: 'ft-self' }, el('span', { text: '🧑' }))
|
||||
const muteBtn = el('button', { class: 'ft-ctl', html: '🎙', title: '静音' })
|
||||
const camBtn = el('button', { class: 'ft-ctl', html: '📷', title: '关闭视频' })
|
||||
const endBtn = el('button', { class: 'ft-ctl end', html: '📞', title: '结束通话' })
|
||||
|
||||
let timer: any = null
|
||||
if (st.phase === 'connected') {
|
||||
timer = setInterval(() => {
|
||||
secs++
|
||||
if (statusEl.isConnected) statusEl.textContent = `${c.name} · ${String(Math.floor(secs / 60)).padStart(2, '0')}:${String(secs % 60).padStart(2, '0')}`
|
||||
}, 1000)
|
||||
props.win.timers.push(timer)
|
||||
}
|
||||
muteBtn.addEventListener('click', () => muteBtn.classList.toggle('off'))
|
||||
camBtn.addEventListener('click', () => { selfView.classList.toggle('hidden'); camBtn.classList.toggle('off') })
|
||||
endBtn.addEventListener('click', () => { clearInterval(timer); clearTimeout(st._t); st.phase = 'ended'; statusEl.textContent = '通话已结束'; video.classList.remove('live'); setTimeout(renderIdle, 1200) })
|
||||
stageEl.value.append(video, statusEl, st.phase !== 'ended' ? selfView : null, el('div', { class: 'ft-controls' }, muteBtn, camBtn, endBtn))
|
||||
function endCall() {
|
||||
clearInterval(callTimer)
|
||||
clearTimeout(connectTimer)
|
||||
phase.value = 'ended'
|
||||
setTimeout(() => { phase.value = 'idle'; micMuted.value = false; camOff.value = false }, 1200)
|
||||
}
|
||||
|
||||
// Expose for onArgs
|
||||
props.win.appState = { startCall }
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(renderIdle)
|
||||
const args = props.win.data
|
||||
if (args?.call) startCall(args.call)
|
||||
})
|
||||
|
||||
onUnmounted(() => { clearTimeout(st._t) })
|
||||
onUnmounted(() => {
|
||||
clearInterval(callTimer)
|
||||
clearTimeout(connectTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
33
tests/cases/34-facetime.test.ts
Normal file
33
tests/cases/34-facetime.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 34-facetime: FaceTime Vue 组件化测试
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { h, render, nextTick } from 'vue'
|
||||
import FaceTimeComponent from '../../src/apps/facetime/FaceTime.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: 'ft', appId: 'facetime', el, body, timers: [] as any[], data: {} }
|
||||
}
|
||||
|
||||
describe('34-facetime — FaceTime Vue 组件化', () => {
|
||||
let win: any
|
||||
beforeEach(() => { setupDOM(); win = makeMockWin()
|
||||
store.set('contacts', [{ id: '1', name: '测试人', phone: '123', email: 't@t.com', note: '' }])
|
||||
})
|
||||
afterEach(() => { render(null, win.body); win.el.remove() })
|
||||
function mount() { const v = h(FaceTimeComponent, { win }); render(v, win.body) }
|
||||
|
||||
it('显示联系人选择界面', () => { mount(); expect(document.querySelector('.ft-grid')).toBeTruthy() })
|
||||
it('联系人列表包含联系人', () => { mount(); expect(document.querySelectorAll('.ft-cell').length).toBeGreaterThanOrEqual(1) })
|
||||
it('点击呼叫开始拨打', async () => { mount(); (document.querySelector('.btn.primary') as HTMLElement)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); expect(document.querySelector('.ft-status')?.textContent).toContain('正在呼叫') })
|
||||
it('麦克风静音切换', async () => { mount(); (document.querySelector('.btn.primary') as HTMLElement)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); const micBtn = document.querySelector('.ft-ctl') as HTMLElement; micBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); expect(micBtn?.classList.contains('off')).toBe(true) })
|
||||
it('视频关闭切换', async () => { mount(); (document.querySelector('.btn.primary') as HTMLElement)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); const camBtn = document.querySelectorAll('.ft-ctl')[1] as HTMLElement; camBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); expect(camBtn?.classList.contains('off')).toBe(true) })
|
||||
it('结束通话回到空闲', async () => { vi.useFakeTimers(); mount(); (document.querySelector('.btn.primary') as HTMLElement)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); const endBtn = document.querySelector('.ft-ctl.end') as HTMLElement; endBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await nextTick(); expect(document.querySelector('.ft-status')?.textContent).toContain('通话已结束'); vi.advanceTimersByTime(1500); await nextTick(); expect(document.querySelector('.ft-grid')).toBeTruthy(); vi.useRealTimers() })
|
||||
it('卸载', () => { mount(); render(null, win.body); expect(win.body.children.length).toBe(0) })
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user