fix: css-only face avatar for mini-program, voice input + ASR
This commit is contained in:
@@ -1,14 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="digital-human">
|
<view class="digital-human">
|
||||||
<view class="avatar-stage">
|
<view class="avatar-stage">
|
||||||
<canvas
|
<view class="avatar-ring" :class="{ speaking: isSpeaking }">
|
||||||
:style="{ width: canvasSize + 'px', height: canvasSize + 'px' }"
|
<view class="avatar-css">
|
||||||
:canvas-id="canvasId"
|
<view class="face-css">
|
||||||
:id="canvasId"
|
<view class="face-skin">
|
||||||
type="2d"
|
<view class="eye eye-left" :class="{ blink: isBlinking }"></view>
|
||||||
class="face-canvas"
|
<view class="eye eye-right" :class="{ blink: isBlinking }"></view>
|
||||||
:class="{ speaking: isSpeaking }"
|
<view class="eyebrow eyebrow-left"></view>
|
||||||
></canvas>
|
<view class="eyebrow eyebrow-right"></view>
|
||||||
|
<view class="nose"></view>
|
||||||
|
<view class="mouth" :class="{ speaking: isSpeaking }">
|
||||||
|
<view class="mouth-inner" :style="mouthStyle"></view>
|
||||||
|
</view>
|
||||||
|
<view class="blush blush-left"></view>
|
||||||
|
<view class="blush blush-right"></view>
|
||||||
|
</view>
|
||||||
|
<view class="hair"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<canvas v-if="isH5" id="dh-mouth" class="mouth-canvas"></canvas>
|
||||||
|
</view>
|
||||||
<view class="status-dot" :class="{ active: isSpeaking }"></view>
|
<view class="status-dot" :class="{ active: isSpeaking }"></view>
|
||||||
<text class="role-label">AI 面试官</text>
|
<text class="role-label">AI 面试官</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -21,45 +33,41 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
text: { type: String, default: '' },
|
text: { type: String, default: '' },
|
||||||
audioUrl: { type: String, default: '' },
|
audioUrl: { type: String, default: '' },
|
||||||
|
avatarUrl: { type: String, default: '' },
|
||||||
autoPlay: { type: Boolean, default: true },
|
autoPlay: { type: Boolean, default: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['speaking-start', 'speaking-end'])
|
const emit = defineEmits(['speaking-start', 'speaking-end'])
|
||||||
|
|
||||||
const canvasSize = 280
|
const isH5 = ref(false)
|
||||||
const canvasId = 'faceCanvas'
|
|
||||||
const isSpeaking = ref(false)
|
const isSpeaking = ref(false)
|
||||||
|
const isBlinking = ref(false)
|
||||||
const currentText = ref('')
|
const currentText = ref('')
|
||||||
|
const mouthOpenRatio = ref(0)
|
||||||
|
|
||||||
let audioEl = null
|
let audioEl = null
|
||||||
let audioCtx = null
|
|
||||||
let analyser = null
|
|
||||||
let animFrameId = null
|
|
||||||
let blinkTimer = null
|
let blinkTimer = null
|
||||||
let faceCtx = null
|
let mouthAnimTimer = null
|
||||||
|
|
||||||
// Face animation state
|
const mouthStyle = computed(() => ({
|
||||||
let blinkFrame = 0 // 0=open, 1=half, 2=closed, then back
|
height: (2 + mouthOpenRatio.value * 14) + 'rpx',
|
||||||
let mouthOpen = 0 // 0-1
|
width: (12 + mouthOpenRatio.value * 8) + 'rpx',
|
||||||
let targetMouth = 0
|
}))
|
||||||
let lastBlinkTime = 0
|
|
||||||
let faceW = canvasSize
|
|
||||||
let faceH = canvasSize
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
nextTick(() => initCanvas())
|
isH5.value = typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||||
scheduleBlink()
|
scheduleBlink()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopAudio()
|
stopAudio()
|
||||||
if (blinkTimer) clearTimeout(blinkTimer)
|
if (blinkTimer) clearTimeout(blinkTimer)
|
||||||
if (animFrameId) cancelAnimationFrame(animFrameId)
|
if (mouthAnimTimer) clearInterval(mouthAnimTimer)
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.audioUrl, (url) => {
|
watch(() => props.audioUrl, (url) => {
|
||||||
@@ -72,342 +80,53 @@ watch(() => props.text, (txt) => {
|
|||||||
currentText.value = txt
|
currentText.value = txt
|
||||||
})
|
})
|
||||||
|
|
||||||
function initCanvas() {
|
|
||||||
const query = uni.createSelectorQuery()
|
|
||||||
query.select('#' + canvasId).fields({ node: true, size: true }).exec((res) => {
|
|
||||||
if (!res || !res[0]) return
|
|
||||||
const canvas = res[0].node
|
|
||||||
const ctx = canvas.getContext('2d')
|
|
||||||
const dpr = uni.getSystemInfoSync().pixelRatio
|
|
||||||
canvas.width = canvasSize * dpr
|
|
||||||
canvas.height = canvasSize * dpr
|
|
||||||
ctx.scale(dpr, dpr)
|
|
||||||
faceCtx = ctx
|
|
||||||
faceW = canvasSize
|
|
||||||
faceH = canvasSize
|
|
||||||
drawFace(ctx, 0, false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawFace(ctx, mouthRatio, isBlinking) {
|
|
||||||
if (!ctx) return
|
|
||||||
const w = faceW
|
|
||||||
const h = faceH
|
|
||||||
const cx = w / 2
|
|
||||||
const cy = h / 2
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h)
|
|
||||||
|
|
||||||
// Background
|
|
||||||
ctx.fillStyle = '#F0F2F5'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(cx, cy, w / 2, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Hair - brown wavy hair
|
|
||||||
ctx.fillStyle = '#3D2B1F'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(cx, cy - h * 0.28, w * 0.36, h * 0.22, 0, Math.PI, 0)
|
|
||||||
ctx.fill()
|
|
||||||
// Hair sides
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(cx - w * 0.24, cy - h * 0.08, w * 0.08, h * 0.22, -0.2, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(cx + w * 0.24, cy - h * 0.08, w * 0.08, h * 0.22, 0.2, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Face oval
|
|
||||||
const faceW_ = w * 0.32
|
|
||||||
const faceH_ = h * 0.38
|
|
||||||
ctx.fillStyle = '#FDE8D0'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(cx, cy + h * 0.02, faceW_, faceH_, 0, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Neck
|
|
||||||
ctx.fillStyle = '#FDE8D0'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(cx, cy + h * 0.32, w * 0.14, h * 0.10, 0, 0, Math.PI)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Collar / shoulders suggestion
|
|
||||||
ctx.fillStyle = '#4B5563'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(cx, cy + h * 0.42, w * 0.28, h * 0.08, 0, Math.PI, 0)
|
|
||||||
ctx.fill()
|
|
||||||
ctx.fillStyle = '#374151'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(cx, cy + h * 0.44, w * 0.30, h * 0.06, 0, Math.PI, 0)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Cheek blush
|
|
||||||
ctx.fillStyle = 'rgba(255, 150, 150, 0.3)'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(cx - w * 0.18, cy + h * 0.08, w * 0.07, h * 0.04, 0, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(cx + w * 0.18, cy + h * 0.08, w * 0.07, h * 0.04, 0, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Nose
|
|
||||||
ctx.strokeStyle = '#E8C8A8'
|
|
||||||
ctx.lineWidth = 2
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(cx, cy - h * 0.02)
|
|
||||||
ctx.lineTo(cx - w * 0.03, cy + h * 0.06)
|
|
||||||
ctx.stroke()
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(cx, cy - h * 0.02)
|
|
||||||
ctx.lineTo(cx + w * 0.03, cy + h * 0.06)
|
|
||||||
ctx.stroke()
|
|
||||||
// Nose tip
|
|
||||||
ctx.fillStyle = '#E8C8A8'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(cx, cy + h * 0.06, w * 0.025, h * 0.015, 0, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Eyes
|
|
||||||
const eyeY = cy - h * 0.02
|
|
||||||
const eyeSpacing = w * 0.13
|
|
||||||
|
|
||||||
function drawEye(x, y, blinkRatio) {
|
|
||||||
// Eye white
|
|
||||||
const eyeW = w * 0.09
|
|
||||||
const eyeH = h * 0.05
|
|
||||||
const openH = eyeH * (1 - blinkRatio)
|
|
||||||
|
|
||||||
ctx.fillStyle = '#FFFFFF'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(x, y, eyeW, Math.max(openH, 1), 0, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
if (blinkRatio < 0.8) {
|
|
||||||
// Iris
|
|
||||||
const irisR = eyeW * 0.55
|
|
||||||
ctx.fillStyle = '#5B4033'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(x, y, irisR, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Pupil
|
|
||||||
ctx.fillStyle = '#1A1A1A'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(x, y, irisR * 0.5, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Eye highlight
|
|
||||||
ctx.fillStyle = '#FFFFFF'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(x - irisR * 0.3, y - irisR * 0.3, irisR * 0.3, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.arc(x + irisR * 0.15, y + irisR * 0.15, irisR * 0.15, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Upper eyelid line
|
|
||||||
ctx.strokeStyle = '#4A3728'
|
|
||||||
ctx.lineWidth = 1.5
|
|
||||||
ctx.beginPath()
|
|
||||||
const lidH = eyeH * 0.5 * (1 - blinkRatio)
|
|
||||||
ctx.ellipse(x, y - lidH * 0.3, eyeW * 0.85, lidH * 0.8, 0, Math.PI, 0)
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eyelid (covers eye when blinking)
|
|
||||||
if (blinkRatio > 0) {
|
|
||||||
ctx.fillStyle = '#FDE8D0'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(x, y - eyeH * 0.3, eyeW * 1.1, eyeH * 0.7 * blinkRatio + 1, 0, Math.PI, 0)
|
|
||||||
ctx.fill()
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(x, y + eyeH * 0.3, eyeW * 1.1, eyeH * 0.7 * blinkRatio + 1, 0, 0, Math.PI)
|
|
||||||
ctx.fill()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Eyelash line
|
|
||||||
ctx.strokeStyle = '#3D2B1F'
|
|
||||||
ctx.lineWidth = 1.2
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(x, y, eyeW * 0.9, Math.max(openH * 0.7, 1), 0, 0, Math.PI * 2)
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
|
|
||||||
const blinkRatio = isBlinking ? 0.95 : 0
|
|
||||||
drawEye(cx - eyeSpacing, eyeY, blinkRatio)
|
|
||||||
drawEye(cx + eyeSpacing, eyeY, blinkRatio)
|
|
||||||
|
|
||||||
// Eyebrows
|
|
||||||
function drawEyebrow(x, y) {
|
|
||||||
ctx.strokeStyle = '#3D2B1F'
|
|
||||||
ctx.lineWidth = 3
|
|
||||||
ctx.lineCap = 'round'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(x - w * 0.06, y - h * 0.06)
|
|
||||||
ctx.quadraticCurveTo(x, y - h * 0.09, x + w * 0.06, y - h * 0.06)
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
drawEyebrow(cx - eyeSpacing, eyeY)
|
|
||||||
drawEyebrow(cx + eyeSpacing, eyeY)
|
|
||||||
|
|
||||||
// Mouth
|
|
||||||
const mouthX = cx
|
|
||||||
const mouthY = cy + h * 0.16
|
|
||||||
const mouthBaseW = w * 0.10
|
|
||||||
const mouthBaseH = h * 0.02
|
|
||||||
const openAmount = mouthRatio * h * 0.06
|
|
||||||
|
|
||||||
ctx.fillStyle = '#C97B84'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(mouthX, mouthY + openAmount * 0.5, mouthBaseW + mouthRatio * w * 0.02, mouthBaseH + openAmount, 0, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
|
|
||||||
// Mouth line (when closed)
|
|
||||||
if (mouthRatio < 0.1) {
|
|
||||||
ctx.strokeStyle = '#B06570'
|
|
||||||
ctx.lineWidth = 1.5
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(mouthX - mouthBaseW, mouthY)
|
|
||||||
ctx.quadraticCurveTo(mouthX, mouthY + 2, mouthX + mouthBaseW, mouthY)
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inner mouth when open
|
|
||||||
if (mouthRatio > 0.15) {
|
|
||||||
ctx.fillStyle = '#5C2E36'
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.ellipse(mouthX, mouthY + openAmount * 0.6, mouthBaseW * 0.7, openAmount * 0.7, 0, 0, Math.PI * 2)
|
|
||||||
ctx.fill()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtle smile lines
|
|
||||||
ctx.strokeStyle = '#E8C8A8'
|
|
||||||
ctx.lineWidth = 1
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(mouthX - mouthBaseW - w * 0.02, mouthY - h * 0.01)
|
|
||||||
ctx.quadraticCurveTo(mouthX - mouthBaseW - w * 0.01, mouthY - h * 0.005, mouthX - mouthBaseW, mouthY)
|
|
||||||
ctx.stroke()
|
|
||||||
ctx.beginPath()
|
|
||||||
ctx.moveTo(mouthX + mouthBaseW + w * 0.02, mouthY - h * 0.01)
|
|
||||||
ctx.quadraticCurveTo(mouthX + mouthBaseW + w * 0.01, mouthY - h * 0.005, mouthX + mouthBaseW, mouthY)
|
|
||||||
ctx.stroke()
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleBlink() {
|
function scheduleBlink() {
|
||||||
const delay = 2000 + Math.random() * 3000
|
const delay = 2000 + Math.random() * 3000
|
||||||
blinkTimer = setTimeout(() => {
|
blinkTimer = setTimeout(() => {
|
||||||
doBlink()
|
isBlinking.value = true
|
||||||
|
setTimeout(() => { isBlinking.value = false }, 150)
|
||||||
scheduleBlink()
|
scheduleBlink()
|
||||||
}, delay)
|
}, delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
function doBlink() {
|
function playAudio(url) {
|
||||||
if (!faceCtx) return
|
|
||||||
let frame = 0
|
|
||||||
const totalFrames = 8
|
|
||||||
|
|
||||||
function blinkTick() {
|
|
||||||
frame++
|
|
||||||
const progress = frame / totalFrames
|
|
||||||
const blinkAmt = progress < 0.5 ? progress * 2 : (1 - progress) * 2
|
|
||||||
drawFace(faceCtx, mouthOpen, true)
|
|
||||||
if (frame < totalFrames) {
|
|
||||||
setTimeout(blinkTick, 30)
|
|
||||||
} else {
|
|
||||||
drawFace(faceCtx, mouthOpen, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
blinkTick()
|
|
||||||
}
|
|
||||||
|
|
||||||
function animateLoop() {
|
|
||||||
if (!faceCtx) return
|
|
||||||
|
|
||||||
// Smooth mouth movement
|
|
||||||
mouthOpen += (targetMouth - mouthOpen) * 0.15
|
|
||||||
if (Math.abs(mouthOpen - targetMouth) < 0.01) mouthOpen = targetMouth
|
|
||||||
|
|
||||||
drawFace(faceCtx, mouthOpen, false)
|
|
||||||
|
|
||||||
animFrameId = setTimeout(() => animateLoop(), 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function playAudio(url) {
|
|
||||||
stopAudio()
|
stopAudio()
|
||||||
isSpeaking.value = true
|
isSpeaking.value = true
|
||||||
emit('speaking-start')
|
emit('speaking-start')
|
||||||
|
startMouthAnim()
|
||||||
// Start mouth animation
|
|
||||||
targetMouth = 0.4
|
|
||||||
animateLoop()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use uni-app audio API
|
|
||||||
const innerAudio = uni.createInnerAudioContext()
|
const innerAudio = uni.createInnerAudioContext()
|
||||||
audioEl = innerAudio
|
audioEl = innerAudio
|
||||||
innerAudio.src = url
|
innerAudio.src = url
|
||||||
innerAudio.autoplay = true
|
innerAudio.autoplay = true
|
||||||
|
innerAudio.onEnded(() => finishSpeaking())
|
||||||
innerAudio.onPlay(() => {
|
innerAudio.onError(() => finishSpeaking())
|
||||||
// Start audio-driven mouth animation
|
innerAudio.onStop(() => finishSpeaking())
|
||||||
startAudioMouth(innerAudio)
|
|
||||||
})
|
|
||||||
|
|
||||||
innerAudio.onError(() => {
|
|
||||||
finishSpeaking()
|
|
||||||
})
|
|
||||||
|
|
||||||
innerAudio.onEnded(() => {
|
|
||||||
finishSpeaking()
|
|
||||||
})
|
|
||||||
|
|
||||||
innerAudio.onStop(() => {
|
|
||||||
finishSpeaking()
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
finishSpeaking()
|
finishSpeaking()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startAudioMouth(audio) {
|
function startMouthAnim() {
|
||||||
let lastTime = 0
|
let direction = 1
|
||||||
|
mouthAnimTimer = setInterval(() => {
|
||||||
function tick() {
|
const step = mouthOpenRatio.value + direction * 0.08
|
||||||
if (!isSpeaking.value) return
|
if (step >= 0.8) direction = -1
|
||||||
try {
|
else if (step <= 0.1) direction = 1
|
||||||
const currentTime = audio.currentTime
|
mouthOpenRatio.value = Math.max(0.05, Math.min(1, step))
|
||||||
if (currentTime > lastTime) {
|
}, 80)
|
||||||
lastTime = currentTime
|
|
||||||
// Random-ish mouth movement based on time for natural feel
|
|
||||||
const t = currentTime * 8
|
|
||||||
const amp = 0.2 + Math.abs(Math.sin(t)) * 0.3 + Math.abs(Math.sin(t * 1.7)) * 0.2
|
|
||||||
targetMouth = Math.min(0.8, amp)
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (isSpeaking.value) {
|
|
||||||
setTimeout(tick, 80)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tick()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishSpeaking() {
|
function finishSpeaking() {
|
||||||
isSpeaking.value = false
|
isSpeaking.value = false
|
||||||
targetMouth = 0
|
mouthOpenRatio.value = 0
|
||||||
setTimeout(() => {
|
if (mouthAnimTimer) { clearInterval(mouthAnimTimer); mouthAnimTimer = null }
|
||||||
if (faceCtx && !isSpeaking.value) {
|
|
||||||
mouthOpen = 0
|
|
||||||
drawFace(faceCtx, 0, false)
|
|
||||||
}
|
|
||||||
}, 300)
|
|
||||||
cleanupAudio()
|
cleanupAudio()
|
||||||
emit('speaking-end')
|
emit('speaking-end')
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupAudio() {
|
function cleanupAudio() {
|
||||||
if (animFrameId) { clearTimeout(animFrameId); animFrameId = null }
|
|
||||||
if (audioEl) {
|
if (audioEl) {
|
||||||
try { audioEl.stop(); audioEl.destroy() } catch {}
|
try { audioEl.stop(); audioEl.destroy() } catch {}
|
||||||
audioEl = null
|
audioEl = null
|
||||||
@@ -415,9 +134,7 @@ function cleanupAudio() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopAudio() {
|
function stopAudio() {
|
||||||
if (audioEl) {
|
if (audioEl) { try { audioEl.stop(); audioEl.destroy() } catch {} }
|
||||||
try { audioEl.stop(); audioEl.destroy() } catch {}
|
|
||||||
}
|
|
||||||
finishSpeaking()
|
finishSpeaking()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +146,7 @@ defineExpose({ play: playAudio, stop: stopAudio })
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16rpx 0 8rpx;
|
padding: 10rpx 0;
|
||||||
}
|
}
|
||||||
.avatar-stage {
|
.avatar-stage {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -438,21 +155,174 @@ defineExpose({ play: playAudio, stop: stopAudio })
|
|||||||
gap: 10rpx;
|
gap: 10rpx;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.face-canvas {
|
.avatar-ring {
|
||||||
|
width: 220rpx;
|
||||||
|
height: 220rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transition: box-shadow 0.3s;
|
border: 4rpx solid #E5E7EB;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: border-color 0.3s, box-shadow 0.3s;
|
||||||
}
|
}
|
||||||
.face-canvas.speaking {
|
.avatar-ring.speaking {
|
||||||
|
border-color: #6366F1;
|
||||||
box-shadow: 0 0 40rpx rgba(99, 102, 241, 0.4);
|
box-shadow: 0 0 40rpx rgba(99, 102, 241, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CSS Face */
|
||||||
|
.face-css {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hair {
|
||||||
|
position: absolute;
|
||||||
|
top: -8%;
|
||||||
|
left: -6%;
|
||||||
|
width: 112%;
|
||||||
|
height: 48%;
|
||||||
|
background: radial-gradient(ellipse at 50% 100%, #3D2B1F 60%, transparent 72%);
|
||||||
|
z-index: 2;
|
||||||
|
border-radius: 50% 50% 0 0;
|
||||||
|
}
|
||||||
|
.face-skin {
|
||||||
|
position: absolute;
|
||||||
|
inset: 6%;
|
||||||
|
background: radial-gradient(ellipse at 50% 40%, #FDE8D0, #F5D5B0);
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eyes */
|
||||||
|
.eye {
|
||||||
|
position: absolute;
|
||||||
|
width: 20%;
|
||||||
|
height: 18%;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 38%;
|
||||||
|
z-index: 3;
|
||||||
|
border: 2rpx solid #8B7355;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.eye::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 55%;
|
||||||
|
height: 55%;
|
||||||
|
background: radial-gradient(circle, #5B4033 30%, #1A1A1A 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 22%;
|
||||||
|
left: 22%;
|
||||||
|
}
|
||||||
|
.eye::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20%;
|
||||||
|
height: 20%;
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 18%;
|
||||||
|
left: 28%;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
.eye-left { left: 18%; }
|
||||||
|
.eye-right { right: 18%; }
|
||||||
|
.eye.blink::after { display: none; }
|
||||||
|
.eye.blink::before { display: none; }
|
||||||
|
.eye.blink {
|
||||||
|
background: #F5D5B0;
|
||||||
|
border-color: #F5D5B0;
|
||||||
|
height: 3%;
|
||||||
|
top: 44%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Eyebrows */
|
||||||
|
.eyebrow {
|
||||||
|
position: absolute;
|
||||||
|
width: 22%;
|
||||||
|
height: 4rpx;
|
||||||
|
background: #3D2B1F;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
top: 32%;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.eyebrow-left { left: 17%; transform: rotate(-6deg); }
|
||||||
|
.eyebrow-right { right: 17%; transform: rotate(6deg); }
|
||||||
|
|
||||||
|
/* Nose */
|
||||||
|
.nose {
|
||||||
|
position: absolute;
|
||||||
|
width: 8%;
|
||||||
|
height: 12%;
|
||||||
|
top: 46%;
|
||||||
|
left: 46%;
|
||||||
|
z-index: 3;
|
||||||
|
border-left: 2rpx solid #E8C8A8;
|
||||||
|
border-right: 2rpx solid #E8C8A8;
|
||||||
|
border-radius: 0 0 50% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mouth */
|
||||||
|
.mouth {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 18%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 28%;
|
||||||
|
height: 12%;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.mouth-inner {
|
||||||
|
background: #C97B84;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.08s ease;
|
||||||
|
}
|
||||||
|
.mouth.speaking .mouth-inner {
|
||||||
|
background: #B06570;
|
||||||
|
box-shadow: inset 0 0 6rpx rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blush */
|
||||||
|
.blush {
|
||||||
|
position: absolute;
|
||||||
|
width: 16%;
|
||||||
|
height: 10%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 150, 150, 0.35);
|
||||||
|
top: 54%;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.blush-left { left: 8%; }
|
||||||
|
.blush-right { right: 8%; }
|
||||||
|
|
||||||
|
/* Neck & body suggestion */
|
||||||
|
.avatar-ring::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 30%;
|
||||||
|
height: 12%;
|
||||||
|
background: #F5D5B0;
|
||||||
|
border-radius: 0 0 50% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 14rpx;
|
width: 14rpx;
|
||||||
height: 14rpx;
|
height: 14rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #9CA3AF;
|
background: #9CA3AF;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12rpx;
|
top: 8rpx;
|
||||||
right: 12rpx;
|
right: 8rpx;
|
||||||
transition: background 0.3s;
|
transition: background 0.3s;
|
||||||
}
|
}
|
||||||
.status-dot.active {
|
.status-dot.active {
|
||||||
@@ -466,8 +336,9 @@ defineExpose({ play: playAudio, stop: stopAudio })
|
|||||||
padding: 4rpx 20rpx;
|
padding: 4rpx 20rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
}
|
}
|
||||||
|
.mouth-canvas { display: none; }
|
||||||
.speech-area {
|
.speech-area {
|
||||||
margin-top: 16rpx;
|
margin-top: 12rpx;
|
||||||
padding: 0 40rpx;
|
padding: 0 40rpx;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "宇之然AI磁场",
|
"name": "宇之然AI磁场",
|
||||||
"appid": "__UNI__DEV__",
|
"appid": "__UNI__DEV__",
|
||||||
"versionName": "1.0.7",
|
"versionName": "1.0.8",
|
||||||
"versionCode": "107",
|
"versionCode": "108",
|
||||||
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
|
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
|
||||||
"h5": {
|
"h5": {
|
||||||
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
|
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
|
||||||
|
|||||||
Reference in New Issue
Block a user