xinli/xinlidsj/pages/voice/index.vue
2026-02-24 16:49:05 +08:00

657 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page" :class="{ big: isH5 }">
<view class="panel">
<view class="section-title">模拟识别</view>
<input class="input" v-model="simulateText" placeholder="输入模拟识别文本,例如:打开预警中心 / 去监区看板 / 打开个体画像" />
<view class="chips">
<view class="chip" v-for="t in quickTexts" :key="t" @click="useQuickText(t)">{{ t }}</view>
</view>
<view class="actions single">
<button class="btn full" type="primary" :disabled="!simulateText || loading" @click="onSimulate">模拟识别并跳转</button>
</view>
<view v-if="errorMsg" class="error">{{ errorMsg }}</view>
</view>
<view class="panel">
<view class="section-title">识别文本</view>
<view class="text-box" :class="{ empty: !recognizedText }">
<text v-if="recognizedText">{{ recognizedText }}</text>
<text v-else>暂无识别结果</text>
</view>
<view v-if="errorMsg" class="error">{{ errorMsg }}</view>
<view class="actions">
<button class="btn" type="primary" :disabled="!recognizedText || loading" @click="onExecute">执行</button>
<button class="btn" :disabled="recording || loading" @click="onClear">清空</button>
</view>
</view>
<view class="record-wrap">
<view class="status">{{ recording ? '录音中…' : '按住说话' }}</view>
<view class="record-btn"
:class="{ recording: recording }"
@touchstart="startRecord"
@touchend="stopRecord"
@touchcancel="stopRecord">
<text class="record-btn-text">{{ loading ? '识别中…' : (recording ? '松开结束' : '按住说话') }}</text>
</view>
</view>
<view class="panel">
<view class="section-title">执行结果</view>
<view class="text-box" :class="{ empty: !commandResult }">
<text v-if="commandResult">{{ commandResult }}</text>
<text v-else>暂无</text>
</view>
</view>
</view>
</template>
<script>
import { request, uploadFile } from '../../utils/request'
const TEMPLATE_EXPIRE_MS = 30 * 1000
const SCENE_TEMPLATES = [
{
id: 'morning_brief',
name: '早会汇报模板',
triggers: ['调用早会汇报模板', '打开早会汇报模板', '早会汇报模板'],
options: [
{ label: '1. 汇报昨日预警情况', action: { type: 'voice_command', text: '打开预警中心' } },
{ label: '2. 展示本周测评完成率', action: { type: 'navigate', url: '/pages/chart/templates' } }
]
},
{
id: 'duty_patrol',
name: '值班巡查模板',
triggers: ['调用值班巡查模板', '打开值班巡查模板', '值班巡查模板', '值班巡查'],
options: [
{ label: '1. 打开监区看板', action: { type: 'navigate', url: '/pages/index/index' } },
{ label: '2. 查看预警中心', action: { type: 'voice_command', text: '打开预警中心' } }
]
}
]
function normalizeText(text) {
return String(text || '').trim()
}
function parseSelection(text) {
const s = normalizeText(text)
const m1 = s.match(/^(?:选|选择)?\s*(\d{1,2})\s*(?:项|号)?$/)
if (m1 && m1[1]) return parseInt(m1[1], 10)
const map = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 }
const m2 = s.match(/^(?:选|选择)?\s*([一二三四五六七八九十])\s*(?:项|号)?$/)
if (m2 && m2[1] && map[m2[1]]) return map[m2[1]]
return null
}
export default {
data() {
return {
isH5: false,
recording: false,
loading: false,
autoStart: false,
simulateText: '',
quickTexts: ['打开预警中心', '去监区看板', '打开个体画像', '打开图表模板', '打开干预任务'],
recognizedText: '',
commandResult: '',
errorMsg: '',
audioPath: '',
activeTemplateId: '',
activeTemplateOptions: [],
templateExpireAt: 0
}
},
onLoad(options) {
try {
const info = uni.getSystemInfoSync()
const p = info ? (info.uniPlatform || info.platform) : ''
this.isH5 = p === 'web' || p === 'h5'
} catch (e) {
this.isH5 = false
}
this.autoStart = !!(options && (options.autostart === '1' || options.autostart === 1 || options.autostart === 'true'))
this.recorder = uni.getRecorderManager()
this.recorder.onStop((res) => {
this.onRecorderStop(res)
})
this.recorder.onError((err) => {
this.loading = false
this.recording = false
this.errorMsg = (err && err.errMsg) ? err.errMsg : '录音失败'
})
if (this.autoStart) {
setTimeout(() => {
if (!this.recording && !this.loading) this.startRecord()
}, 300)
}
},
methods: {
clearTemplateSession() {
this.activeTemplateId = ''
this.activeTemplateOptions = []
this.templateExpireAt = 0
},
getActiveTemplate() {
if (!this.activeTemplateId) return null
const now = Date.now()
if (!this.templateExpireAt || now > this.templateExpireAt) {
this.clearTemplateSession()
return null
}
for (let i = 0; i < SCENE_TEMPLATES.length; i++) {
if (SCENE_TEMPLATES[i].id === this.activeTemplateId) return SCENE_TEMPLATES[i]
}
return null
},
findTemplateByText(text) {
const s = normalizeText(text)
if (!s) return null
for (let i = 0; i < SCENE_TEMPLATES.length; i++) {
const t = SCENE_TEMPLATES[i]
const triggers = t && t.triggers ? t.triggers : []
for (let j = 0; j < triggers.length; j++) {
if (s.includes(triggers[j])) return t
}
}
return null
},
openTemplateMenu(tpl) {
if (!tpl || !tpl.options || !tpl.options.length) return
this.activeTemplateId = tpl.id
this.activeTemplateOptions = tpl.options
this.templateExpireAt = Date.now() + TEMPLATE_EXPIRE_MS
this.commandResult = `已进入${tpl.name}请说“选1/选2”…${Math.floor(TEMPLATE_EXPIRE_MS / 1000)}秒内有效)`
uni.showActionSheet({
itemList: tpl.options.map(o => o.label),
success: (res) => {
const idx = (res && typeof res.tapIndex === 'number') ? res.tapIndex + 1 : null
if (!idx) return
this.executeTemplateSelection(idx)
}
})
},
executeVoiceCommandText(text) {
return request({
url: '/voice/command',
method: 'POST',
data: { text }
})
},
executeTemplateSelection(selIndex) {
const tpl = this.getActiveTemplate()
if (!tpl) {
this.commandResult = '模板已失效请重新说“调用XX模板”'
return Promise.resolve()
}
const opt = this.activeTemplateOptions[selIndex - 1]
if (!opt) {
this.commandResult = `未找到选项${selIndex},请重试`
return Promise.resolve()
}
this.clearTemplateSession()
if (opt.action && opt.action.type === 'navigate' && opt.action.url) {
uni.navigateTo({ url: opt.action.url })
this.commandResult = '已跳转:' + opt.action.url
return Promise.resolve()
}
if (opt.action && opt.action.type === 'voice_command' && opt.action.text) {
this.loading = true
return this.executeVoiceCommandText(opt.action.text).then((res) => {
this.loading = false
return this.handleCommandResponse(res)
}).catch((e) => {
this.loading = false
this.errorMsg = e && e.message ? e.message : '网络错误'
})
}
this.commandResult = opt.label
return Promise.resolve()
},
handleCommandResponse(res) {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) {
this.errorMsg = (data && data.msg) ? data.msg : '执行失败'
return
}
const type = data.type
const payload = data.payload || {}
if (type === 'analysis') {
this.commandResult = payload.result || '暂无分析结果'
return
}
if (type === 'navigate' && payload.url) {
uni.navigateTo({ url: payload.url })
this.commandResult = '已跳转:' + payload.url
return
}
if (type === 'ask_clarify') {
this.commandResult = payload.message || '需要澄清指令'
return
}
this.commandResult = JSON.stringify(data, null, 2)
},
executeAnalyzeText(text) {
return request({
url: '/voice/analyze',
method: 'POST',
data: { text }
})
},
matchAnalyze(text) {
const s = normalizeText(text)
if (!s) return null
const m = s.match(/^分析\s*([\s\S]{1,64}?)(?:数据)?$/)
if (!m) return null
const topic = normalizeText(m[1])
return { topic: topic || '相关' }
},
tryHandleSceneTemplate(text) {
const s = normalizeText(text)
if (!s) return false
// 1) 识别到模板唤起
const tpl = this.findTemplateByText(s)
if (tpl) {
this.openTemplateMenu(tpl)
return true
}
// 2) 识别到“选1/选2”
const sel = parseSelection(s)
if (sel != null) {
const activeTpl = this.getActiveTemplate()
if (activeTpl) {
this.executeTemplateSelection(sel)
return true
}
}
// 3) 识别到“分析xx数据”
const analyze = this.matchAnalyze(s)
if (analyze) {
this.loading = true
this.executeAnalyzeText(s).then((res) => {
this.loading = false
this.handleCommandResponse(res)
}).catch((e) => {
this.loading = false
this.errorMsg = e && e.message ? e.message : '网络错误'
})
return true
}
return false
},
useQuickText(t) {
this.simulateText = t
},
onSimulate() {
this.errorMsg = ''
this.commandResult = ''
const text = (this.simulateText || '').trim()
if (!text) return
this.recognizedText = text
const analyze = this.matchAnalyze(text)
if (analyze) {
this.loading = true
this.executeAnalyzeText(text).then((res) => {
this.loading = false
this.handleCommandResponse(res)
}).catch((e) => {
this.loading = false
this.errorMsg = e && e.message ? e.message : '网络错误'
})
return
}
if (this.tryHandleSceneTemplate(text)) return
const match = this.matchNavigate(text)
if (!match) {
this.commandResult = '未匹配到跳转:' + text
return
}
uni.navigateTo({ url: match.url })
this.commandResult = '已跳转:' + match.name
},
matchNavigate(text) {
const s = String(text || '').toLowerCase()
const reportQueryMatch = String(text || '').match(/查询\s*([^\s的]{1,32})\s*的?报告/)
if (reportQueryMatch && reportQueryMatch[1]) {
const keyword = String(reportQueryMatch[1]).trim()
return { name: '报告列表', url: '/pages/report/index?keyword=' + encodeURIComponent(keyword) }
}
const rules = [
{ name: '预警中心', url: '/pages/warning/index', keys: ['预警', '风险', '告警', '预警中心'] },
{ name: '监区看板', url: '/pages/index/index', keys: ['看板', '监区', 'dashboard'] },
{ name: '个体画像', url: '/pages/profile/index', keys: ['画像', '个体', '人员', '档案', 'profile'] },
{ name: '图表模板', url: '/pages/chart/templates', keys: ['图表模板', '模板', '图表', 'charts'] },
{ name: '自定义图表', url: '/pages/chart/custom', keys: ['自定义图表', '自定义', 'custom'] },
{ name: '干预任务', url: '/pages/interventionTask/index', keys: ['干预', '任务', 'intervention'] },
{ name: '测评列表', url: '/pages/assessment/index', keys: ['测评', '量表', 'assessment'] },
{ name: '报告列表', url: '/pages/report/index', keys: ['报告', 'report'] },
{ name: '综合报告', url: '/pages/comprehensive/index', keys: ['综合报告', '综合', 'comprehensive'] }
]
for (let i = 0; i < rules.length; i++) {
const r = rules[i]
if (!r || !r.keys) continue
for (let j = 0; j < r.keys.length; j++) {
const k = String(r.keys[j]).toLowerCase()
if (k && s.includes(k)) return { name: r.name, url: r.url }
}
}
return null
},
startRecord() {
if (this.loading) return
this.errorMsg = ''
this.commandResult = ''
this.recording = true
this.recognizedText = ''
this.audioPath = ''
this.recorder.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
})
},
stopRecord() {
if (!this.recording) return
this.recording = false
this.loading = true
this.recorder.stop()
},
onRecorderStop(res) {
if (!res || !res.tempFilePath) {
this.loading = false
this.errorMsg = '未获取到录音文件'
return
}
this.uploadAndAsr(res.tempFilePath)
},
onExecute() {
this.errorMsg = ''
if (this.tryHandleSceneTemplate(this.recognizedText)) return
const analyze = this.matchAnalyze(this.recognizedText)
if (analyze) {
this.loading = true
this.executeAnalyzeText(this.recognizedText).then((res) => {
this.loading = false
this.handleCommandResponse(res)
}).catch((e) => {
this.loading = false
this.errorMsg = e && e.message ? e.message : '网络错误'
})
return
}
this.loading = true
this.executeVoiceCommandText(this.recognizedText).then((res) => {
this.loading = false
this.handleCommandResponse(res)
}).catch((e) => {
this.loading = false
this.errorMsg = e && e.message ? e.message : '网络错误'
})
},
onClear() {
this.simulateText = ''
this.recognizedText = ''
this.commandResult = ''
this.errorMsg = ''
this.audioPath = ''
this.clearTemplateSession()
},
uploadAndAsr(tempFilePath) {
uploadFile({
url: '/voice/asr',
filePath: tempFilePath,
name: 'file',
formData: { language: 'zh' }
}).then((res) => {
this.loading = false
let data = null
try {
data = JSON.parse(res.data)
} catch (e) {
this.errorMsg = '服务返回格式错误'
return
}
if (!data || data.code !== 200) {
this.errorMsg = (data && data.msg) ? data.msg : '识别失败'
return
}
this.recognizedText = data.text || ''
this.audioPath = data.audioPath || ''
if (!this.recognizedText) {
this.errorMsg = '未识别到有效文本'
}
}).catch((e) => {
this.loading = false
this.errorMsg = e && e.message ? e.message : '上传失败'
})
}
}
}
</script>
<style>
.page {
min-height: 100vh;
padding: 24rpx 24rpx 120rpx;
box-sizing: border-box;
background: #F4F6FB;
}
.page.big {
min-height: 100vh;
padding: 14rpx 14rpx 120rpx;
box-sizing: border-box;
position: relative;
overflow: hidden;
--neon-a: rgba(0, 240, 255, 0.95);
--neon-b: rgba(60, 140, 255, 0.95);
--neon-soft: rgba(0, 240, 255, 0.18);
--glass: rgba(7, 13, 28, 0.42);
--glass-2: rgba(10, 18, 38, 0.52);
background-image:
radial-gradient(1100rpx 520rpx at 50% 14%, rgba(43, 107, 255, 0.30) 0%, rgba(6, 16, 40, 0.0) 65%),
linear-gradient(180deg, rgba(5, 11, 24, 0.90) 0%, rgba(8, 20, 45, 0.85) 42%, rgba(6, 16, 40, 0.92) 100%),
url('/static/bg.png');
background-size: auto, auto, cover;
background-position: center, center, center;
background-repeat: no-repeat, no-repeat, no-repeat;
color: rgba(242, 252, 255, 0.96);
font-size: 24rpx;
line-height: 1.5;
text-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.55);
}
.page.big:before {
content: none;
}
.panel {
background: rgba(255, 255, 255, 0.95);
border-radius: 18rpx;
padding: 24rpx;
border: 1px solid rgba(15, 23, 42, 0.06);
box-shadow: 0 10rpx 22rpx rgba(15, 23, 42, 0.05);
margin-bottom: 18rpx;
}
.page.big .panel {
position: relative;
z-index: 1;
border-radius: 18rpx;
padding: 26rpx 26rpx 22rpx;
border: 1px solid rgba(0, 240, 255, 0.16);
background-image:
linear-gradient(135deg, rgba(0, 240, 255, 0.08) 0%, rgba(60, 140, 255, 0.05) 35%, rgba(165, 90, 255, 0.06) 100%),
linear-gradient(180deg, rgba(7, 13, 28, 0.72) 0%, rgba(7, 13, 28, 0.56) 100%);
background-repeat: no-repeat;
background-size: cover;
backdrop-filter: blur(6px);
box-shadow: 0 14rpx 26rpx rgba(0, 0, 0, 0.42);
}
.section-title {
font-size: 28rpx;
font-weight: 700;
color: #111827;
margin-bottom: 16rpx;
}
.page.big .section-title {
color: rgba(242, 252, 255, 0.94);
font-weight: 900;
letter-spacing: 1rpx;
text-shadow: 0 0 16rpx rgba(0, 240, 255, 0.16);
}
.text-box {
min-height: 120rpx;
border-radius: 16rpx;
padding: 18rpx;
box-sizing: border-box;
background: rgba(15, 23, 42, 0.04);
color: #111827;
font-size: 26rpx;
line-height: 40rpx;
border: 1px solid rgba(15, 23, 42, 0.06);
}
.page.big .text-box {
background: rgba(7, 13, 28, 0.55);
color: rgba(242, 252, 255, 0.92);
border: 1px solid rgba(116, 216, 255, 0.14);
}
.text-box.empty {
color: #94A3B8;
}
.page.big .text-box.empty {
color: rgba(242, 252, 255, 0.62);
}
.error {
margin-top: 14rpx;
font-size: 24rpx;
color: #ff4d4f;
line-height: 36rpx;
}
.actions {
margin-top: 18rpx;
display: flex;
justify-content: space-between;
gap: 14rpx;
}
.actions.single {
justify-content: flex-start;
}
.btn {
width: 48%;
border-radius: 16rpx;
}
.page.big .btn {
border-radius: 14rpx;
}
.btn.full {
width: 100%;
}
.input {
width: 100%;
background: rgba(15, 23, 42, 0.04);
border-radius: 16rpx;
padding: 22rpx 20rpx;
font-size: 28rpx;
box-sizing: border-box;
border: 1px solid rgba(15, 23, 42, 0.06);
min-height: 92rpx;
line-height: 92rpx;
}
.page.big .input {
background: rgba(7, 13, 28, 0.78);
border-radius: 14rpx;
border: 1px solid rgba(116, 216, 255, 0.18);
color: rgba(242, 252, 255, 0.92);
}
.chips {
display: flex;
flex-wrap: wrap;
margin-top: 14rpx;
gap: 14rpx;
}
.chip {
padding: 12rpx 18rpx;
border-radius: 999rpx;
font-size: 24rpx;
color: #64748B;
background: rgba(15, 23, 42, 0.04);
border: 1px solid rgba(15, 23, 42, 0.06);
}
.page.big .chip {
color: rgba(242, 252, 255, 0.82);
background: rgba(7, 13, 28, 0.55);
border: 1px solid rgba(0, 240, 255, 0.16);
box-shadow: 0 0 16rpx rgba(0, 166, 255, 0.08);
}
.record-wrap {
align-items: center;
display: flex;
flex-direction: column;
margin: 30rpx 0 24rpx;
}
.status {
font-size: 24rpx;
color: #94A3B8;
margin-bottom: 18rpx;
}
.page.big .status {
color: rgba(242, 252, 255, 0.72);
}
.record-btn {
width: 420rpx;
height: 420rpx;
border-radius: 210rpx;
display: flex;
align-items: center;
justify-content: center;
background: #1677ff;
box-shadow: 0 18rpx 40rpx rgba(22, 119, 255, 0.22);
}
.page.big .record-btn {
border: 1px solid rgba(0, 240, 255, 0.22);
background: radial-gradient(circle at 30% 30%, rgba(0, 240, 255, 0.22) 0%, rgba(7, 13, 28, 0.82) 60%, rgba(2, 8, 22, 0.92) 100%);
box-shadow: 0 0 26rpx rgba(0, 240, 255, 0.14), 0 18rpx 40rpx rgba(0, 0, 0, 0.45);
}
.record-btn.recording {
background: #F56C6C;
box-shadow: 0 18rpx 40rpx rgba(245, 108, 108, 0.22);
}
.page.big .record-btn.recording {
border-color: rgba(255, 72, 92, 0.35);
background: radial-gradient(circle at 30% 30%, rgba(255, 72, 92, 0.22) 0%, rgba(7, 13, 28, 0.82) 60%, rgba(2, 8, 22, 0.92) 100%);
box-shadow: 0 0 26rpx rgba(255, 72, 92, 0.14), 0 18rpx 40rpx rgba(0, 0, 0, 0.45);
}
.record-btn-text {
color: #ffffff;
font-size: 30rpx;
font-weight: 700;
}
</style>