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

chore: update

上级 636c5107
...@@ -38,6 +38,7 @@ ...@@ -38,6 +38,7 @@
"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/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",
"@vue-macros/reactivity-transform": "^1.1.3", "@vue-macros/reactivity-transform": "^1.1.3",
...@@ -2288,6 +2289,13 @@ ...@@ -2288,6 +2289,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/file-saver": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
......
...@@ -45,6 +45,7 @@ ...@@ -45,6 +45,7 @@
"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/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",
"@vue-macros/reactivity-transform": "^1.1.3", "@vue-macros/reactivity-transform": "^1.1.3",
......
...@@ -14,8 +14,8 @@ watchEffect(() => { ...@@ -14,8 +14,8 @@ watchEffect(() => {
<template> <template>
<el-cascader <el-cascader
clearable clearable
:options="options"
placeholder="请选择" placeholder="请选择"
style="width: 100%" style="width: 100%"
:props="{ label: 'name', value: 'id', emitPath: false, checkStrictly: true }" /> :options="options"
:props="{ label: 'name', value: 'id', emitPath: false, checkStrictly: true, expandTrigger: 'hover' }" />
</template> </template>
...@@ -58,7 +58,7 @@ export function useCountdown({ initialTime = 0, autoStart = false, onEnd }: Coun ...@@ -58,7 +58,7 @@ export function useCountdown({ initialTime = 0, autoStart = false, onEnd }: Coun
const reset = (newTime: number = initialTime) => { const reset = (newTime: number = initialTime) => {
stop() stop()
timeLeft.value = newTime timeLeft.value = newTime
if (autoStart) start() start()
} }
// 组件卸载时清除定时器 // 组件卸载时清除定时器
......
import { useWebSocket, type UseWebSocketOptions } from '@vueuse/core'
import { useUserStore } from '@/stores/user'
export function useSocket(options: UseWebSocketOptions) {
const userStore = useUserStore()
const ssoId = userStore.user?.id
const defaultOptions = {
autoReconnect: true,
onConnected: () => {
send(JSON.stringify({ type: 'login', sso_id: ssoId }))
},
heartbeat: { message: JSON.stringify({ type: 'health', sso_id: ssoId }), interval: 1000 * 50, pongTimeout: 1000 },
}
const { status, data, send, open, close } = useWebSocket('wss://saas-lab-api.ezijing.com/wss', {
...defaultOptions,
...options,
})
return { status, data, send, open, close }
}
...@@ -66,7 +66,6 @@ async function handleUpdate() { ...@@ -66,7 +66,6 @@ async function handleUpdate() {
</el-form-item> </el-form-item>
<el-form-item label="所属商品品类" prop="live_commodity_type_id"> <el-form-item label="所属商品品类" prop="live_commodity_type_id">
<LiveProductCategory v-model="form.live_commodity_type_id" style="width: 100%" placeholder="请选择" /> <LiveProductCategory v-model="form.live_commodity_type_id" style="width: 100%" placeholder="请选择" />
<!-- <el-select v-model="form.live_commodity_type_id" style="width: 100%" placeholder="请选择"> </el-select> -->
</el-form-item> </el-form-item>
<el-form-item label="重要性" prop="is_importance"> <el-form-item label="重要性" prop="is_importance">
<el-radio-group v-model="form.is_importance" :disabled="isUpdate"> <el-radio-group v-model="form.is_importance" :disabled="isUpdate">
......
...@@ -66,7 +66,7 @@ const handleRemove = async (row) => { ...@@ -66,7 +66,7 @@ const handleRemove = async (row) => {
<el-button type="primary" @click="handleAdd()">新增类别</el-button> <el-button type="primary" @click="handleAdd()">新增类别</el-button>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
<el-button text type="primary" @click="handleAdd({ pid: row.id })">新增</el-button> <el-button text type="primary" @click="handleAdd({ pid: row.id })" v-if="row.level != 4">新增</el-button>
<el-button text type="primary" @click="handleUpdate(row)">编辑</el-button> <el-button text type="primary" @click="handleUpdate(row)">编辑</el-button>
<el-button text type="primary" @click="handleRemove(row)">删除</el-button> <el-button text type="primary" @click="handleRemove(row)">删除</el-button>
</template> </template>
......
...@@ -58,12 +58,12 @@ const handleSelect = (item) => { ...@@ -58,12 +58,12 @@ const handleSelect = (item) => {
</p> </p>
<el-cascader-panel <el-cascader-panel
v-model="form.live_commodity_type_id" v-model="form.live_commodity_type_id"
:options="options"
:props="{ label: 'name', value: 'id', emitPath: false }"
placeholder="请输入关键词搜索商品分类"
size="large"
filterable filterable
clearable clearable
placeholder="请输入关键词搜索商品分类"
size="large"
:options="options"
:props="{ label: 'name', value: 'id', emitPath: false, expandTrigger: 'hover' }"
style="width: 100%" /> style="width: 100%" />
</el-form-item> </el-form-item>
</div> </div>
......
...@@ -29,13 +29,8 @@ const { open, onChange } = useFileDialog({ ...@@ -29,13 +29,8 @@ const { open, onChange } = useFileDialog({
onChange(async (files) => { onChange(async (files) => {
const [file] = files const [file] = files
const res = await upload(file) const res = await upload(file)
const result = { const nowTime = dayjs().format('YYYY-MM-DD HH:mm:ss')
name: file.name, const result = { name: file.name, size: file.size, type: file.type, url: res, upload_time: nowTime }
size: file.size,
type: file.type,
url: res,
upload_time: dayjs().format('YYYY-MM-DD HH:mm:ss'),
}
if (typeof props.modelValue === 'string') { if (typeof props.modelValue === 'string') {
emit('update:modelValue', result.url) emit('update:modelValue', result.url)
} else { } else {
...@@ -45,7 +40,7 @@ onChange(async (files) => { ...@@ -45,7 +40,7 @@ onChange(async (files) => {
} }
}) })
const handleOpen = (i) => { const handleOpen = (i = 0) => {
index.value = i index.value = i
open() open()
} }
...@@ -74,9 +69,11 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips ...@@ -74,9 +69,11 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips
</a> </a>
</template> </template>
<template v-else> <template v-else>
<div class="upload-item__title">{{ getTitle(i) }}</div> <div class="upload-item-uploader" @click="handleOpen(i)">
<i class="el-icon"><Plus /></i> <div class="upload-item__title">{{ getTitle(i) }}</div>
<div class="upload-item__tips">{{ getTips(i) }}</div> <i class="el-icon"><Plus /></i>
<div class="upload-item__tips">{{ getTips(i) }}</div>
</div>
</template> </template>
</div> </div>
</template> </template>
...@@ -87,7 +84,7 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips ...@@ -87,7 +84,7 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips
<template v-else> <template v-else>
<el-popover placement="top" width="160px" trigger="hover"> <el-popover placement="top" width="160px" trigger="hover">
<ul class="upload-popover"> <ul class="upload-popover">
<li @click="handleOpen(0)"> <li @click="handleOpen">
<i class="el-icon"><UploadFilled /></i>本地上传 <i class="el-icon"><UploadFilled /></i>本地上传
</li> </li>
<li v-if="!isVideo"> <li v-if="!isVideo">
...@@ -95,7 +92,7 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips ...@@ -95,7 +92,7 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips
</li> </li>
</ul> </ul>
<template #reference> <template #reference>
<div class="upload-item" :style="{ height: props.itemHeight, width: props.itemHeight }"> <div class="upload-item">
<template v-if="props.modelValue"> <template v-if="props.modelValue">
<a :href="props.modelValue" target="_blank"> <a :href="props.modelValue" target="_blank">
<video :src="props.modelValue" v-if="isVideo"></video> <video :src="props.modelValue" v-if="isVideo"></video>
...@@ -103,9 +100,11 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips ...@@ -103,9 +100,11 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips
</a> </a>
</template> </template>
<template v-else> <template v-else>
<div class="upload-item__title">{{ props.firstTitle || props.title }}</div> <div class="upload-item-uploader" @click="handleOpen">
<i class="el-icon"><Plus /></i> <div class="upload-item__title">{{ props.firstTitle || props.title }}</div>
<div class="upload-item__tips">{{ props.firstTips || props.tips }}</div> <i class="el-icon"><Plus /></i>
<div class="upload-item__tips">{{ props.firstTips || props.tips }}</div>
</div>
</template> </template>
</div> </div>
</template> </template>
...@@ -139,6 +138,7 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips ...@@ -139,6 +138,7 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
text-align: center;
width: 100px; width: 100px;
height: v-bind(itemHeight); height: v-bind(itemHeight);
border: 1px dashed var(--el-border-color-darker); border: 1px dashed var(--el-border-color-darker);
...@@ -154,8 +154,9 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips ...@@ -154,8 +154,9 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips
} }
&__title { &__title {
position: absolute; position: absolute;
left: 0;
top: 0; top: 0;
width: 100%; right: 0;
background-color: var(--main-color); background-color: var(--main-color);
font-size: 12px; font-size: 12px;
color: #fff; color: #fff;
...@@ -165,5 +166,13 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips ...@@ -165,5 +166,13 @@ const getTips = (i) => (i === 0 && props.firstTips) || props.tips
&__tips { &__tips {
font-size: 12px; font-size: 12px;
} }
&-uploader {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
} }
</style> </style>
...@@ -38,3 +38,26 @@ export function getTalkList(params?: { ...@@ -38,3 +38,26 @@ export function getTalkList(params?: {
}) { }) {
return httpRequest.get('/api/lab/v1/experiment/live-speeches/list', { params }) return httpRequest.get('/api/lab/v1/experiment/live-speeches/list', { params })
} }
// 更新实验直播练习详情
export function saveTestRecord(data: {
id?: string
live_practice_id: string
live_start_time: string
live_end_time: string
live_duration?: string
live_video_addres?: string
live_info?: string
}) {
return httpRequest.post('/api/lab/v1/experiment/live-practice/save-live-practice-record', data)
}
// 获取直播训练记录列表
export function getRecordList(params: { live_practice_id: string }) {
return httpRequest.get('/api/lab/v1/experiment/live-practice/live-practice-records', { params })
}
// 获取直播训练记录列表
export function getRecord(params: { id: string }) {
return httpRequest.get('/api/lab/v1/experiment/live-practice/live-practice-record', { params })
}
<script setup> <script setup>
import { useUserStore } from '@/stores/user'
import LiveCover from './LiveCover.vue' import LiveCover from './LiveCover.vue'
import LiveStream from './LiveStream.vue' import { ElMessage } from 'element-plus'
import LivePlayback from './LivePlayback.vue' import { useFileDialog } from '@vueuse/core'
import { ref, watch, defineExpose, defineProps } from 'vue' import { useUserStore } from '@/stores/user'
import { upload } from '@/utils/upload'
import { useLive, readBlobAsBase64 } from '../composables/useLive'
import { useSocket } from '@/composables/useSocket'
import { saveTestRecord, getRecord } from '../api'
import { saveAs } from 'file-saver'
import md5 from 'blueimp-md5'
import dayjs from 'dayjs'
import { useLiveChat } from '../composables/useLiveChat'
const props = defineProps({ const props = defineProps({
isView: { 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: () => {} },
}) })
const { messages, viewers, stats, currentTime, start: startChat } = useLiveChat()
const detail = inject('detail')
const userName = computed(() => {
const user = detail.value?.current_user || {}
return user.real_name || user.nickname || user.username
})
const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
const live = ref(null) const ssoId = userStore.user?.id
const enabled = ref(false)
const toggleLive = () => { const recordId = ref(route.query.record_id || '')
if (enabled.value) {
live.value?.start() const fileUrl = ref('')
props.onStart?.() const fileName = computed(() => md5(`${ssoId}${startTime.value}`))
} else {
live.value?.stop() // WebSocket 设置
props.onStop?.() const { send } = useSocket({
onMessage: (ws, event) => {
try {
const data = JSON.parse(event.data)
if (data?.type === 'video_rtc') {
fileUrl.value = data.data.uri
}
} catch (error) {
console.error('Failed to parse message:', error)
}
},
})
const {
stream,
start: startLive,
stop: stopLive,
startTime,
endTime,
duration,
currentTime: currentLiveTime,
} = useLive({
onStart: () => {
props.onStart && props.onStart()
startChat()
},
onRecord: async (blob) => {
if (props.isLocalUpload) return
const base64Data = await readBlobAsBase64(blob)
const jsonData = JSON.stringify({
type: 'send',
sso_id: ssoId,
data: { type: 'video_rtc', channel: 'rtc', data: { video: base64Data, file_name: fileName.value } },
})
send(jsonData)
},
onStop: (blob) => {
props.onStop && props.onStop(blob)
handleUpdateRecord()
// 保存录像到本地
if (props.isLocalUpload) saveAs(blob, `${fileName.value}.mp4`)
},
})
watch(currentLiveTime, () => {
currentTime.value = currentLiveTime.value
})
const record = ref({})
const handleGetRecord = async () => {
if (!recordId.value) return
const res = await getRecord({ id: recordId.value })
record.value = res?.data.detail
}
watchEffect(() => {
handleGetRecord()
})
// 更新录像记录
const handleUpdateRecord = async (params) => {
const defaultParams = {
live_practice_id: route.query.id,
live_start_time: dayjs(startTime.value).format('YYYY-MM-DD HH:mm:ss'),
live_end_time: dayjs(endTime.value).format('YYYY-MM-DD HH:mm:ss'),
live_duration: duration.value.toString(),
live_video_addres: fileUrl.value,
live_info: JSON.stringify({ messages: messages.value, stats }),
} }
const res = await saveTestRecord({ ...defaultParams, ...params })
recordId.value = res?.data.id
} }
// 上传视频
const handleUpload = () => {
const dialog = useFileDialog()
dialog.open({ accept: 'video/mp4' })
dialog.onChange(async ([file]) => {
const res = await upload(file)
handleUpdateRecord({ id: recordId.value, live_video_addres: res })
ElMessage.success('上传成功')
})
}
const video = ref(null)
watchEffect(() => {
if (video.value) {
video.value.srcObject = stream.value
}
})
const enabled = ref(false)
const toggleLive = () => {
enabled.value ? startLive() : stopLive()
}
watch(enabled, toggleLive) watch(enabled, toggleLive)
function start() { function start() {
if (live.value) enabled.value = true if (video.value) enabled.value = true
} }
function stop() { function stop() {
if (live.value) enabled.value = false if (video.value) {
enabled.value = false
}
} }
defineExpose({ enabled, start, stop }) defineExpose({ enabled, start, stop })
</script> </script>
<template> <template>
<div class="live"> <div class="live">
<div class="live-hd"> <div class="live-hd">
<p>主播:{{ userStore.user.name }}</p> <p>主播:{{ userName }}</p>
<img :src="enabled ? '/live/live2.png' : '/live/live1.png'" v-if="!isView" alt="直播状态" class="live-icon" /> <img :src="enabled ? '/live/live2.png' : '/live/live1.png'" alt="直播状态" class="live-icon" />
</div> </div>
<div class="live-bd"> <div class="live-bd">
<component :is="isView ? LivePlayback : LiveStream" ref="live" /> <video muted autoplay ref="video"></video>
<LiveCover v-if="enabled" /> <LiveCover :messages="messages" :stats="stats" :viewers="viewers" v-if="enabled" />
</div> </div>
<div class="live-ft" v-if="!isView"> <div class="live-ft">
<el-button type="primary" size="large" round style="width: 80%" @click="enabled ? stop() : start()"> <el-button
type="primary"
size="large"
round
style="width: 80%"
@click="enabled ? stop() : start()"
v-if="!recordId">
{{ enabled ? '结束直播练习' : '开始练习' }} {{ enabled ? '结束直播练习' : '开始练习' }}
</el-button> </el-button>
<el-button
type="primary"
size="large"
round
style="width: 80%; margin: 10px 0 0 0"
@click="handleUpload"
v-if="isLocalUpload && recordId"
>上传</el-button
>
</div> </div>
</div> </div>
</template> </template>
...@@ -63,6 +186,7 @@ defineExpose({ enabled, start, stop }) ...@@ -63,6 +186,7 @@ defineExpose({ enabled, start, stop })
&-hd { &-hd {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: 10px;
.live-icon { .live-icon {
height: 20px; height: 20px;
...@@ -71,14 +195,19 @@ defineExpose({ enabled, start, stop }) ...@@ -71,14 +195,19 @@ defineExpose({ enabled, start, stop })
&-bd { &-bd {
position: relative; position: relative;
margin: 10px 0;
height: 667px; height: 667px;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background-color: #000; background-color: #000;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
} }
&-ft { &-ft {
margin-top: 10px;
text-align: center; text-align: center;
} }
} }
......
<script setup> <script setup>
import { useUserStore } from '@/stores/user' const props = defineProps(['viewers', 'messages', 'stats'])
const userStore = useUserStore()
const currentViewers = computed(() => props.viewers.slice(0, 3))
const detail = inject('detail')
const messageRef = ref()
function scrollToBottom() {
if (!messageRef.value) return
messageRef.value.scrollTo(0, messageRef.value.scrollHeight)
}
watch(props.messages, () => nextTick(() => scrollToBottom()))
const userName = computed(() => {
const user = detail.value?.current_user || {}
return user.real_name || user.nickname || user.username
})
const product = computed(() => {
return detail.value?.live_commodity || {}
})
const productUrl = computed(() => {
if (!detail.value) return '/live/product_bg.png'
const [file] = JSON.parse(product.value.picture_addreses)
return file.url
})
</script> </script>
<template> <template>
...@@ -11,16 +36,14 @@ const userStore = useUserStore() ...@@ -11,16 +36,14 @@ const userStore = useUserStore()
<div class="headerTop"> <div class="headerTop">
<div class="userPanel"> <div class="userPanel">
<div style="width: 88px; margin-right: 6px; margin-left: 10px"> <div style="width: 88px; margin-right: 6px; margin-left: 10px">
<div class="whiteText">{{ userStore.user.name }}</div> <div class="whiteText">{{ userName }}</div>
<div class="likeTip">66本场点赞</div> <div class="likeTip">{{ stats.totalLikes }}本场点赞</div>
</div> </div>
<div class="followButton">关注</div> <div class="followButton">关注</div>
</div> </div>
<div style="display: flex; align-items: center"> <div style="display: flex; align-items: center">
<img src="/live/avatar/1.png" class="smallAvatar" /><img src="/live/avatar/2.png" class="smallAvatar" /><img <img :src="item.avatar" class="smallAvatar" v-for="item in currentViewers" :key="item.id" />
src="/live/avatar/3.png" <div class="grayPanel" style="margin-right: 4px">{{ viewers.length }}</div>
class="smallAvatar" />
<div class="grayPanel" style="margin-right: 4px">50</div>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
<g <g
stroke="#fff" stroke="#fff"
...@@ -99,10 +122,19 @@ const userStore = useUserStore() ...@@ -99,10 +122,19 @@ const userStore = useUserStore()
d="M34.0245 12.5077L36.1695 14.6527C36.8464 15.3296 36.8464 16.4272 36.1695 17.1041L29.2728 23.9996L36.1695 30.8959C36.8464 31.5728 36.8464 32.6704 36.1695 33.3473L34.0245 35.4923C33.3475 36.1692 32.25 36.1692 31.5731 35.4923L24.6749 28.5931L17.7838 35.4898C17.1069 36.1667 16.0093 36.1667 15.3324 35.4898L13.1874 33.3448C12.5105 32.6679 12.5105 31.5703 13.1874 30.8934L20.077 23.9996L13.1874 17.1066C12.5105 16.4297 12.5105 15.3321 13.1874 14.6552L15.3324 12.5102C16.0093 11.8333 17.1069 11.8333 17.7838 12.5102L24.6749 19.4017L31.5731 12.5077C32.25 11.8308 33.3475 11.8308 34.0245 12.5077Z"></path> d="M34.0245 12.5077L36.1695 14.6527C36.8464 15.3296 36.8464 16.4272 36.1695 17.1041L29.2728 23.9996L36.1695 30.8959C36.8464 31.5728 36.8464 32.6704 36.1695 33.3473L34.0245 35.4923C33.3475 36.1692 32.25 36.1692 31.5731 35.4923L24.6749 28.5931L17.7838 35.4898C17.1069 36.1667 16.0093 36.1667 15.3324 35.4898L13.1874 33.3448C12.5105 32.6679 12.5105 31.5703 13.1874 30.8934L20.077 23.9996L13.1874 17.1066C12.5105 16.4297 12.5105 15.3321 13.1874 14.6552L15.3324 12.5102C16.0093 11.8333 17.1069 11.8333 17.7838 12.5102L24.6749 19.4017L31.5731 12.5077C32.25 11.8308 33.3475 11.8308 34.0245 12.5077Z"></path>
</svg> </svg>
</div> </div>
<img src="/live/product_bg.png" style="width: 106px; height: 106px" /> <img :src="productUrl" style="width: 106px; height: 106px" />
<div class="title">手机</div> <div class="title">{{ product.title }}</div>
</div>
</div>
<div class="message" ref="messageRef">
<div class="message-item" v-for="item in messages" :key="item.id">
<div class="message-item-inner">
<span class="message-item__name"> {{ item.user.name }}: </span>
<span class="message-item__content">{{ item.content }}</span>
</div>
</div> </div>
</div> </div>
<slot />
</div> </div>
</template> </template>
...@@ -259,6 +291,39 @@ const userStore = useUserStore() ...@@ -259,6 +291,39 @@ const userStore = useUserStore()
font-size: 12px; font-size: 12px;
padding: 6px; padding: 6px;
width: 100%; width: 100%;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
.message {
position: absolute;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
max-height: 200px;
background: linear-gradient(180deg, transparent 100%, rgba(0, 0, 0, 0.24) 0);
z-index: 120;
margin: 14px 0;
&-item {
font-size: 13px;
margin: 4px 10px;
&-inner {
display: inline-block;
border-radius: 120px;
padding: 5px;
background: #00000033;
}
&__name {
color: #8ce7ff;
cursor: pointer;
}
&__content {
color: #fff;
} }
} }
} }
......
<script setup></script> <script setup>
import LiveCover from './LiveCover.vue'
import { useLiveChat } from '../composables/useLiveChat'
const detail = inject('detail')
const record = inject('record')
const { messages, viewers, stats, currentTime } = useLiveChat({
defaultMessages: record.value.live_info.messages,
})
const userName = computed(() => {
const user = detail.value?.current_user || {}
return user.real_name || user.nickname || user.username
})
const video = ref(null)
const playing = ref(false)
onMounted(() => {
video.value.ontimeupdate = () => {
currentTime.value = Math.floor(video.value?.currentTime) || 0
}
video.value.onplay = () => {
playing.value = true
}
video.value.onpause = () => {
playing.value = false
}
video.value.onended = () => {
playing.value = false
}
})
const togglePlay = () => {
playing.value ? video.value?.pause() : video.value?.play()
}
</script>
<template> <template>
<video controls autoplay ref="video" class="live-stream-video"></video> <div class="live">
<div class="live-hd">
<p>主播:{{ userName }}</p>
</div>
<div class="live-bd">
<video :src="record.live_video_addres" ref="video"></video>
<LiveCover :messages="messages" :stats="stats" :viewers="viewers">
<div class="play-button" @click="togglePlay">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="none"
viewBox="0 0 32 32"
v-if="!playing">
<g clip-path="url(#play-fill_svg__a)">
<path
fill="#fff"
fill-rule="evenodd"
d="M7.388 2.88 26.054 14.88a1.333 1.333 0 0 1 0 2.243L7.388 29.12a1.333 1.333 0 0 1-2.055-1.122V4.002a1.333 1.333 0 0 1 2.055-1.121"
clip-rule="evenodd"></path>
</g>
<defs>
<clipPath id="play-fill_svg__a"><path fill="#fff" d="M0 0h32v32H0z"></path></clipPath>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32" v-else>
<g clip-path="url(#pause-circle-fill_svg__a)">
<path
fill="#fff"
fill-rule="evenodd"
d="M11 3c1.104 0 2 .582 2 1.3v23.4c0 .718-.896 1.3-2 1.3H7c-1.105 0-2-.582-2-1.3V4.3C5 3.582 5.895 3 7 3zm14 0c1.104 0 2 .582 2 1.3v23.4c0 .718-.896 1.3-2 1.3h-4c-1.105 0-2-.582-2-1.3V4.3c0-.718.895-1.3 2-1.3z"
clip-rule="evenodd"></path>
</g>
<defs>
<clipPath id="pause-circle-fill_svg__a"><path fill="#fff" d="M0 0h32v32H0z"></path></clipPath>
</defs>
</svg>
</div>
</LiveCover>
</div>
</div>
</template> </template>
<style lang="scss" scoped> <style lang="scss">
.live-stream-video { .live {
width: 100%; width: 375px;
height: 100%;
object-fit: cover; &-hd {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
.live-icon {
height: 20px;
}
}
&-bd {
position: relative;
height: 667px;
border-radius: 8px;
overflow: hidden;
background-color: #000;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&-ft {
margin-top: 10px;
text-align: center;
}
.play-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #00000033;
border-radius: 1000px;
cursor: pointer;
height: 64px;
width: 64px;
display: flex;
align-items: center;
justify-content: center;
}
} }
</style> </style>
<script setup>
import { useDevicesList, useUserMedia } from '@vueuse/core'
import { saveAs } from 'file-saver'
const { videoInputs: cameras, audioInputs: microphones } = useDevicesList({
requestPermissions: true
})
const currentCamera = computed(() => cameras.value[0]?.deviceId)
const currentMicrophone = computed(() => microphones.value[0]?.deviceId)
const { stream } = useUserMedia({
enabled: true,
constraints: {
video: { deviceId: currentCamera },
audio: { deviceId: currentMicrophone }
}
})
const video = ref()
watchEffect(() => {
if (video.value) {
video.value.srcObject = stream.value
}
})
watch(stream, value => {
if (value) {
console.log('stream changed', value)
createMediaRecorder()
}
})
let mediaRecorder = null
let recordedChunks = []
function createMediaRecorder() {
if (!stream.value) return
mediaRecorder = new MediaRecorder(stream.value, { mimeType: 'video/mp4' })
mediaRecorder.ondataavailable = event => {
if (event.data.size > 0) {
recordedChunks.push(event.data)
const reader = new FileReader()
reader.onloadend = () => {
console.log(reader.result)
const base64Data = reader.result.split(',')[2] // 去掉 Base64 前缀
console.log(base64Data)
// 构建 JSON 数据
const jsonData = JSON.stringify({
type: 'video',
payload: base64Data
})
console.log('发送视频数据', jsonData)
}
reader.readAsDataURL(event.data) // 将 Blob 转换为 Base64
}
}
// 停止录制后生成 Blob 和下载链接
mediaRecorder.onstop = () => {
const blob = new Blob(recordedChunks, { type: 'video/mp4' })
saveAs(blob, 'recording.mp4')
recordedChunks = []
}
}
function start() {
if (!mediaRecorder) return
mediaRecorder.start(100)
console.log(mediaRecorder.state)
console.log('录制开始')
}
function stop() {
if (!mediaRecorder) return
mediaRecorder.stop()
console.log(mediaRecorder.state)
console.log('录制停止')
}
defineExpose({ start, stop })
</script>
<template>
<video muted autoplay ref="video" class="live-stream-video"></video>
</template>
<style lang="scss" scoped>
.live-stream-video {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
<script setup>
import { getRecordList } from '../api'
const props = defineProps(['data'])
function formatDuration(seconds) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return minutes > 0 ? `${minutes}m${remainingSeconds}s` : `${remainingSeconds}s`
}
// 列表配置
const listOptions = {
hasPagination: false,
remote: {
httpRequest: getRecordList,
params: { live_practice_id: props.data.id },
callback(data) {
return { list: data.items }
},
},
columns: [
{ label: '序号', type: 'index', width: 60 },
{ label: '主播', prop: 'id' },
{
label: '直播时长',
prop: 'live_duration',
computed({ row }) {
return formatDuration(row.live_duration)
},
},
{ label: '开始时间', prop: 'live_start_time' },
{ label: '结束时间', prop: 'live_end_time' },
{ label: '操作', slots: 'table-x', width: 100 },
],
}
</script>
<template>
<el-dialog title="选择直播练习场次">
<AppList v-bind="listOptions" ref="appList">
<template #table-x="{ row }">
<el-button text type="primary">
<router-link
:to="{ path: 'test/view', query: { ...$route.query, id: data.id, record_id: row.id } }"
target="_blank"
>查看</router-link
>
</el-button>
</template>
</AppList>
</el-dialog>
</template>
import { useDevicesList, useUserMedia } from '@vueuse/core'
export const readBlobAsBase64 = (blob: Blob): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
try {
const base64Data = (reader.result as string).split(',')[2]
resolve(base64Data)
} catch (error) {
reject(error)
}
}
reader.onerror = () => {
reject(new Error('Failed to read file'))
}
reader.readAsDataURL(blob)
})
}
interface UseLiveProps {
enabledUserMedia: boolean
onStart?: () => void
onRecord?: (data: Blob) => void
onStop?: (blob: Blob) => void
}
export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }: UseLiveProps) {
const startTime = ref(0)
const endTime = ref(0)
const duration = computed(() => Math.floor((endTime.value - startTime.value) / 1000))
const currentTime = ref(0)
// 获取设备列表并设置默认设备
const { videoInputs: cameras, audioInputs: microphones } = useDevicesList({ requestPermissions: true })
const currentCamera = computed(() => cameras.value[0]?.deviceId)
const currentMicrophone = computed(() => microphones.value[0]?.deviceId)
// 创建媒体流
const { stream } = useUserMedia({
enabled: enabledUserMedia,
constraints: { video: { deviceId: currentCamera as any }, audio: { deviceId: currentMicrophone as any } },
})
// 录像设置
const recordedChunks = ref<Blob[]>([])
let mediaRecorder: MediaRecorder | null = null
let timeUpdateInterval: number | null = null
// 初始化MediaRecorder
const initializeMediaRecorder = () => {
if (!stream.value) return
mediaRecorder = new MediaRecorder(stream.value, { mimeType: 'video/mp4' })
mediaRecorder.ondataavailable = handleDataAvailable
mediaRecorder.onstart = handleStart
mediaRecorder.onstop = handleStop
}
// 数据可用时处理
const handleDataAvailable = (event: BlobEvent) => {
if (event.data.size > 0) {
recordedChunks.value.push(event.data)
onRecord && onRecord(event.data)
}
}
// 录像开始时处理
const handleStart = () => {
startTime.value = Date.now()
onStart && onStart()
// Update currentTime every 100ms while recording
timeUpdateInterval = setInterval(() => {
currentTime.value = Math.floor((Date.now() - startTime.value) / 1000)
}, 100)
}
// 录像停止时处理
const handleStop = () => {
endTime.value = Date.now()
const blob = new Blob(recordedChunks.value, { type: 'video/mp4' })
onStop && onStop(blob)
recordedChunks.value = []
// Clear the interval when recording stops
if (timeUpdateInterval !== null) {
clearInterval(timeUpdateInterval)
timeUpdateInterval = null
}
}
// 开始录制
const start = () => {
if (!mediaRecorder) initializeMediaRecorder()
recordedChunks.value = []
mediaRecorder?.start(100) // 每100ms触发一次dataavailable事件
}
// 停止录制
const stop = () => {
if (mediaRecorder) mediaRecorder.stop()
}
return { stream, start, stop, startTime, endTime, duration, currentTime }
}
import { nanoid } from 'nanoid'
// 礼物
const gifts = ['棒棒糖', '墨镜', '鲜花', '亲吻', '兔耳朵']
// 头像
const avatars = [
'/live/avatar/1.png',
'/live/avatar/2.png',
'/live/avatar/3.png',
'/live/avatar/4.jpeg',
'/live/avatar/5.jpeg',
'/live/avatar/6.jpeg',
'/live/avatar/7.jpeg',
'/live/avatar/8.jpeg',
'/live/avatar/9.jpeg',
'/live/avatar/10.jpeg',
'/live/avatar/11.jpeg',
'/live/avatar/12.jpeg',
'/live/avatar/13.jpeg',
]
export interface Viewer {
id: string
name: string
avatar: string
}
export interface Message {
id: string
type: 'like' | 'gift' | 'join' | 'leave'
content: string
timestamp: number
currentTime: number
user: Viewer
}
export interface UseLiveChatOptions {
initViewerCount?: number
autoStart?: boolean
updateInterval?: number
defaultMessages?: Message[]
}
export function useLiveChat(options: UseLiveChatOptions = {}) {
const { initViewerCount = 20, autoStart = false, updateInterval = 200, defaultMessages } = options
const viewers = ref<Viewer[]>([])
const messages = ref<Message[]>([])
const currentTime = ref(0)
const stats = reactive({
totalViewers: 0, // 总在线人数
peakViewers: 0, // 最高在线人数
totalGifts: 0, // 总送礼数
totalLikes: 0, // 总点赞人数
totalGiftViewers: 0, // 总送礼人数
})
// 生成用户
const generateViewer = () => {
const id = nanoid(4)
return { id, name: `用户_${id}`, avatar: avatars[Math.floor(Math.random() * avatars.length)] }
}
// 随机获取用户
const getRandomViewer = () => {
return viewers.value[Math.floor(Math.random() * viewers.value.length)]
}
// 随机加入
const randomJoin = () => {
const user = generateViewer()
addMessage({ type: 'join', content: '来了', user })
}
// 随机离开
const randomLeave = () => {
const user = generateViewer()
addMessage({ type: 'leave', content: '离开了', user })
}
// 随机送礼物
const randomGift = () => {
const user = getRandomViewer()
const gift = gifts[Math.floor(Math.random() * 5)]
addMessage({ type: 'gift', content: `送了一个${gift}`, user })
}
// 随机点赞
const randomLike = () => {
const user = getRandomViewer()
addMessage({ type: 'like', content: '点赞了', user })
}
// 发送消息
const addMessage = (message: Omit<Message, 'id' | 'currentTime' | 'timestamp'>) => {
switch (message.type) {
// 进入直播
case 'join':
viewers.value.push(message.user)
stats.totalViewers++
stats.peakViewers = Math.max(viewers.value.length, stats.peakViewers)
break
// 离开直播
case 'leave':
viewers.value = viewers.value.filter((v) => v.id !== message.user.id)
stats.totalViewers--
break
// 点赞
case 'like':
stats.totalLikes++
break
// 送礼物
case 'gift':
stats.totalGifts++
stats.totalGiftViewers++
break
}
messages.value.push({ id: nanoid(), currentTime: currentTime.value, timestamp: Date.now(), ...message })
}
// 随机事件生成
const generateRandomEvents = () => {
const rand = Math.random()
if (rand < 0.9) randomJoin()
if (rand < 0.4) randomLeave()
if (rand < 0.7) randomGift()
if (rand < 0.8) randomLike()
}
// 开始
const start = () => {
// 初始化观众
for (let i = 0; i < initViewerCount; i++) {
randomJoin()
}
// 开始定时更新
setInterval(generateRandomEvents, updateInterval)
}
if (autoStart) start()
const reset = () => {
viewers.value = []
messages.value = []
Object.assign(stats, {
totalViewers: 0, // 总在线人数
peakViewers: 0, // 最高在线人数
totalGifts: 0, // 总送礼数
totalLikes: 0, // 总点赞人数
totalGiftViewers: 0, // 总送礼人数
})
}
watch(
currentTime,
() => {
if (defaultMessages && defaultMessages.length > 0) {
// reset()
defaultMessages.forEach((message) => {
if (message.currentTime == currentTime.value) addMessage(message)
})
}
},
{ immediate: true }
)
return { viewers, messages, stats, currentTime, start, reset }
}
<script setup> <script setup>
import Live from '../components/Live.vue' import Live from '../components/Live.vue'
import { getTest } from '../api' import LivePlayback from '../components/LivePlayback.vue'
import { getTest, getRecord } from '../api'
import { useCountdown } from '@/composables/useCountdown' import { useCountdown } from '@/composables/useCountdown'
defineProps({ const props = defineProps({
isView: { type: Boolean, default: false }, isView: { type: Boolean, default: false },
}) })
const route = useRoute() const route = useRoute()
const { timeLeft, formattedTime, start, stop } = useCountdown({ const { timeLeft, formattedTime, stop, reset } = useCountdown({
// 倒计时结束 // 倒计时结束
onEnd: () => { onEnd: () => {
live.value?.stop() live.value?.stop()
...@@ -17,10 +18,19 @@ const { timeLeft, formattedTime, start, stop } = useCountdown({ ...@@ -17,10 +18,19 @@ const { timeLeft, formattedTime, start, stop } = useCountdown({
const live = ref(null) const live = ref(null)
const detail = 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() { async function fetchInfo() {
const res = await getTest({ id: route.query.id }) const res = await getTest({ id: route.query.id })
detail.value = res.data.detail detail.value = res.data.detail
timeLeft.value = parseInt(detail.value?.duration) || 0 timeLeft.value = duration.value
} }
watchEffect(() => { watchEffect(() => {
fetchInfo() fetchInfo()
...@@ -34,12 +44,42 @@ const hotList = computed(() => { ...@@ -34,12 +44,42 @@ const hotList = computed(() => {
const actList = computed(() => { const actList = computed(() => {
return detail.value?.live_speech.marketing_campaign.split(';') 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 })
record.value = { ...res.data.detail, live_info: JSON.parse(res.data.detail.live_info) }
}
onMounted(() => {
props.isView && fetchRecord()
})
</script> </script>
<template> <template>
<AppCard title="直播练习" full> <AppCard title="直播练习" full>
<div class="live-row"> <div class="live-row">
<div class="live-col"><Live ref="live" :isView="isView" :onStart="start" :onStop="stop" /></div> <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"> <div class="live-col" style="flex: 1">
<h2 class="h2-title">直播话术</h2> <h2 class="h2-title">直播话术</h2>
<div class="live-talk-content"> <div class="live-talk-content">
...@@ -92,23 +132,23 @@ const actList = computed(() => { ...@@ -92,23 +132,23 @@ const actList = computed(() => {
<div class="live-data"> <div class="live-data">
<dl> <dl>
<dt>观众总人数:</dt> <dt>观众总人数:</dt>
<dd>100</dd> <dd>{{ stats.totalViewers }}</dd>
</dl> </dl>
<dl> <dl>
<dt>最高峰人数:</dt> <dt>最高峰人数:</dt>
<dd>100</dd> <dd>{{ stats.peakViewers }}</dd>
</dl> </dl>
<dl> <dl>
<dt>点赞数:</dt> <dt>点赞数:</dt>
<dd>9778</dd> <dd>{{ stats.totalLikes }}</dd>
</dl> </dl>
<dl> <dl>
<dt>刷礼物人数:</dt> <dt>刷礼物人数:</dt>
<dd>40</dd> <dd>{{ stats.totalGiftViewers }}</dd>
</dl> </dl>
<dl> <dl>
<dt>刷礼物总数:</dt> <dt>刷礼物总数:</dt>
<dd>1200</dd> <dd>{{ stats.totalGifts }}</dd>
</dl> </dl>
</div> </div>
</div> </div>
...@@ -189,6 +229,8 @@ const actList = computed(() => { ...@@ -189,6 +229,8 @@ const actList = computed(() => {
} }
} }
.live-talk-content { .live-talk-content {
max-height: 660px;
overflow-y: auto;
p { p {
margin: 10px 0; margin: 10px 0;
line-height: 24px; line-height: 24px;
......
<script setup> <script setup>
import { ElMessage } from 'element-plus' import { ElMessageBox, ElMessage } from 'element-plus'
import LiveProductCategory from '@/components/LiveProductCategory.vue' import LiveProductCategory from '@/components/LiveProductCategory.vue'
import { getTestList } from '../api' import { getTestList, deleteTest } from '../api'
import { useFileDialog } from '@vueuse/core' import { getNameByValue, liveTestUploadWay } from '@/utils/dictionary'
import { upload } from '@/utils/upload'
const FormDialog = defineAsyncComponent(() => import('../components/FormDialog.vue')) const FormDialog = defineAsyncComponent(() => import('../components/FormDialog.vue'))
const RecordDialog = defineAsyncComponent(() => import('../components/RecordDialog.vue'))
const appList = ref(null) const appList = ref(null)
// 刷新 // 刷新
...@@ -37,6 +37,13 @@ const listOptions = computed(() => { ...@@ -37,6 +37,13 @@ const listOptions = computed(() => {
{ label: '商品标题', prop: 'live_commodity_title' }, { label: '商品标题', prop: 'live_commodity_title' },
{ label: '直播话术', prop: 'live_commodity_speeches_name' }, { label: '直播话术', prop: 'live_commodity_speeches_name' },
{ label: '所属商品品类', prop: 'live_commodity_type_full_name' }, { label: '所属商品品类', prop: 'live_commodity_type_full_name' },
{
label: '上传方式',
prop: 'upload_way',
computed({ row }) {
return getNameByValue(row.upload_way, liveTestUploadWay)
},
},
{ label: '更新时间', prop: 'updated_time' }, { label: '更新时间', prop: 'updated_time' },
{ label: '操作', slots: 'table-x', width: 200 }, { label: '操作', slots: 'table-x', width: 200 },
], ],
...@@ -45,14 +52,18 @@ const listOptions = computed(() => { ...@@ -45,14 +52,18 @@ const listOptions = computed(() => {
const formVisible = ref(false) const formVisible = ref(false)
const handleUpload = () => { const recordVisible = ref(false)
const dialog = useFileDialog() const currentRow = ref(null)
dialog.open({ accept: 'video/mp4' }) const handelView = (row) => {
dialog.onChange(async ([file]) => { recordVisible.value = true
const res = await upload(file) currentRow.value = row
console.log(file, res) }
ElMessage.success('上传成功') // 删除
}) const handleRemove = async (row) => {
await ElMessageBox.confirm('此操作将永久删除该数据, 是否继续?', '提示')
await deleteTest({ id: row.id })
ElMessage.success('删除成功')
handleRefresh()
} }
</script> </script>
...@@ -66,16 +77,16 @@ const handleUpload = () => { ...@@ -66,16 +77,16 @@ const handleUpload = () => {
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh"></LiveProductCategory> <LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh"></LiveProductCategory>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
<el-button text type="primary" @click="handleUpload">上传</el-button>
<el-button text type="primary"> <el-button text type="primary">
<router-link :to="{ path: 'test/demo', query: { id: row.id } }">练习</router-link> <router-link :to="{ path: 'test/demo', query: { id: row.id } }">练习</router-link>
</el-button> </el-button>
<el-button text type="primary"> <el-button text type="primary" @click="handelView(row)">查看</el-button>
<router-link :to="{ path: 'test/view', query: { id: row.id } }">查看</router-link> <el-button text type="primary" @click="handleRemove(row)">删除</el-button>
</el-button>
</template> </template>
</AppList> </AppList>
</AppCard> </AppCard>
<!-- 新建/修改 --> <!-- 新建/修改 -->
<FormDialog v-model="formVisible" @update="handleRefresh" v-if="formVisible" /> <FormDialog v-model="formVisible" @update="handleRefresh" v-if="formVisible" />
<!-- 查看 -->
<RecordDialog v-model="recordVisible" :data="currentRow" v-if="recordVisible"></RecordDialog>
</template> </template>
...@@ -3,7 +3,8 @@ import md5 from 'blueimp-md5' ...@@ -3,7 +3,8 @@ import md5 from 'blueimp-md5'
import { getSignature, uploadFile } from '@/api/base' import { getSignature, uploadFile } from '@/api/base'
export async function upload(blob: Blob) { export async function upload(blob: Blob) {
const key = 'upload/saas-lab/' + md5(new Date().getTime() + Math.random().toString(36).slice(-8)) + '.png' const fileType = blob.type.split('/').pop() || 'png'
const key = 'upload/saas-lab/' + md5(new Date().getTime() + Math.random().toString(36).slice(-8)) + '.' + fileType
const response: any = await getSignature() const response: any = await getSignature()
const params = { const params = {
key, key,
...@@ -13,7 +14,7 @@ export async function upload(blob: Blob) { ...@@ -13,7 +14,7 @@ export async function upload(blob: Blob) {
signature: response.signature, signature: response.signature,
success_action_status: '200', success_action_status: '200',
file: blob, file: blob,
url: `${response.host}/${key}` url: `${response.host}/${key}`,
} }
await uploadFile(params) await uploadFile(params)
return params.url return params.url
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论