提交 9a5912ba authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增直播下单

上级 ddb1d739
VITE_LOGIN_URL=https://login.ezijing.com/auth/login/index VITE_LOGIN_URL=https://login.ezijing.com/auth/login/index
VITE_LAB_URL=https://bi.ezijing.com/bi/?proc=0&action=index VITE_LAB_URL=https://bi.ezijing.com/bi/?proc=0&action=index
VITE_SURVEYKING_URL=https://surveyking.ezijing.com VITE_SURVEYKING_URL=https://surveyking.ezijing.com
\ No newline at end of file
# 直播语音识别
VITE_ACCESS_KEY_ID=LTAI5t7YUVzDVSFLYvnuWGuq
VITE_ACCESS_KEY_SECRET=GBsohg5hSUP99dzIuRuCQilUXTSiYe
VITE_APP_KEY=W7Yqc8L49MEnnLsE
\ No newline at end of file
...@@ -302,6 +302,11 @@ ...@@ -302,6 +302,11 @@
"onWatcherCleanup": true, "onWatcherCleanup": true,
"useId": true, "useId": true,
"useModel": true, "useModel": true,
"useTemplateRef": true "useTemplateRef": true,
"createRef": true,
"onElementRemoval": true,
"useCountdown": true,
"usePreferredReducedTransparency": true,
"useSSRWidth": true
} }
} }
...@@ -28,6 +28,7 @@ declare global { ...@@ -28,6 +28,7 @@ declare global {
const createGlobalState: typeof import('@vueuse/core')['createGlobalState'] const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState'] const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn'] const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createRef: typeof import('@vueuse/core')['createRef']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate'] const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable'] const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise'] const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
...@@ -62,6 +63,7 @@ declare global { ...@@ -62,6 +63,7 @@ declare global {
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside'] const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated'] const onDeactivated: typeof import('vue')['onDeactivated']
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
const onErrorCaptured: typeof import('vue')['onErrorCaptured'] const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke'] const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress'] const onLongPress: typeof import('@vueuse/core')['onLongPress']
...@@ -144,6 +146,7 @@ declare global { ...@@ -144,6 +146,7 @@ declare global {
const useCloned: typeof import('@vueuse/core')['useCloned'] const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode'] const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog'] const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCountdown: typeof import('@vueuse/core')['useCountdown']
const useCounter: typeof import('@vueuse/core')['useCounter'] const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule'] const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar'] const useCssVar: typeof import('@vueuse/core')['useCssVar']
...@@ -224,12 +227,14 @@ declare global { ...@@ -224,12 +227,14 @@ declare global {
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark'] const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages'] const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion'] const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
const usePrevious: typeof import('@vueuse/core')['usePrevious'] const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useRafFn: typeof import('@vueuse/core')['useRafFn'] const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory'] const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver'] const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute'] const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter'] const useRouter: typeof import('vue-router')['useRouter']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation'] const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea'] const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag'] const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
......
差异被折叠。
...@@ -22,10 +22,11 @@ ...@@ -22,10 +22,11 @@
"@tinymce/tinymce-vue": "^5.0.1", "@tinymce/tinymce-vue": "^5.0.1",
"@vue-flow/controls": "^1.1.2", "@vue-flow/controls": "^1.1.2",
"@vue-flow/core": "^1.39.0", "@vue-flow/core": "^1.39.0",
"@vueuse/components": "^11.2.0", "@vueuse/components": "^13.3.0",
"@vueuse/core": "^11.2.0", "@vueuse/core": "^13.3.0",
"axios": "^1.6.8", "axios": "^1.6.8",
"blueimp-md5": "^2.19.0", "blueimp-md5": "^2.19.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"echarts": "^5.5.0", "echarts": "^5.5.0",
"echarts-wordcloud": "^2.1.0", "echarts-wordcloud": "^2.1.0",
...@@ -37,14 +38,16 @@ ...@@ -37,14 +38,16 @@
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"pinia": "^2.2.6", "pinia": "^2.2.6",
"scroll-into-view-if-needed": "^3.1.0", "scroll-into-view-if-needed": "^3.1.0",
"vue": "^3.5.12", "vue": "^3.5.15",
"vue-echarts": "^6.6.9", "vue-echarts": "^6.6.9",
"vue-router": "^4.4.5", "vue-markdown-render": "^2.2.1",
"vue-router": "^4.5.1",
"xss": "^1.0.15" "xss": "^1.0.15"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"@types/blueimp-md5": "^2.18.2", "@types/blueimp-md5": "^2.18.2",
"@types/crypto-js": "^4.2.2",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/node": "^20.17.6", "@types/node": "^20.17.6",
"@vitejs/plugin-vue": "^5.1.4", "@vitejs/plugin-vue": "^5.1.4",
......
class PCMProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0]
if (input.length > 0) {
const inputData = input[0] // 取第一个通道
const inputData16 = new Int16Array(inputData.length)
for (let i = 0; i < inputData.length; i++) {
// PCM 16-bit 转换
inputData16[i] = Math.max(-1, Math.min(1, inputData[i])) * 0x7fff
}
// 通过 port 向主线程发送处理后的数据
this.port.postMessage(inputData16.buffer, [inputData16.buffer])
}
return true // 保持处理器活跃
}
}
registerProcessor('pcm-processor', PCMProcessor)
import httpRequest from '@/utils/axios'
// 获取订单列表
export function getOrderList(params?: { live_commodity_type_id?: string; live_commodity_title?: string }) {
return httpRequest.get('/api/lab/v1/experiment/live-practice-order/list', { params })
}
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/components/layout/Index.vue'
const routes: RouteRecordRaw[] = [
{
path: '/live/order',
component: Layout,
children: [{ path: '', component: () => import('./views/Index.vue') }]
}
]
export { routes }
<script setup>
import LiveProductCategory from '@/components/LiveProductCategory.vue'
import { getOrderList } from '../api'
const appList = ref(null)
// 刷新
const handleRefresh = () => {
appList.value?.refetch()
}
const listParams = reactive({ name: '', live_commodity_type_id: '', live_commodity_title: '' })
// 列表配置
const listOptions = computed(() => {
return {
remote: {
httpRequest: getOrderList,
params: listParams,
beforeRequest(params, isReset) {
if (isReset) listParams.live_commodity_type_id = ''
params.live_commodity_type_id = listParams.live_commodity_type_id
return params
},
},
filters: [
{ label: '直播主题品类', prop: 'live_commodity_type_id', slots: 'filter-category' },
{ label: '直播主题名称', prop: 'live_commodity_title', type: 'input' },
],
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '主播', prop: 'updated_operator_name' },
{
label: '商品信息',
prop: 'commodity_info',
computed({ row }) {
try {
const info = JSON.parse(row.commodity_info)
const [picture] = JSON.parse(info.picture_addreses) || []
return `<div style="display: flex; align-items: center; gap: 10px;"><img src="${picture.url}" width="100" />${info.title}</div>`
} catch (e) {
console.log(e)
}
return '--'
},
},
{
label: '单价/数量',
prop: 'num',
computed({ row }) {
return `¥${row.price}<br/>x${row.num}`
},
},
{ label: '订单编号', prop: 'id' },
{
label: '订单状态',
prop: 'status',
computed() {
return '待发货'
},
},
{ label: '下单时间', prop: 'updated_time' },
],
}
})
</script>
<template>
<AppCard title="订单管理">
<AppList v-bind="listOptions" ref="appList">
<template #filter-category>
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh"></LiveProductCategory>
</template>
</AppList>
</AppCard>
</template>
...@@ -61,3 +61,8 @@ export function getRecordList(params: { live_practice_id: string }) { ...@@ -61,3 +61,8 @@ export function getRecordList(params: { live_practice_id: string }) {
export function getRecord(params: { id: string }) { export function getRecord(params: { id: string }) {
return httpRequest.get('/api/lab/v1/experiment/live-practice/live-practice-record', { params }) return httpRequest.get('/api/lab/v1/experiment/live-practice/live-practice-record', { params })
} }
// 推送字幕
export function pushSubtitle(params: { subtitle: string; selling_point: string; marketing_campaign: string }) {
return httpRequest.get('/api/lab/v1/experiment/live-practice/push-subtitle', { params })
}
<script setup>
import Live from './Live.vue'
import LivePlayback from './LivePlayback.vue'
import { getTest, getRecord, pushSubtitle } from '../api'
import { useCountdown } from '@/composables/useCountdown'
const props = defineProps({
id: { type: String },
recordId: { type: String },
isView: { type: Boolean, default: false },
})
const { timeLeft, formattedTime, stop, reset } = useCountdown({
// 倒计时结束
onEnd: () => {
live.value?.stop()
},
})
const live = ref(null)
const detail = ref(null)
provide('detail', detail)
const duration = computed(() => {
return parseInt(detail.value?.duration) * 60 || 0
})
const isLocalUpload = computed(() => {
return detail.value?.upload_way == 2
})
async function fetchInfo() {
const res = await getTest({ id: props.id })
detail.value = res.data.detail
timeLeft.value = duration.value
}
watchEffect(() => {
!props.isView && fetchInfo()
})
// 商品卖点
const hotList = computed(() => {
return detail.value?.live_speech.selling_point.split(/;|;/)
})
// 营销活动
const actList = computed(() => {
return detail.value?.live_speech.marketing_campaign.split(/;|;/)
})
// 直播记录数据
const record = ref(null)
provide('record', record)
// 直播数据
const stats = computed(() => {
const result = { totalViewers: 0, peakViewers: 0, totalGifts: 0, totalLikes: 0, totalGiftViewers: 0 }
return Object.assign(result, record.value?.live_info.stats)
})
const timelines = ref([])
const fetchRecord = async () => {
const res = await getRecord({ id: props.recordId })
const resDetail = res.data.detail
record.value = { ...resDetail, live_info: JSON.parse(resDetail.live_info) }
detail.value = resDetail.live_practice_info
timeLeft.value = duration.value
timelines.value = JSON.parse(resDetail.subtitle)?.Result?.Sentences || []
}
onMounted(() => {
props.isView && fetchRecord()
})
// 毫秒转分钟 00:00,需要补0
const formatTime = (time) => {
const minutes = Math.floor(time / 1000 / 60)
const seconds = Math.floor((time / 1000) % 60)
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
const orderCount = ref(0)
const onSentenceEnd = async (result, data) => {
console.log('onSentenceEnd', result, data)
const res = await pushSubtitle({
subtitle: result,
selling_point: detail.value.live_speech.selling_point,
marketing_campaign: detail.value.live_speech.marketing_campaign,
})
orderCount.value = res.data.count
}
const currentStats = ref({})
const onStatsChange = (stats) => {
currentStats.value = stats
}
</script>
<template>
<div class="live-row">
<div class="live-col">
<template v-if="isView">
<LivePlayback :record="record" v-if="record"></LivePlayback>
</template>
<template v-else>
<Live
ref="live"
:isLocalUpload="isLocalUpload"
:onStart="() => reset(duration)"
:onStop="stop"
:onSentenceEnd="onSentenceEnd"
:onStatsChange="onStatsChange"
v-if="detail" />
</template>
</div>
<div class="live-col" style="flex: 1" v-if="isView">
<el-timeline style="max-width: 600px">
<el-timeline-item
placement="top"
v-for="(item, index) in timelines"
:key="index"
:timestamp="formatTime(item.BeginTime)">
{{ item.Text }}
</el-timeline-item>
</el-timeline>
</div>
<div class="live-col" style="flex: 1">
<h2 class="h2-title">直播话术</h2>
<div class="live-talk-content" v-html="detail?.live_speech.content"></div>
</div>
<div class="live-col" style="width: 350px" v-if="!isView">
<div class="live-col-box" v-if="isView">
<h2 class="h2-title">直播数据</h2>
<div class="live-data">
<dl>
<dt>观众总人数:</dt>
<dd>{{ stats.totalViewers }}</dd>
</dl>
<dl>
<dt>最高峰人数:</dt>
<dd>{{ stats.peakViewers }}</dd>
</dl>
<dl>
<dt>点赞数:</dt>
<dd>{{ stats.totalLikes }}</dd>
</dl>
<dl>
<dt>刷礼物人数:</dt>
<dd>{{ stats.totalGiftViewers }}</dd>
</dl>
<dl>
<dt>刷礼物总数:</dt>
<dd>{{ stats.totalGifts }}</dd>
</dl>
</div>
</div>
<div class="live-col-box" v-else>
<h2 class="h2-title">倒计时</h2>
<h3 class="live-time">{{ formattedTime }}</h3>
<div class="live-data">
<dl>
<dt>观众人数:</dt>
<dd>{{ currentStats.viewers }}</dd>
</dl>
<dl>
<dt>订单量:</dt>
<dd>{{ orderCount }}</dd>
</dl>
</div>
</div>
<div class="live-col-box">
<h2 class="h2-title">主题卖点</h2>
<ul class="live-tag live-tag__hot">
<li v-for="item in hotList" :key="item">{{ item }}</li>
</ul>
</div>
<div class="live-col-box">
<h2 class="h2-title">营销活动</h2>
<ul class="live-tag live-tag__act">
<li v-for="item in actList" :key="item">{{ item }}</li>
</ul>
</div>
</div>
</div>
</template>
<style lang="scss">
.live-row {
height: 100%;
display: flex;
gap: 20px;
}
.live-col {
padding: 20px;
border-radius: 10px;
border: 1px solid #eee;
max-height: 100%;
overflow-y: auto;
.h2-title {
margin-top: 0;
}
}
.live-tag {
margin: 20px 0;
background-color: #eee;
border-radius: 10px;
padding: 20px;
gap: 10px;
li {
line-height: 30px;
background-color: #fff;
text-align: center;
border: 1px solid rgba(105, 113, 140, 0.12);
}
}
.live-data {
padding: 0 0 20px 10px;
dl {
margin: 10px 0;
display: flex;
}
dt {
font-weight: bold;
}
}
.live-time {
height: 140px;
font-size: 72px;
text-align: center;
color: var(--main-color);
}
.live-tag__hot {
display: flex;
flex-wrap: wrap;
li {
flex: 0 0 calc(50% - 10px);
}
}
.live-tag__act {
li {
margin: 10px 0;
}
}
.live-talk-content {
max-height: 660px;
overflow-y: auto;
line-height: 24px;
}
</style>
...@@ -11,14 +11,24 @@ import { saveAs } from 'file-saver' ...@@ -11,14 +11,24 @@ import { saveAs } from 'file-saver'
import md5 from 'blueimp-md5' import md5 from 'blueimp-md5'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useLiveChat } from '../composables/useLiveChat' import { useLiveChat } from '../composables/useLiveChat'
import { useSpeechTranscriber } from '../composables/useSpeechTranscriber'
const props = defineProps({ const props = defineProps({
isLocalUpload: { type: Boolean, default: false }, isLocalUpload: { type: Boolean, default: false },
onStart: { type: Function, default: () => {} }, onStart: { type: Function, default: () => {} },
onStop: { type: Function, default: () => {} }, onStop: { type: Function, default: () => {} },
onSentenceEnd: { type: Function, default: () => {} },
onStatsChange: { type: Function, default: () => {} },
}) })
const { messages, viewers, stats, currentTime, start: startChat } = useLiveChat() const { messages, viewers, stats, currentTime, start: startChat } = useLiveChat()
watch(
() => [stats, viewers],
() => {
props.onStatsChange && props.onStatsChange({ ...stats, viewers: viewers.value.length })
},
{ immediate: true, deep: true }
)
const detail = inject('detail') const detail = inject('detail')
...@@ -50,6 +60,9 @@ const { send } = useSocket({ ...@@ -50,6 +60,9 @@ const { send } = useSocket({
} }
}, },
}) })
const { start: startSpeechTranscriber, stop: stopSpeechTranscriber } = useSpeechTranscriber({
onSentenceEnd: props.onSentenceEnd,
})
const { const {
stream, stream,
...@@ -61,8 +74,12 @@ const { ...@@ -61,8 +74,12 @@ const {
currentTime: currentLiveTime, currentTime: currentLiveTime,
} = useLive({ } = useLive({
onStart: () => { onStart: () => {
console.log('开始直播')
props.onStart && props.onStart() props.onStart && props.onStart()
startChat() startChat()
if (!props.isLocalUpload) {
startSpeechTranscriber(stream.value)
}
}, },
onRecord: async (blob) => { onRecord: async (blob) => {
if (props.isLocalUpload) return if (props.isLocalUpload) return
...@@ -75,6 +92,7 @@ const { ...@@ -75,6 +92,7 @@ const {
send(jsonData) send(jsonData)
}, },
onStop: (blob) => { onStop: (blob) => {
console.log('结束直播')
if (enabled.value) return if (enabled.value) return
props.onStop && props.onStop(blob) props.onStop && props.onStop(blob)
handleUpdateRecord({ live_video_size: blob.size.toString() }) handleUpdateRecord({ live_video_size: blob.size.toString() })
...@@ -83,6 +101,7 @@ const { ...@@ -83,6 +101,7 @@ const {
saveAs(blob, `${fileName.value}.mp4`) saveAs(blob, `${fileName.value}.mp4`)
ElMessageBox.alert('请将保存的本地视频文件上传服务端,否则会影响您的实操成绩评价!', '温馨提示') ElMessageBox.alert('请将保存的本地视频文件上传服务端,否则会影响您的实操成绩评价!', '温馨提示')
} }
stopSpeechTranscriber()
}, },
}) })
......
...@@ -46,7 +46,21 @@ const listOptions = { ...@@ -46,7 +46,21 @@ const listOptions = {
return row.live_video_addres ? '已上传' : '未上传' return row.live_video_addres ? '已上传' : '未上传'
}, },
}, },
{ label: '操作', slots: 'table-x', width: 150 }, {
label: '状态',
prop: 'ai_status',
computed({ row }) {
return row.ai_status || '--'
},
},
{
label: '评级',
prop: 'ai_level',
computed({ row }) {
return row.ai_level || '--'
},
},
{ label: '操作', slots: 'table-x', width: 120 },
], ],
} }
......
...@@ -72,7 +72,7 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }: ...@@ -72,7 +72,7 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
// Update currentTime every 100ms while recording // Update currentTime every 100ms while recording
timeUpdateInterval = setInterval(() => { timeUpdateInterval = setInterval(() => {
currentTime.value = Math.floor((Date.now() - startTime.value) / 1000) currentTime.value = Math.floor((Date.now() - startTime.value) / 1000)
}, 100) }, 1000 * 5)
} }
// 录像停止时处理 // 录像停止时处理
......
import axios from 'axios'
import Base64 from 'crypto-js/enc-base64'
import HmacSHA1 from 'crypto-js/hmac-sha1'
import { useWebSocket } from '@vueuse/core'
// https://help.aliyun.com/zh/isi/developer-reference/websocket
export function useSpeechTranscriber({ onSentenceEnd }: { onSentenceEnd?: (result: string, data: any) => void }) {
let audioContext: AudioContext
let audioInput: MediaStreamAudioSourceNode
const token = ref('')
const isTranscriptionStarted = ref(false)
const wsUrl = computed(() => {
return `wss://nls-gateway-cn-shanghai.aliyuncs.com/ws/v1?token=${token.value}`
})
const header = {
appkey: import.meta.env.VITE_APP_KEY,
namespace: 'SpeechTranscriber',
task_id: crypto.randomUUID().replace(/-/g, ''),
message_id: crypto.randomUUID().replace(/-/g, ''),
}
const { send, status, open } = useWebSocket(wsUrl, {
autoConnect: false,
immediate: false,
onConnected: () => {
console.log('语音识别连接成功')
const startTranscriptionMessage = {
header: { ...header, name: 'StartTranscription' },
payload: {
format: 'pcm',
sample_rate: 16000,
enable_intermediate_result: true,
enable_punctuation_prediction: true,
enable_inverse_text_normalization: true,
},
}
send(JSON.stringify(startTranscriptionMessage))
},
onMessage: (ws, event) => {
try {
const data = JSON.parse(event.data)
if (data.header.name === 'TranscriptionStarted') {
isTranscriptionStarted.value = true
}
if (data.header.name === 'SentenceEnd') {
// 一句话结束
console.log(data.payload.result)
onSentenceEnd?.(data.payload.result, data)
}
} catch (e) {
console.log('error', e, event.data)
}
},
onError: (ws, event) => {
console.log('语音识别连接失败', event)
},
onDisconnected: (ws, event) => {
console.log('语音识别连接断开', event)
},
})
const start = async (stream: MediaStream) => {
console.log('开始录音', stream)
token.value = await getToken()
open()
try {
// 创建 AudioContext 并设定采样率
audioContext = new AudioContext({ sampleRate: 16000 })
audioInput = audioContext.createMediaStreamSource(stream)
// 加载 AudioWorkletProcessor 模块
await audioContext.audioWorklet.addModule('/processor.js')
// 创建自定义处理器节点
const audioWorkletNode = new AudioWorkletNode(audioContext, 'pcm-processor')
// 监听自定义消息
audioWorkletNode.port.onmessage = (event) => {
const inputData16 = event.data as ArrayBuffer
if (status.value === 'OPEN' && isTranscriptionStarted.value) {
send(inputData16) // 发送到后端或处理函数
}
}
// 连接音频流到处理器
audioInput.connect(audioWorkletNode)
// 如果你不需要播放,可以不连接 destination
// audioWorkletNode.connect(audioContext.destination);
} catch (e) {
console.error('录音失败: ' + e)
}
}
const stop = () => {
if (audioInput) {
audioInput.disconnect()
}
if (audioContext) {
audioContext.close()
}
const stopTranscriptionMessage = { header: { ...header, name: 'StopTranscription' } }
send(JSON.stringify(stopTranscriptionMessage))
}
return { start, stop }
}
async function getToken() {
const parameters: Record<string, string> = {
AccessKeyId: import.meta.env.VITE_ACCESS_KEY_ID,
Action: 'CreateToken',
Format: 'JSON',
RegionId: 'cn-shanghai',
SignatureMethod: 'HMAC-SHA1',
SignatureNonce: crypto.randomUUID(),
SignatureVersion: '1.0',
Timestamp: new Date().toISOString().replace(/\.\d{3}/, ''),
Version: '2019-02-28',
}
const specialUrlEncode = (text: string) => {
return encodeURIComponent(text).replace(/\+/g, '%20').replace(/\*/g, '%2A').replace(/%7E/g, '~')
}
const encodeParameters = (params: Record<string, string>) => {
return Object.keys(params)
.sort()
.map((key) => `${specialUrlEncode(key)}=${specialUrlEncode(params[key])}`)
.join('&')
}
const canonicalizedQueryString = encodeParameters(parameters)
const stringToSign = 'GET' + '&' + specialUrlEncode('/') + '&' + specialUrlEncode(canonicalizedQueryString)
const signature = Base64.stringify(HmacSHA1(stringToSign, `${import.meta.env.VITE_ACCESS_KEY_SECRET}&`))
const encodedSignature = specialUrlEncode(signature)
const url = `https://nls-meta.cn-shanghai.aliyuncs.com/?Signature=${encodedSignature}&${canonicalizedQueryString}`
const response = await axios.get(url)
return response.data.Token.Id
}
...@@ -12,7 +12,7 @@ const routes: RouteRecordRaw[] = [ ...@@ -12,7 +12,7 @@ const routes: RouteRecordRaw[] = [
children: [ children: [
{ path: '', component: () => import('./views/Index.vue') }, { path: '', component: () => import('./views/Index.vue') },
{ path: 'demo', component: () => import('./views/Demo.vue') }, { path: 'demo', component: () => import('./views/Demo.vue') },
{ path: 'view', component: () => import('./views/Demo.vue'), props: { isView: true } }, { path: 'view', component: () => import('./views/View.vue') },
], ],
}, },
{ {
......
<script setup> <script setup>
import Live from '../components/Live.vue' import Demo from '../components/Demo.vue'
import LivePlayback from '../components/LivePlayback.vue'
import { getTest, getRecord } from '../api'
import { useCountdown } from '@/composables/useCountdown'
const props = defineProps({
isView: { type: Boolean, default: false },
})
const route = useRoute() const route = useRoute()
const id = route.query.id
const { timeLeft, formattedTime, stop, reset } = useCountdown({
// 倒计时结束
onEnd: () => {
live.value?.stop()
},
})
const live = ref(null)
const detail = ref(null)
provide('detail', detail)
const duration = computed(() => {
return parseInt(detail.value?.duration) * 60 || 0
})
const isLocalUpload = computed(() => {
return detail.value?.upload_way == 2
})
async function fetchInfo() {
const res = await getTest({ id: route.query.id })
detail.value = res.data.detail
timeLeft.value = duration.value
}
watchEffect(() => {
!props.isView && fetchInfo()
})
// 商品卖点
const hotList = computed(() => {
return detail.value?.live_speech.selling_point.split(/;|;/)
})
// 营销活动
const actList = computed(() => {
return detail.value?.live_speech.marketing_campaign.split(/;|;/)
})
// 直播记录数据
const record = ref(null)
provide('record', record)
// 直播数据
const stats = computed(() => {
const result = { totalViewers: 0, peakViewers: 0, totalGifts: 0, totalLikes: 0, totalGiftViewers: 0 }
return Object.assign(result, record.value?.live_info.stats)
})
const fetchRecord = async () => {
const res = await getRecord({ id: route.query.record_id })
const resDetail = res.data.detail
record.value = { ...resDetail, live_info: JSON.parse(resDetail.live_info) }
detail.value = resDetail.live_practice_info
timeLeft.value = duration.value
}
onMounted(() => {
props.isView && fetchRecord()
})
</script> </script>
<template> <template>
<AppCard :title="isView ? '查看历史直播详情' : '直播'" full> <AppCard title="直播" full>
<div class="live-row"> <Demo :id="id" />
<div class="live-col">
<template v-if="isView">
<LivePlayback :record="record" v-if="record"></LivePlayback>
</template>
<template v-else>
<Live
ref="live"
:isLocalUpload="isLocalUpload"
:onStart="() => reset(duration)"
:onStop="stop"
v-if="detail" />
</template>
</div>
<div class="live-col" style="flex: 1">
<h2 class="h2-title">直播话术</h2>
<div class="live-talk-content" v-html="detail?.live_speech.content"></div>
</div>
<div class="live-col" style="width: 350px">
<div class="live-col-box" v-if="isView">
<h2 class="h2-title">直播数据</h2>
<div class="live-data">
<dl>
<dt>观众总人数:</dt>
<dd>{{ stats.totalViewers }}</dd>
</dl>
<dl>
<dt>最高峰人数:</dt>
<dd>{{ stats.peakViewers }}</dd>
</dl>
<dl>
<dt>点赞数:</dt>
<dd>{{ stats.totalLikes }}</dd>
</dl>
<dl>
<dt>刷礼物人数:</dt>
<dd>{{ stats.totalGiftViewers }}</dd>
</dl>
<dl>
<dt>刷礼物总数:</dt>
<dd>{{ stats.totalGifts }}</dd>
</dl>
</div>
</div>
<div class="live-col-box" v-else>
<h2 class="h2-title">倒计时</h2>
<h3 class="live-time">{{ formattedTime }}</h3>
</div>
<div class="live-col-box">
<h2 class="h2-title">主题卖点</h2>
<ul class="live-tag live-tag__hot">
<li v-for="item in hotList" :key="item">{{ item }}</li>
</ul>
</div>
<div class="live-col-box">
<h2 class="h2-title">营销活动</h2>
<ul class="live-tag live-tag__act">
<li v-for="item in actList" :key="item">{{ item }}</li>
</ul>
</div>
</div>
</div>
</AppCard> </AppCard>
</template> </template>
<style lang="scss">
.live-row {
height: 100%;
display: flex;
gap: 20px;
}
.live-col {
padding: 20px;
border-radius: 10px;
border: 1px solid #eee;
.h2-title {
margin-top: 0;
}
}
.live-tag {
margin: 20px 0;
background-color: #eee;
border-radius: 10px;
padding: 20px;
gap: 10px;
li {
line-height: 30px;
background-color: #fff;
text-align: center;
border: 1px solid rgba(105, 113, 140, 0.12);
}
}
.live-data {
padding: 0 0 20px 10px;
dl {
margin: 10px 0;
display: flex;
}
dt {
font-weight: bold;
}
}
.live-time {
height: 140px;
font-size: 72px;
text-align: center;
color: var(--main-color);
}
.live-tag__hot {
display: flex;
flex-wrap: wrap;
li {
flex: 0 0 calc(50% - 10px);
}
}
.live-tag__act {
li {
margin: 10px 0;
}
}
.live-talk-content {
max-height: 660px;
overflow-y: auto;
line-height: 24px;
}
</style>
<script setup>
import VueMarkdown from 'vue-markdown-render'
import Demo from '../components/Demo.vue'
import { getRecord } from '../api'
const route = useRoute()
const id = route.query.id
const recordId = route.query.record_id
const dialogVisible = ref(false)
const detail = ref(null)
const fetchRecord = async () => {
const res = await getRecord({ id: recordId })
const resDetail = res.data.detail
detail.value = { ...resDetail, live_info: JSON.parse(resDetail.live_info) }
}
onMounted(() => {
fetchRecord()
})
const tipsList = {
A: [
{ title: '卖点讲解', content: '强化产品独特性与情感场景化表达。' },
{ title: '违规情况', content: '关注平台新规并规避敏感词。' },
{ title: '讲解时长', content: '精准分配时间,促销环节压缩冗余。' },
{ title: '其他建议', content: '模拟实战并复盘转化数据。' },
],
B: [
{ title: '卖点讲解', content: '场景化设计减少参数罗列。' },
{ title: '违规情况', content: '熟记合规词库并规避竞品对比。' },
{ title: '讲解时长', content: '控制节奏,促销环节提速。' },
{ title: '其他建议', content: '学习高感染力话术并准备互动模板。' },
],
C: [
{ title: '卖点讲解', content: '结构化表达并突出核心卖点。' },
{ title: '违规情况', content: '系统学习规则并使用AI审核。' },
{ title: '讲解时长', content: '分段练习并制定时间分配表。' },
{ title: '其他建议', content: '参加基础培训并拆解优秀案例。' },
],
D: [
{ title: '卖点讲解', content: '模板化练习并记忆关键词。' },
{ title: '违规情况', content: '精读合规手册并替换敏感词。' },
{ title: '讲解时长', content: '分段训练并使用计时器。' },
{ title: '其他建议', content: '录制回放并建立反馈机制。' },
],
}
const tips = computed(() => {
return tipsList[detail.value?.ai_level || 'A']
})
function formatDuration(seconds) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = (seconds % 60).toFixed(2)
return minutes > 0 ? `${minutes}m${remainingSeconds}s` : `${remainingSeconds}s`
}
</script>
<template>
<div class="live-view" v-if="detail">
<AppCard>
<div class="live-info">
<div class="live-info-header">
<h2>我的练习表现</h2>
<p>评分等级</p>
<h3>{{ detail.ai_level || '--' }}</h3>
<p>主播完成质量良好,需要关注细节勤加练习</p>
</div>
<div class="live-info-item">
<div>
<p>主播姓名</p>
<p>{{ detail.created_operator.real_name || detail.created_operator.nickname }}</p>
</div>
<div>
<p>直播时长</p>
<p>{{ formatDuration(detail.live_duration) }}</p>
</div>
<div>
<p>开始时间</p>
<p>{{ detail.live_start_time }}</p>
</div>
<div>
<p>结束时间</p>
<p>{{ detail.live_end_time }}</p>
</div>
<div>
<p>视频状态</p>
<p>已上传</p>
</div>
<div>
<p>观众总人数</p>
<p>{{ detail.live_info.stats.totalViewers }}</p>
</div>
<div>
<p>最高峰人数</p>
<p>{{ detail.live_info.stats.peakViewers }}</p>
</div>
<div>
<p>点赞数</p>
<p>{{ detail.live_info.stats.totalLikes }}</p>
</div>
<div>
<p>刷礼物人数</p>
<p>{{ detail.live_info.stats.totalGiftViewers }}</p>
</div>
<div>
<p>刷礼物总数</p>
<p>{{ detail.live_info.stats.totalGifts }}</p>
</div>
<div>
<p>下单量</p>
<p>{{ detail.orders_count }}</p>
</div>
<div>
<p>操作</p>
<el-button type="primary" link @click="dialogVisible = true">查看</el-button>
</div>
</div>
</div>
</AppCard>
<AppCard>
<h2 class="live-title">练习优化建议</h2>
<div v-for="(item, index) in tips" :key="index">
<p class="live-tips">
<b>{{ item.title }}</b> {{ item.content }}
</p>
</div>
</AppCard>
<AppCard>
<h2 class="live-title">违规情况</h2>
<div class="live-markdown">
<VueMarkdown :source="detail.illegal_word" v-if="detail.illegal_word" />
</div>
</AppCard>
<AppCard>
<h2 class="live-title">卖点讲解</h2>
<el-steps direction="vertical" :active="1">
<el-step
v-for="(item, index) in detail.words"
:key="index"
:title="item.name"
:status="item.time ? 'success' : 'error'">
<template #description> {{ item.time }} {{ item.subtitle }} </template>
</el-step>
</el-steps>
</AppCard>
<AppCard>
<h2 class="live-title">产生订单</h2>
<el-steps direction="vertical" :active="1">
<el-step
v-for="(item, index) in detail.words"
:key="index"
:title="item.name"
:status="item.time ? 'success' : 'error'">
<template #description> {{ item.time }} {{ item.subtitle }} </template>
</el-step>
</el-steps>
</AppCard>
<el-dialog v-model="dialogVisible" title="直播回放" width="1200px">
<div style="height: 740px">
<Demo :id="id" :recordId="recordId" :isView="true" />
</div>
</el-dialog>
</div>
</template>
<style lang="scss">
.live-view {
.live-info {
display: flex;
gap: 12px;
p {
font-size: 14px;
color: #525252;
margin: 10px 0;
}
.live-info-header {
display: flex;
flex-direction: column;
border-right: 1px solid #e5e5e5;
padding-right: 12px;
h2 {
font-size: 24px;
font-weight: bold;
}
h3 {
font-size: 40px;
font-weight: bold;
}
}
.live-info-item {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 12px;
div {
width: 180px;
}
}
}
.live-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 20px;
}
.live-tips {
margin-top: 10px;
font-size: 14px;
color: #000;
b {
font-size: 14px;
font-weight: bold;
margin-right: 20px;
}
}
.el-step__description {
margin-bottom: 20px;
}
.live-markdown {
line-height: 1.8;
li {
list-style: disc;
margin-left: 20px;
}
p {
margin: 10px 0;
}
strong,
b {
font-weight: bold;
}
}
}
</style>
...@@ -342,11 +342,24 @@ const adminMenus: IMenuItem[] = [ ...@@ -342,11 +342,24 @@ const adminMenus: IMenuItem[] = [
path: '/live', path: '/live',
icon: markRaw(IconLive), icon: markRaw(IconLive),
children: [ children: [
{ id: 21, name: '商品品类管理', path: '/live/product/category', icon: markRaw(IconLiveProductCategory) }, {
{ id: 22, name: '商品属性管理', path: '/live/product/attr', icon: markRaw(IconLiveProductAttr) }, id: 21,
{ id: 23, name: '商品管理', path: '/live/product/management', icon: markRaw(IconLiveProductManagement) }, name: '商品品类管理',
path: '/live/product/category',
icon: markRaw(IconLiveProductCategory),
role: [5, 6],
},
{ id: 22, name: '商品属性管理', path: '/live/product/attr', icon: markRaw(IconLiveProductAttr), role: [5, 6] },
{
id: 23,
name: '商品管理',
path: '/live/product/management',
icon: markRaw(IconLiveProductManagement),
role: [5, 6],
},
{ id: 24, name: '直播话术管理', path: '/live/talk', icon: markRaw(IconLiveTalk) }, { id: 24, name: '直播话术管理', path: '/live/talk', icon: markRaw(IconLiveTalk) },
{ id: 25, name: '直播', path: '/live/test', icon: markRaw(IconLiveTest) }, { id: 25, name: '直播', path: '/live/test', icon: markRaw(IconLiveTest) },
{ id: 201, name: '订单管理', path: '/live/order', icon: markRaw(IconCard) },
], ],
}, },
{ {
...@@ -372,13 +385,14 @@ export const useMenuStore = defineStore({ ...@@ -372,13 +385,14 @@ export const useMenuStore = defineStore({
getters: { getters: {
menus: (state) => { menus: (state) => {
const userStore = useUserStore() const userStore = useUserStore()
const userRole = userStore.role?.id const userRole = userStore.role?.id || 1
const userPermissions = userStore.menus || [] const userPermissions = userStore.menus || []
// 递归过滤菜单及其子菜单 // 递归过滤菜单及其子菜单
const filterMenus = (menus: IMenuItem[]): IMenuItem[] => { const filterMenus = (menus: IMenuItem[]): IMenuItem[] => {
return menus return menus
.filter((menu) => userPermissions.some((perm) => perm.id === menu.id)) .filter((menu) => userPermissions.some((perm) => perm.id === menu.id))
.filter((menu) => menu.role ? menu.role?.includes(userRole) : true)
.map((menu) => { .map((menu) => {
const filteredMenu: IMenuItem = { const filteredMenu: IMenuItem = {
...menu, ...menu,
......
...@@ -8,6 +8,7 @@ export interface IMenuItem { ...@@ -8,6 +8,7 @@ export interface IMenuItem {
studentPath?: string studentPath?: string
icon?: Component icon?: Component
children?: IMenuItem[] children?: IMenuItem[]
role?: number[]
} }
// 用户信息 // 用户信息
......
...@@ -50,11 +50,11 @@ export default defineConfig(({ mode }) => ({ ...@@ -50,11 +50,11 @@ export default defineConfig(({ mode }) => ({
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/qianfan/, ''), rewrite: (path) => path.replace(/^\/api\/qianfan/, ''),
}, },
'/api/lab': { // '/api/lab': {
target: 'http://local-com-resource-api.frontend.ezijing.com', // target: 'http://local-com-resource-api.frontend.ezijing.com',
changeOrigin: true, // changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/lab/, ''), // rewrite: (path) => path.replace(/^\/api\/lab/, ''),
}, // },
'/api': 'https://saas-dml.ezijing.com', '/api': 'https://saas-dml.ezijing.com',
}, },
}, },
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论