fix: WeChat login Content-Type header, ASR tiny model, re-upload mini-program v1.0.11
@@ -107,7 +107,7 @@ ${conversationHistory}
|
|||||||
if (aiMsg?.content) {
|
if (aiMsg?.content) {
|
||||||
try {
|
try {
|
||||||
const tts = await this.ttsService.synthesize(aiMsg.content)
|
const tts = await this.ttsService.synthesize(aiMsg.content)
|
||||||
return { ...base, ttsHash: tts.hash, ttsDurationMs: tts.durationMs }
|
return { ...base, ttsHash: tts.hash, ttsDurationMs: tts.durationMs, ttsAmplitude: tts.amplitudeData }
|
||||||
} catch {
|
} catch {
|
||||||
// TTS failure is non-critical, return without audio
|
// TTS failure is non-critical, return without audio
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ export class WechatPayService {
|
|||||||
this.logger.warn('微信支付配置不完整,支付功能不可用')
|
this.logger.warn('微信支付配置不完整,支付功能不可用')
|
||||||
}
|
}
|
||||||
const certDir = path.resolve(__dirname, '../../certs')
|
const certDir = path.resolve(__dirname, '../../certs')
|
||||||
|
if (!fs.existsSync(certDir)) {
|
||||||
|
this.logger.error(`证书目录不存在: ${certDir}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
this.privateKey = fs.readFileSync(path.join(certDir, 'apiclient_key.pem'), 'utf8')
|
this.privateKey = fs.readFileSync(path.join(certDir, 'apiclient_key.pem'), 'utf8')
|
||||||
// 从证书中提取序列号
|
|
||||||
const cert = fs.readFileSync(path.join(certDir, 'apiclient_cert.pem'), 'utf8')
|
const cert = fs.readFileSync(path.join(certDir, 'apiclient_cert.pem'), 'utf8')
|
||||||
const certObj = new crypto.X509Certificate(cert)
|
const certObj = new crypto.X509Certificate(cert)
|
||||||
this.mchSerialNo = certObj.serialNumber
|
this.mchSerialNo = certObj.serialNumber
|
||||||
@@ -122,6 +125,10 @@ export class WechatPayService {
|
|||||||
// 1. 验签
|
// 1. 验签
|
||||||
const message = `${wechatTimestamp}\n${wechatNonce}\n${JSON.stringify(body)}\n`
|
const message = `${wechatTimestamp}\n${wechatNonce}\n${JSON.stringify(body)}\n`
|
||||||
const certDir = path.resolve(__dirname, '../../certs')
|
const certDir = path.resolve(__dirname, '../../certs')
|
||||||
|
if (!fs.existsSync(certDir)) {
|
||||||
|
this.logger.error(`证书目录不存在: ${certDir}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
const platformCert = fs.readFileSync(path.join(certDir, 'pub_key.pem'), 'utf8')
|
const platformCert = fs.readFileSync(path.join(certDir, 'pub_key.pem'), 'utf8')
|
||||||
const verify = crypto.createVerify('RSA-SHA256').update(message)
|
const verify = crypto.createVerify('RSA-SHA256').update(message)
|
||||||
const isValid = verify.verify(platformCert, wechatSignature, 'base64')
|
const isValid = verify.verify(platformCert, wechatSignature, 'base64')
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export class TtsController {
|
|||||||
throw new HttpException('文本不能为空且不超过500字', HttpStatus.BAD_REQUEST)
|
throw new HttpException('文本不能为空且不超过500字', HttpStatus.BAD_REQUEST)
|
||||||
}
|
}
|
||||||
const result = await this.ttsService.synthesize(text, voice)
|
const result = await this.ttsService.synthesize(text, voice)
|
||||||
return { hash: result.hash, durationMs: result.durationMs }
|
return { hash: result.hash, durationMs: result.durationMs, amplitudeData: result.amplitudeData }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@@ -59,8 +59,7 @@ export class TtsController {
|
|||||||
const parsed = JSON.parse(result)
|
const parsed = JSON.parse(result)
|
||||||
if (parsed.text) return { text: parsed.text.trim() }
|
if (parsed.text) return { text: parsed.text.trim() }
|
||||||
}
|
}
|
||||||
const whisperBin = '/root/.local/bin/whisper'
|
const whisperResult = execSync(`python3 -c 'import sys, whisper; model = whisper.load_model("tiny"); print(model.transcribe(sys.argv[1], language="zh")["text"].strip())' "${dest}"`, { encoding: 'utf8', timeout: 60000 })
|
||||||
const whisperResult = execSync(`${whisperBin} "${dest}" --language zh --output_format txt 2>/dev/null`, { encoding: 'utf8', timeout: 60000 })
|
|
||||||
if (whisperResult && whisperResult.trim()) {
|
if (whisperResult && whisperResult.trim()) {
|
||||||
return { text: whisperResult.trim() }
|
return { text: whisperResult.trim() }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface TtsResult {
|
|||||||
hash: string
|
hash: string
|
||||||
filePath: string
|
filePath: string
|
||||||
durationMs: number
|
durationMs: number
|
||||||
|
amplitudeData: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_VOICES = new Set([
|
const VALID_VOICES = new Set([
|
||||||
@@ -41,7 +42,10 @@ export class TtsService {
|
|||||||
|
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
const durationMs = await this.getDuration(filePath)
|
const durationMs = await this.getDuration(filePath)
|
||||||
return { hash, filePath, durationMs }
|
const amplitudeData = this.loadAmplitudeData(hash)
|
||||||
|
if (amplitudeData) {
|
||||||
|
return { hash, filePath, durationMs, amplitudeData }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -50,8 +54,9 @@ export class TtsService {
|
|||||||
{ timeout: 30000 },
|
{ timeout: 30000 },
|
||||||
)
|
)
|
||||||
const durationMs = await this.getDuration(filePath)
|
const durationMs = await this.getDuration(filePath)
|
||||||
|
const amplitudeData = this.extractAmplitude(filePath, hash)
|
||||||
this.logger.log(`TTS generated: hash=${hash} text="${text.slice(0, 40)}..." duration=${durationMs}ms`)
|
this.logger.log(`TTS generated: hash=${hash} text="${text.slice(0, 40)}..." duration=${durationMs}ms`)
|
||||||
return { hash, filePath, durationMs }
|
return { hash, filePath, durationMs, amplitudeData }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(`TTS failed: ${e.message}`)
|
this.logger.error(`TTS failed: ${e.message}`)
|
||||||
throw e
|
throw e
|
||||||
@@ -63,6 +68,46 @@ export class TtsService {
|
|||||||
return fs.existsSync(filePath) ? filePath : null
|
return fs.existsSync(filePath) ? filePath : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractAmplitude(mp3Path: string, hash: string): number[] {
|
||||||
|
try {
|
||||||
|
const pcmPath = `/tmp/tts-cache/${hash}.pcm`
|
||||||
|
execSync(
|
||||||
|
`ffmpeg -y -i "${mp3Path}" -f s16le -acodec pcm_s16le -ar 16000 -ac 1 "${pcmPath}" 2>/dev/null`,
|
||||||
|
{ timeout: 10000 },
|
||||||
|
)
|
||||||
|
const pcmBuf = fs.readFileSync(pcmPath)
|
||||||
|
const samples = new Int16Array(pcmBuf.buffer, pcmBuf.byteOffset, pcmBuf.byteLength / 2)
|
||||||
|
const chunkSize = Math.floor(16000 * 0.05) // 50ms
|
||||||
|
const amplitudes: number[] = []
|
||||||
|
for (let i = 0; i < samples.length; i += chunkSize) {
|
||||||
|
const end = Math.min(i + chunkSize, samples.length)
|
||||||
|
let sumSq = 0
|
||||||
|
for (let j = i; j < end; j++) {
|
||||||
|
sumSq += samples[j] * samples[j]
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sumSq / (end - i))
|
||||||
|
amplitudes.push(Number((Math.min(1, rms / 16000)).toFixed(4)))
|
||||||
|
}
|
||||||
|
try { fs.unlinkSync(pcmPath) } catch {}
|
||||||
|
const ampPath = `/tmp/tts-cache/${hash}.amp`
|
||||||
|
fs.writeFileSync(ampPath, JSON.stringify(amplitudes))
|
||||||
|
return amplitudes
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`振幅提取失败: ${e.message}`)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadAmplitudeData(hash: string): number[] | null {
|
||||||
|
try {
|
||||||
|
const ampPath = `/tmp/tts-cache/${hash}.amp`
|
||||||
|
if (!fs.existsSync(ampPath)) return null
|
||||||
|
return JSON.parse(fs.readFileSync(ampPath, 'utf8'))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private escapeText(text: string): string {
|
private escapeText(text: string): string {
|
||||||
return text.replace(/"/g, '\\"').replace(/\n/g, ' ').replace(/\r/g, '')
|
return text.replace(/"/g, '\\"').replace(/\n/g, ' ').replace(/\r/g, '')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||||
"@vue/test-utils": "^2.4.11",
|
"@vue/test-utils": "^2.4.11",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"miniprogram-ci": "^2.1.31",
|
"miniprogram-ci": "^2.1.42",
|
||||||
"sass": "^1.70.0",
|
"sass": "^1.70.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.2.0",
|
"vite": "^5.2.0",
|
||||||
@@ -5674,15 +5674,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@swc/core": {
|
"node_modules/@swc/core": {
|
||||||
"version": "1.4.14",
|
"version": "1.15.33",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core/-/core-1.4.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core/-/core-1.15.33.tgz",
|
||||||
"integrity": "sha512-tHXg6OxboUsqa/L7DpsCcFnxhLkqN/ht5pCwav1HnvfthbiNIJypr86rNx4cUnQDJepETviSqBTIjxa7pSpGDQ==",
|
"integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/counter": "^0.1.2",
|
"@swc/counter": "^0.1.3",
|
||||||
"@swc/types": "^0.1.5"
|
"@swc/types": "^0.1.26"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -5692,19 +5692,21 @@
|
|||||||
"url": "https://opencollective.com/swc"
|
"url": "https://opencollective.com/swc"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@swc/core-darwin-arm64": "1.4.14",
|
"@swc/core-darwin-arm64": "1.15.33",
|
||||||
"@swc/core-darwin-x64": "1.4.14",
|
"@swc/core-darwin-x64": "1.15.33",
|
||||||
"@swc/core-linux-arm-gnueabihf": "1.4.14",
|
"@swc/core-linux-arm-gnueabihf": "1.15.33",
|
||||||
"@swc/core-linux-arm64-gnu": "1.4.14",
|
"@swc/core-linux-arm64-gnu": "1.15.33",
|
||||||
"@swc/core-linux-arm64-musl": "1.4.14",
|
"@swc/core-linux-arm64-musl": "1.15.33",
|
||||||
"@swc/core-linux-x64-gnu": "1.4.14",
|
"@swc/core-linux-ppc64-gnu": "1.15.33",
|
||||||
"@swc/core-linux-x64-musl": "1.4.14",
|
"@swc/core-linux-s390x-gnu": "1.15.33",
|
||||||
"@swc/core-win32-arm64-msvc": "1.4.14",
|
"@swc/core-linux-x64-gnu": "1.15.33",
|
||||||
"@swc/core-win32-ia32-msvc": "1.4.14",
|
"@swc/core-linux-x64-musl": "1.15.33",
|
||||||
"@swc/core-win32-x64-msvc": "1.4.14"
|
"@swc/core-win32-arm64-msvc": "1.15.33",
|
||||||
|
"@swc/core-win32-ia32-msvc": "1.15.33",
|
||||||
|
"@swc/core-win32-x64-msvc": "1.15.33"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": ">=0.5.17"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@swc/helpers": {
|
"@swc/helpers": {
|
||||||
@@ -5713,9 +5715,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swc/core-darwin-arm64": {
|
"node_modules/@swc/core-darwin-arm64": {
|
||||||
"version": "1.4.14",
|
"version": "1.15.33",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.4.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz",
|
||||||
"integrity": "sha512-8iPfLhYNspBl836YYsfv6ErXwDUqJ7IMieddV3Ey/t/97JAEAdNDUdtTKDtbyP0j/Ebyqyn+fKcqwSq7rAof0g==",
|
"integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -5730,9 +5732,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swc/core-darwin-x64": {
|
"node_modules/@swc/core-darwin-x64": {
|
||||||
"version": "1.4.14",
|
"version": "1.15.33",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.4.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz",
|
||||||
"integrity": "sha512-9CqSj8uRZ92cnlgAlVaWMaJJBdxtNvCzJxaGj5KuIseeG6Q0l1g+qk8JcU7h9dAsH9saHTNwNFBVGKQo0W0ujg==",
|
"integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -5747,9 +5749,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||||
"version": "1.4.14",
|
"version": "1.15.33",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.4.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz",
|
||||||
"integrity": "sha512-mfd5JArPITTzMjcezH4DwMw+BdjBV1y25Khp8itEIpdih9ei+fvxOOrDYTN08b466NuE2dF2XuhKtRLA7fXArQ==",
|
"integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -5764,9 +5766,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||||
"version": "1.4.14",
|
"version": "1.15.33",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.4.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz",
|
||||||
"integrity": "sha512-3Lqlhlmy8MVRS9xTShMaPAp0oyUt0KFhDs4ixJsjdxKecE0NJSV/MInuDmrkij1C8/RQ2wySRlV9np5jK86oWw==",
|
"integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -5781,9 +5783,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swc/core-linux-arm64-musl": {
|
"node_modules/@swc/core-linux-arm64-musl": {
|
||||||
"version": "1.4.14",
|
"version": "1.15.33",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.4.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz",
|
||||||
"integrity": "sha512-n0YoCa64TUcJrbcXIHIHDWQjdUPdaXeMHNEu7yyBtOpm01oMGTKP3frsUXIABLBmAVWtKvqit4/W1KVKn5gJzg==",
|
"integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -5797,10 +5799,44 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/core-linux-ppc64-gnu": {
|
||||||
|
"version": "1.15.33",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz",
|
||||||
|
"integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-s390x-gnu": {
|
||||||
|
"version": "1.15.33",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz",
|
||||||
|
"integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/core-linux-x64-gnu": {
|
"node_modules/@swc/core-linux-x64-gnu": {
|
||||||
"version": "1.4.14",
|
"version": "1.15.33",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.4.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz",
|
||||||
"integrity": "sha512-CGmlwLWbfG1dB4jZBJnp2IWlK5xBMNLjN7AR5kKA3sEpionoccEnChOEvfux1UdVJQjLRKuHNV9yGyqGBTpxfQ==",
|
"integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -5815,9 +5851,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swc/core-linux-x64-musl": {
|
"node_modules/@swc/core-linux-x64-musl": {
|
||||||
"version": "1.4.14",
|
"version": "1.15.33",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.4.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz",
|
||||||
"integrity": "sha512-xq4npk8YKYmNwmr8fbvF2KP3kUVdZYfXZMQnW425gP3/sn+yFQO8Nd0bGH40vOVQn41kEesSe0Z5O/JDor2TgQ==",
|
"integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -5832,9 +5868,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swc/core-win32-arm64-msvc": {
|
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||||
"version": "1.4.14",
|
"version": "1.15.33",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.4.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz",
|
||||||
"integrity": "sha512-imq0X+gU9uUe6FqzOQot5gpKoaC00aCUiN58NOzwp0QXEupn8CDuZpdBN93HiZswfLruu5jA1tsc15x6v9p0Yg==",
|
"integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -5849,9 +5885,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swc/core-win32-ia32-msvc": {
|
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||||
"version": "1.4.14",
|
"version": "1.15.33",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.4.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz",
|
||||||
"integrity": "sha512-cH6QpXMw5D3t+lpx6SkErHrxN0yFzmQ0lgNAJxoDRiaAdDbqA6Col8UqUJwUS++Ul6aCWgNhCdiEYehPaoyDPA==",
|
"integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -5866,9 +5902,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@swc/core-win32-x64-msvc": {
|
"node_modules/@swc/core-win32-x64-msvc": {
|
||||||
"version": "1.4.14",
|
"version": "1.15.33",
|
||||||
"resolved": "https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.4.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz",
|
||||||
"integrity": "sha512-FmZ4Tby4wW65K/36BKzmuu7mlq7cW5XOxzvufaSNVvQ5PN4OodAlqPjToe029oma4Av+ykJiif64scMttyNAzg==",
|
"integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -15461,9 +15497,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/miniprogram-ci": {
|
"node_modules/miniprogram-ci": {
|
||||||
"version": "2.1.31",
|
"version": "2.1.42",
|
||||||
"resolved": "https://registry.npmmirror.com/miniprogram-ci/-/miniprogram-ci-2.1.31.tgz",
|
"resolved": "https://registry.npmmirror.com/miniprogram-ci/-/miniprogram-ci-2.1.42.tgz",
|
||||||
"integrity": "sha512-SREx6UnJC74aQ2a1YMNShqQOB97nHO+ll6ZQrCQp98NHXcRq848VjZoD5ELpd95z+8uTASQUAcFtl/HrXuM7Nw==",
|
"integrity": "sha512-H/bq0Wo6kMbwzcOM4jiYSMuQUkND50+zeyY8EkJuY1TQYijkLbFmNK/CFsFJsJgVcvXZ94wn8Do50YSO/Rv6/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -15589,7 +15625,7 @@
|
|||||||
"@babel/template": "7.20.7",
|
"@babel/template": "7.20.7",
|
||||||
"@babel/traverse": "7.21.4",
|
"@babel/traverse": "7.21.4",
|
||||||
"@babel/types": "7.24.6",
|
"@babel/types": "7.24.6",
|
||||||
"@swc/core": "1.4.14",
|
"@swc/core": "1.15.33",
|
||||||
"@vue/reactivity": "3.0.5",
|
"@vue/reactivity": "3.0.5",
|
||||||
"acorn": "^6.1.1",
|
"acorn": "^6.1.1",
|
||||||
"adm-zip": "0.5.10",
|
"adm-zip": "0.5.10",
|
||||||
@@ -15642,7 +15678,7 @@
|
|||||||
"string-hash-64": "1.0.3",
|
"string-hash-64": "1.0.3",
|
||||||
"sync-message": "0.0.12",
|
"sync-message": "0.0.12",
|
||||||
"terminal-kit": "^2.4.0",
|
"terminal-kit": "^2.4.0",
|
||||||
"terser": "4.8.0",
|
"terser": "5.27.1",
|
||||||
"tmp": "0.0.28",
|
"tmp": "0.0.28",
|
||||||
"tslib": "^2.4.0",
|
"tslib": "^2.4.0",
|
||||||
"uglify-js": "3.0.27",
|
"uglify-js": "3.0.27",
|
||||||
@@ -18152,21 +18188,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/miniprogram-ci/node_modules/terser": {
|
"node_modules/miniprogram-ci/node_modules/terser": {
|
||||||
"version": "4.8.0",
|
"version": "5.27.1",
|
||||||
"resolved": "https://registry.npmmirror.com/terser/-/terser-4.8.0.tgz",
|
"resolved": "https://registry.npmmirror.com/terser/-/terser-5.27.1.tgz",
|
||||||
"integrity": "sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==",
|
"integrity": "sha512-29wAr6UU/oQpnTw5HoadwjUZnFQXGdOfj0LjZ4sVxzqwHh/QVkvr7m8y9WoR4iN3FRitVduTc6KdjcW38Npsug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
|
"acorn": "^8.8.2",
|
||||||
"commander": "^2.20.0",
|
"commander": "^2.20.0",
|
||||||
"source-map": "~0.6.1",
|
"source-map-support": "~0.5.20"
|
||||||
"source-map-support": "~0.5.12"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"terser": "bin/terser"
|
"terser": "bin/terser"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/miniprogram-ci/node_modules/terser/node_modules/acorn": {
|
||||||
|
"version": "8.17.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz",
|
||||||
|
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"acorn": "bin/acorn"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/miniprogram-ci/node_modules/terser/node_modules/commander": {
|
"node_modules/miniprogram-ci/node_modules/terser/node_modules/commander": {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:mp-weixin": "uni -p mp-weixin",
|
"dev:mp-weixin": "uni -p mp-weixin",
|
||||||
"build:mp-weixin": "uni build -p mp-weixin",
|
"build:mp-weixin": "uni build -p mp-weixin && cp -n static/avatar-*.png dist/build/mp-weixin/static/ 2>/dev/null; true",
|
||||||
"dev:h5": "uni",
|
"dev:h5": "uni",
|
||||||
"build:h5": "uni build",
|
"build:h5": "uni build && cp -n static/avatar-*.png dist/build/h5/static/ 2>/dev/null; true",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
|
||||||
"@vue/test-utils": "^2.4.11",
|
"@vue/test-utils": "^2.4.11",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"miniprogram-ci": "^2.1.31",
|
"miniprogram-ci": "^2.1.42",
|
||||||
"sass": "^1.70.0",
|
"sass": "^1.70.0",
|
||||||
"typescript": "^5.3.0",
|
"typescript": "^5.3.0",
|
||||||
"vite": "^5.2.0",
|
"vite": "^5.2.0",
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="digital-human">
|
<view class="digital-human">
|
||||||
<view class="avatar-stage">
|
<view class="avatar-stage">
|
||||||
<view class="avatar-ring" :class="{ speaking: isSpeaking }">
|
<view class="avatar-body" :class="{ speaking: isSpeaking }">
|
||||||
<image class="face-img" src="/static/ai-face.png" mode="aspectFill" />
|
<image class="avatar-img" :src="avatarSrc" mode="aspectFill" @error="avatarError = true" />
|
||||||
<view class="mouth-overlay" :class="{ open: isSpeaking }" :style="{ height: mouthHeight + 'rpx' }"></view>
|
<view class="mouth-overlay" :style="mouthStyle"></view>
|
||||||
</view>
|
</view>
|
||||||
|
<image src="/static/avatar-default.png" style="display:none" />
|
||||||
|
<image src="/static/avatar-software.png" style="display:none" />
|
||||||
|
<image src="/static/avatar-frontend.png" style="display:none" />
|
||||||
|
<image src="/static/avatar-backend.png" style="display:none" />
|
||||||
|
<image src="/static/avatar-algo.png" style="display:none" />
|
||||||
|
<image src="/static/avatar-pm.png" style="display:none" />
|
||||||
|
<image src="/static/avatar-data.png" style="display:none" />
|
||||||
|
<image src="/static/avatar-marketing.png" style="display:none" />
|
||||||
|
<image src="/static/avatar-ops.png" style="display:none" />
|
||||||
|
<image src="/static/avatar-ui.png" style="display:none" />
|
||||||
<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">{{ positionLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="speech-area" v-if="currentText">
|
<view class="speech-area" v-if="currentText">
|
||||||
<view class="speech-bubble">
|
<view class="speech-bubble">
|
||||||
@@ -17,12 +27,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
import { ref, watch, computed, 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: '' },
|
||||||
|
amplitudeData: { type: Array, default: () => [] },
|
||||||
avatarUrl: { type: String, default: '' },
|
avatarUrl: { type: String, default: '' },
|
||||||
|
position: { type: String, default: '' },
|
||||||
autoPlay: { type: Boolean, default: true },
|
autoPlay: { type: Boolean, default: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,19 +42,50 @@ const emit = defineEmits(['speaking-start', 'speaking-end'])
|
|||||||
|
|
||||||
const isSpeaking = ref(false)
|
const isSpeaking = ref(false)
|
||||||
const currentText = ref('')
|
const currentText = ref('')
|
||||||
const mouthHeight = ref(4)
|
const mouthOpenness = ref(0)
|
||||||
|
const avatarError = ref(false)
|
||||||
|
|
||||||
let audioEl = null
|
let audioEl = null
|
||||||
let mouthTimer = null
|
let animFrame = null
|
||||||
let blinkTimer = null
|
|
||||||
|
|
||||||
onMounted(() => {
|
const allAvatars = [
|
||||||
scheduleBlink()
|
'/static/avatar-default.png',
|
||||||
|
'/static/avatar-software.png',
|
||||||
|
'/static/avatar-frontend.png',
|
||||||
|
'/static/avatar-backend.png',
|
||||||
|
'/static/avatar-algo.png',
|
||||||
|
'/static/avatar-pm.png',
|
||||||
|
'/static/avatar-data.png',
|
||||||
|
'/static/avatar-marketing.png',
|
||||||
|
'/static/avatar-ops.png',
|
||||||
|
'/static/avatar-ui.png',
|
||||||
|
]
|
||||||
|
|
||||||
|
const avatarMap = {
|
||||||
|
'软件开发': '/static/avatar-software.png',
|
||||||
|
'前端开发': '/static/avatar-frontend.png',
|
||||||
|
'后端开发': '/static/avatar-backend.png',
|
||||||
|
'算法工程师': '/static/avatar-algo.png',
|
||||||
|
'产品经理': '/static/avatar-pm.png',
|
||||||
|
'数据分析': '/static/avatar-data.png',
|
||||||
|
'市场营销': '/static/avatar-marketing.png',
|
||||||
|
'运营': '/static/avatar-ops.png',
|
||||||
|
'UI设计': '/static/avatar-ui.png',
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarSrc = computed(() => {
|
||||||
|
if (avatarError.value) return '/static/avatar-default.png'
|
||||||
|
if (props.avatarUrl) return props.avatarUrl
|
||||||
|
const key = Object.keys(avatarMap).find(k => props.position.includes(k))
|
||||||
|
return key ? avatarMap[key] : '/static/avatar-default.png'
|
||||||
|
})
|
||||||
|
|
||||||
|
const positionLabel = computed(() => {
|
||||||
|
return props.position ? `${props.position} 面试官` : 'AI 面试官'
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopAudio()
|
stopAudio()
|
||||||
if (blinkTimer) clearTimeout(blinkTimer)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.audioUrl, (url) => {
|
watch(() => props.audioUrl, (url) => {
|
||||||
@@ -55,22 +98,47 @@ watch(() => props.text, (txt) => {
|
|||||||
currentText.value = txt
|
currentText.value = txt
|
||||||
})
|
})
|
||||||
|
|
||||||
function scheduleBlink() {
|
const mouthStyle = computed(() => {
|
||||||
const delay = 2500 + Math.random() * 3500
|
const o = mouthOpenness.value
|
||||||
blinkTimer = setTimeout(() => {
|
const h = 3 + o * o * 22
|
||||||
if (isSpeaking.value) {
|
const w = 18 + o * 8
|
||||||
mouthHeight.value = 14
|
return {
|
||||||
setTimeout(() => { mouthHeight.value = 4 }, 100)
|
height: h + 'rpx',
|
||||||
|
width: w + 'rpx',
|
||||||
|
borderRadius: o > 0.3 ? '50%' : '4rpx',
|
||||||
|
background: o > 0.5 ? 'linear-gradient(180deg, #C97B84, #A85562)' : '#C97B84',
|
||||||
|
opacity: o > 0.01 ? 1 : 0.3,
|
||||||
}
|
}
|
||||||
scheduleBlink()
|
})
|
||||||
}, delay)
|
|
||||||
|
function getAmplitude(positionMs) {
|
||||||
|
const amp = props.amplitudeData
|
||||||
|
if (!amp || amp.length === 0) return -1
|
||||||
|
const idx = Math.floor(positionMs / 50)
|
||||||
|
if (idx >= amp.length) return -1
|
||||||
|
return amp[idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickMouth() {
|
||||||
|
if (!audioEl) return
|
||||||
|
const currentTime = audioEl.currentTime * 1000 || 0
|
||||||
|
const amp = getAmplitude(currentTime)
|
||||||
|
if (amp >= 0) {
|
||||||
|
const openness = Math.pow(Math.min(1, amp * 2.5), 0.7)
|
||||||
|
mouthOpenness.value = openness
|
||||||
|
} else {
|
||||||
|
const t = Date.now() / 1000
|
||||||
|
const idle = (Math.sin(t * 4) + 1) / 2 * 0.15
|
||||||
|
mouthOpenness.value = Math.max(0.03, idle)
|
||||||
|
}
|
||||||
|
animFrame = setTimeout(tickMouth, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
function playAudio(url) {
|
function playAudio(url) {
|
||||||
stopAudio()
|
stopAudio()
|
||||||
isSpeaking.value = true
|
isSpeaking.value = true
|
||||||
emit('speaking-start')
|
emit('speaking-start')
|
||||||
startMouthAnim()
|
tickMouth()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const innerAudio = uni.createInnerAudioContext()
|
const innerAudio = uni.createInnerAudioContext()
|
||||||
@@ -85,25 +153,18 @@ function playAudio(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startMouthAnim() {
|
|
||||||
let dir = 1
|
|
||||||
let h = 4
|
|
||||||
mouthTimer = setInterval(() => {
|
|
||||||
h += dir * 2
|
|
||||||
if (h >= 16) dir = -1
|
|
||||||
else if (h <= 4) dir = 1
|
|
||||||
mouthHeight.value = h
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
function finishSpeaking() {
|
function finishSpeaking() {
|
||||||
isSpeaking.value = false
|
isSpeaking.value = false
|
||||||
mouthHeight.value = 4
|
mouthOpenness.value = 0
|
||||||
if (mouthTimer) { clearInterval(mouthTimer); mouthTimer = null }
|
cleanupTimers()
|
||||||
cleanupAudio()
|
cleanupAudio()
|
||||||
emit('speaking-end')
|
emit('speaking-end')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanupTimers() {
|
||||||
|
if (animFrame) { clearTimeout(animFrame); animFrame = null }
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupAudio() {
|
function cleanupAudio() {
|
||||||
if (audioEl) {
|
if (audioEl) {
|
||||||
try { audioEl.stop(); audioEl.destroy() } catch {}
|
try { audioEl.stop(); audioEl.destroy() } catch {}
|
||||||
@@ -133,16 +194,17 @@ defineExpose({ play: playAudio, stop: stopAudio })
|
|||||||
gap: 10rpx;
|
gap: 10rpx;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.avatar-ring {
|
.avatar-body {
|
||||||
width: 220rpx;
|
width: 200rpx;
|
||||||
height: 220rpx;
|
height: 260rpx;
|
||||||
border-radius: 50%;
|
border-radius: 20rpx;
|
||||||
border: 4rpx solid #E5E7EB;
|
border: 4rpx solid #E5E7EB;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: border-color 0.3s, box-shadow 0.3s;
|
transition: border-color 0.3s, box-shadow 0.3s;
|
||||||
|
animation: breathing 4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.avatar-ring.speaking {
|
.avatar-body.speaking {
|
||||||
border-color: #6366F1;
|
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);
|
||||||
animation: speakPulse 1.5s ease-in-out infinite;
|
animation: speakPulse 1.5s ease-in-out infinite;
|
||||||
@@ -151,22 +213,20 @@ defineExpose({ play: playAudio, stop: stopAudio })
|
|||||||
0%, 100% { box-shadow: 0 0 20rpx rgba(99, 102, 241, 0.3); }
|
0%, 100% { box-shadow: 0 0 20rpx rgba(99, 102, 241, 0.3); }
|
||||||
50% { box-shadow: 0 0 50rpx rgba(99, 102, 241, 0.6); }
|
50% { box-shadow: 0 0 50rpx rgba(99, 102, 241, 0.6); }
|
||||||
}
|
}
|
||||||
.face-img {
|
@keyframes breathing {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.012); }
|
||||||
|
}
|
||||||
|
.avatar-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
.mouth-overlay {
|
.mouth-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 28%;
|
top: 36.8%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 22%;
|
transition: all 0.08s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
background: #C97B84;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: height 0.1s;
|
|
||||||
}
|
|
||||||
.mouth-overlay.open {
|
|
||||||
background: #A85562;
|
|
||||||
}
|
}
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 14rpx;
|
width: 14rpx;
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export const API_ENDPOINTS = {
|
|||||||
TTS: {
|
TTS: {
|
||||||
SYNTHESIZE: '/tts/synthesize',
|
SYNTHESIZE: '/tts/synthesize',
|
||||||
AUDIO: (hash: string) => `/tts/audio/${hash}`,
|
AUDIO: (hash: string) => `/tts/audio/${hash}`,
|
||||||
|
ASR: '/tts/asr',
|
||||||
},
|
},
|
||||||
SHARE: {
|
SHARE: {
|
||||||
CREATE: '/share/create',
|
CREATE: '/share/create',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "宇之然AI磁场",
|
"name": "宇之然AI磁场",
|
||||||
"appid": "__UNI__DEV__",
|
"appid": "__UNI__DEV__",
|
||||||
"versionName": "1.0.10",
|
"versionName": "1.0.11",
|
||||||
"versionCode": "110",
|
"versionCode": "111",
|
||||||
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
|
"description": "职引 - 宇之然AI磁场旗下AI模拟面试平台,提供AI面试官模拟练习、简历智能优化、大厂面经题库,助你轻松应对校招面试。",
|
||||||
"h5": {
|
"h5": {
|
||||||
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
|
"title": "职引 - AI模拟面试 | 宇之然AI磁场",
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
ref="dhRef"
|
ref="dhRef"
|
||||||
:text="aiSpeechText"
|
:text="aiSpeechText"
|
||||||
:audio-url="aiAudioUrl"
|
:audio-url="aiAudioUrl"
|
||||||
|
:amplitude-data="aiAmplitudeData"
|
||||||
|
:position="position"
|
||||||
:auto-play="true"
|
:auto-play="true"
|
||||||
@speaking-start="onAvatarSpeaking"
|
@speaking-start="onAvatarSpeaking"
|
||||||
@speaking-end="onAvatarSilent"
|
@speaking-end="onAvatarSilent"
|
||||||
@@ -53,7 +55,7 @@
|
|||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<view class="input-bar" v-if="!isComplete">
|
<view class="input-bar" v-if="!isComplete">
|
||||||
<view class="mic-btn" :class="{ recording: isRecording }" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="stopRecord">
|
<view class="mic-btn" :class="{ recording: isRecording }" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="stopRecord" @mousedown="startRecord" @mouseup="stopRecord" @mouseleave="stopRecord">
|
||||||
<text class="mic-icon">🎤</text>
|
<text class="mic-icon">🎤</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="input-box">
|
<view class="input-box">
|
||||||
@@ -89,6 +91,7 @@ const position = ref('')
|
|||||||
const avatarMode = ref(true)
|
const avatarMode = ref(true)
|
||||||
const aiSpeechText = ref('')
|
const aiSpeechText = ref('')
|
||||||
const aiAudioUrl = ref('')
|
const aiAudioUrl = ref('')
|
||||||
|
const aiAmplitudeData = ref([])
|
||||||
const isSpeaking = ref(false)
|
const isSpeaking = ref(false)
|
||||||
const dhRef = ref(null)
|
const dhRef = ref(null)
|
||||||
const isRecording = ref(false)
|
const isRecording = ref(false)
|
||||||
@@ -180,7 +183,7 @@ const sendAnswer = async () => {
|
|||||||
const aiMsg = res.data.messages.find(m => m.role === 'ai')
|
const aiMsg = res.data.messages.find(m => m.role === 'ai')
|
||||||
messages.value.push(...res.data.messages)
|
messages.value.push(...res.data.messages)
|
||||||
if (avatarMode.value && aiMsg) {
|
if (avatarMode.value && aiMsg) {
|
||||||
await speakAiText(aiMsg.content, res.data.ttsHash)
|
await speakAiText(aiMsg.content, res.data.ttsHash, res.data.ttsAmplitude)
|
||||||
}
|
}
|
||||||
answeredCount.value = res.data.questionCount || answeredCount.value + 1
|
answeredCount.value = res.data.questionCount || answeredCount.value + 1
|
||||||
if (res.data.ttsHash && !avatarMode.value) {
|
if (res.data.ttsHash && !avatarMode.value) {
|
||||||
@@ -195,8 +198,9 @@ const sendAnswer = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function speakAiText(text, ttsHash) {
|
async function speakAiText(text, ttsHash, ttsAmplitude) {
|
||||||
aiSpeechText.value = text
|
aiSpeechText.value = text
|
||||||
|
aiAmplitudeData.value = ttsAmplitude || []
|
||||||
if (ttsHash) {
|
if (ttsHash) {
|
||||||
aiAudioUrl.value = api(API_ENDPOINTS.TTS.AUDIO(ttsHash))
|
aiAudioUrl.value = api(API_ENDPOINTS.TTS.AUDIO(ttsHash))
|
||||||
} else {
|
} else {
|
||||||
@@ -208,6 +212,7 @@ async function speakAiText(text, ttsHash) {
|
|||||||
})
|
})
|
||||||
if (synthRes.statusCode === 200 && synthRes.data?.hash) {
|
if (synthRes.statusCode === 200 && synthRes.data?.hash) {
|
||||||
aiAudioUrl.value = api(API_ENDPOINTS.TTS.AUDIO(synthRes.data.hash))
|
aiAudioUrl.value = api(API_ENDPOINTS.TTS.AUDIO(synthRes.data.hash))
|
||||||
|
aiAmplitudeData.value = synthRes.data?.amplitudeData || []
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -252,11 +257,12 @@ function stopRecord() {
|
|||||||
const audioPath = res.tempFilePath
|
const audioPath = res.tempFilePath
|
||||||
try {
|
try {
|
||||||
const uploadRes = await uni.uploadFile({
|
const uploadRes = await uni.uploadFile({
|
||||||
url: api('/asr/recognize'),
|
url: api(API_ENDPOINTS.TTS.ASR),
|
||||||
filePath: audioPath,
|
filePath: audioPath,
|
||||||
name: 'audio',
|
name: 'audio',
|
||||||
header: { 'Authorization': `Bearer ${token.value}` },
|
header: { 'Authorization': `Bearer ${token.value}` },
|
||||||
})
|
})
|
||||||
|
console.log('[ASR] upload response:', uploadRes.statusCode, typeof uploadRes.data === 'string' ? uploadRes.data.slice(0, 200) : JSON.stringify(uploadRes.data).slice(0, 200))
|
||||||
if (uploadRes.statusCode === 200 && uploadRes.data) {
|
if (uploadRes.statusCode === 200 && uploadRes.data) {
|
||||||
const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data
|
const data = typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data
|
||||||
if (data.text) {
|
if (data.text) {
|
||||||
@@ -265,7 +271,9 @@ function stopRecord() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch (e) {
|
||||||
|
console.error('[ASR] upload error:', e?.message || e)
|
||||||
|
}
|
||||||
uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' })
|
uni.showToast({ title: '语音识别失败,请手动输入', icon: 'none' })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,12 +286,25 @@ const doWxLogin = async () => {
|
|||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
wxLoading.value = true
|
wxLoading.value = true
|
||||||
try {
|
try {
|
||||||
const { code } = await uni.login()
|
const wxResp = await uni.login()
|
||||||
const res = await uni.request({ url: api('/user/wx-login'), method: 'POST', data: { code } })
|
console.log('[wxLogin] uni.login success:', JSON.stringify(wxResp).slice(0, 300))
|
||||||
|
const { code, errMsg } = wxResp
|
||||||
|
if (!code) { console.error('[wxLogin] no code:', errMsg); showToast('获取微信凭证失败'); return }
|
||||||
|
const res = await uni.request({
|
||||||
|
url: api('/user/wx-login'), method: 'POST',
|
||||||
|
header: { 'Content-Type': 'application/json' },
|
||||||
|
data: { code },
|
||||||
|
})
|
||||||
|
console.log('[wxLogin] server response:', res.statusCode, JSON.stringify(res.data).slice(0, 500))
|
||||||
if (res.statusCode === 200 && res.data?.token) {
|
if (res.statusCode === 200 && res.data?.token) {
|
||||||
loginSuccess(res.data)
|
loginSuccess(res.data)
|
||||||
} else { showToast('微信登录失败') }
|
} else {
|
||||||
} catch { showToast('微信登录失败') }
|
showToast(res.data?.message || `登录失败(${res.statusCode})`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[wxLogin] error:', JSON.stringify(e).slice(0, 500))
|
||||||
|
showToast('微信登录失败')
|
||||||
|
}
|
||||||
finally { wxLoading.value = false }
|
finally { wxLoading.value = false }
|
||||||
// #endif
|
// #endif
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |