mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-08-06 10:57:26 +08:00
腾讯云直播
This commit is contained in:
96
manager/src/api/live.js
Normal file
96
manager/src/api/live.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import axios from 'axios'
|
||||
import { getStore } from '@/libs/storage'
|
||||
import {
|
||||
deleteRequest,
|
||||
getRequest,
|
||||
managerUrl,
|
||||
postRequest,
|
||||
putRequest,
|
||||
} from '@/libs/axios'
|
||||
|
||||
function deleteRequestWithBody(url, data) {
|
||||
const accessToken = getStore('accessToken')
|
||||
return axios({
|
||||
method: 'delete',
|
||||
baseURL: managerUrl,
|
||||
url,
|
||||
data,
|
||||
headers: {
|
||||
accessToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then(res => res.data)
|
||||
}
|
||||
|
||||
// ========== 直播间 ==========
|
||||
export const queryLivePage = params => getRequest('/live/room/page', params)
|
||||
|
||||
export const getLiveInfo = id => getRequest(`/live/room/${id}`)
|
||||
|
||||
export const createLive = params =>
|
||||
postRequest('/live/room', params, { 'Content-type': 'application/json' })
|
||||
|
||||
export const updateLive = (id, params) =>
|
||||
putRequest(`/live/room/${id}`, params, { 'Content-type': 'application/json' })
|
||||
|
||||
export const startLive = id => putRequest(`/live/room/start/${id}`)
|
||||
|
||||
export const endLive = id => putRequest(`/live/room/end/${id}`)
|
||||
|
||||
export const deleteLive = id => deleteRequest(`/live/room/${id}`)
|
||||
|
||||
// ========== 直播商品 ==========
|
||||
export const getLiveGoodsList = liveId => getRequest(`/live/goods/list/${liveId}`)
|
||||
|
||||
export const saveBatchLiveGoods = params =>
|
||||
postRequest('/live/goods/batch', params, { 'Content-type': 'application/json' })
|
||||
|
||||
export const removeBatchLiveGoods = ids => deleteRequestWithBody('/live/goods/batch', ids)
|
||||
|
||||
export const setRecommendLiveGoods = id => putRequest(`/live/goods/recommend/${id}`)
|
||||
|
||||
export const cancelRecommendLiveGoods = id => deleteRequest(`/live/goods/recommend/${id}`)
|
||||
|
||||
export const setLiveGoodsSoldOut = (id, soldOutFlag) =>
|
||||
putRequest(`/live/goods/sold-out/${id}`, { soldOutFlag })
|
||||
|
||||
export const getLiveGoodsPopularity = (id, liveId) =>
|
||||
getRequest(`/live/goods/popularity/${id}`, { liveId })
|
||||
|
||||
export const setLiveGoodsPopularity = (id, liveId, popularity) =>
|
||||
putRequest(`/live/goods/set/popularity/${id}`, { liveId, popularity })
|
||||
|
||||
// ========== 直播优惠券 ==========
|
||||
export const getLiveCouponList = liveId => getRequest(`/live/coupon/list/${liveId}`)
|
||||
|
||||
export const saveBatchLiveCoupon = params =>
|
||||
postRequest('/live/coupon/batch', params, { 'Content-type': 'application/json' })
|
||||
|
||||
export const removeBatchLiveCoupon = ids => deleteRequest('/live/coupon/batch', { ids: ids.join(',') })
|
||||
|
||||
export const setRecommendLiveCoupon = id => putRequest(`/live/coupon/recommend/${id}`)
|
||||
|
||||
export const cancelRecommendLiveCoupon = id => deleteRequest(`/live/coupon/recommend/${id}`)
|
||||
|
||||
// ========== 直播消息 ==========
|
||||
export const queryLiveMessagePage = params => getRequest('/live/message/page', params)
|
||||
|
||||
export const authLiveMessage = params =>
|
||||
putRequest('/live/message/auth', params, { 'Content-type': 'application/json' })
|
||||
|
||||
// ========== 直播用户 / 拉黑 ==========
|
||||
export const liveUserList = params => getRequest('/live/user/page', params)
|
||||
|
||||
export const editLiveUserMute = params => putRequest('/live/user/edit/mute', params)
|
||||
|
||||
export const getLiveBlockPage = params => postRequest('/live/block/page', params, { 'Content-type': 'application/json' })
|
||||
|
||||
export const blockUser = params => postRequest('/live/block/user', params)
|
||||
|
||||
export const unblockUser = params => postRequest('/live/block/user/unblock', params)
|
||||
|
||||
// ========== 直播订单 ==========
|
||||
export const queryLiveOrderPage = params => getRequest('/live/order/page', params)
|
||||
|
||||
export const getLiveOrderStatistics = liveRoomId =>
|
||||
getRequest('/live/order/statistics', { liveRoomId })
|
||||
@@ -355,6 +355,36 @@ export const otherRouter = {
|
||||
name: "add-sms-sign",
|
||||
component: () => import("@/views/sys/message/smsSign.vue")
|
||||
},
|
||||
{
|
||||
path: "live-list",
|
||||
title: "直播管理",
|
||||
name: "live-list",
|
||||
component: () => import("@/views/live/list.vue")
|
||||
},
|
||||
{
|
||||
path: "live-add",
|
||||
title: "创建直播",
|
||||
name: "live-add",
|
||||
component: () => import("@/views/live/add.vue")
|
||||
},
|
||||
{
|
||||
path: "live-edit",
|
||||
title: "编辑直播",
|
||||
name: "live-edit",
|
||||
component: () => import("@/views/live/edit.vue")
|
||||
},
|
||||
{
|
||||
path: "live-manage-detail",
|
||||
title: "直播详情",
|
||||
name: "live-manage-detail",
|
||||
component: () => import("@/views/live/detail.vue")
|
||||
},
|
||||
{
|
||||
path: "live-control-panel",
|
||||
title: "直播中控台",
|
||||
name: "live-control-panel",
|
||||
component: () => import("@/views/live/control-panel.vue")
|
||||
},
|
||||
{
|
||||
path: "live-detail",
|
||||
title: "查看直播",
|
||||
|
||||
67
manager/src/types/live.ts
Normal file
67
manager/src/types/live.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export interface LiveOrderStatistics {
|
||||
totalAmount?: number
|
||||
salesVolume?: number
|
||||
dealUserCount?: number
|
||||
averageOrderValue?: number
|
||||
conversionRate?: number
|
||||
}
|
||||
|
||||
export interface LiveRoomForm {
|
||||
title: string
|
||||
description?: string
|
||||
coverImg: string
|
||||
/** 0 横屏 1 竖屏 2 三分屏 */
|
||||
displayMode: string
|
||||
liveIntroduce?: string
|
||||
startTime: string
|
||||
endTime?: string
|
||||
pushStreamServer?: string
|
||||
pushStreamCode?: string
|
||||
}
|
||||
|
||||
export interface LiveCoupon {
|
||||
id?: string
|
||||
couponId: string
|
||||
couponName: string
|
||||
couponPrice: number
|
||||
liveRoomId?: string
|
||||
liveRoomName?: string
|
||||
hideFlag?: boolean
|
||||
recommend?: boolean
|
||||
issueQuantity?: number
|
||||
receivedCount?: number
|
||||
usedCount?: number
|
||||
}
|
||||
|
||||
export interface LiveGoodsItem {
|
||||
id?: string
|
||||
liveRoomId?: string
|
||||
goodsId: string
|
||||
skuId: string
|
||||
goodsName?: string
|
||||
stock?: string
|
||||
price?: number
|
||||
thumbnail?: string
|
||||
salesCount?: number
|
||||
storeId?: string
|
||||
storeName?: string
|
||||
originPrice?: number
|
||||
sellPoint?: string
|
||||
popularity?: number
|
||||
hideFlag?: boolean
|
||||
canBuyFlag?: boolean
|
||||
soldOutFlag?: boolean
|
||||
recommend?: boolean
|
||||
}
|
||||
|
||||
export interface LiveMessage {
|
||||
id: string
|
||||
liveRoomId?: string
|
||||
liveUserId?: string
|
||||
userId?: string
|
||||
userName?: string
|
||||
userFace?: string
|
||||
message?: string
|
||||
authStatus?: string
|
||||
createTime?: string
|
||||
}
|
||||
241
manager/src/views/live/add.vue
Normal file
241
manager/src/views/live/add.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="live-form-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span class="page-title">创建直播</span>
|
||||
</template>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="100px" class="live-form">
|
||||
<el-form-item label="直播标题" prop="title">
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="请输入直播标题"
|
||||
maxlength="34"
|
||||
show-word-limit
|
||||
class="form-input"
|
||||
/>
|
||||
<div class="form-tip">直播间名字,最短3个汉字,最长17个汉字,1个汉字相当于2个字符</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示模式" prop="displayMode">
|
||||
<el-radio-group v-model="form.displayMode">
|
||||
<el-radio value="0">横屏</el-radio>
|
||||
<el-radio value="1">竖屏</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="开始时间" prop="startTime">
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
placeholder="请选择日期"
|
||||
class="form-input"
|
||||
/>
|
||||
<div class="form-tip">请选择直播开始时间</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="直播封面" prop="coverImg">
|
||||
<div class="cover-uploader">
|
||||
<div class="cover-preview">
|
||||
<el-image v-if="form.coverImg" :src="form.coverImg" fit="cover" class="cover-image" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<el-icon :size="32"><Picture /></el-icon>
|
||||
<span>暂无封面图片</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button @click="openCoverPicker">
|
||||
<el-icon><Picture /></el-icon>
|
||||
选择图片
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="form-tip">建议尺寸 1080x1920,大小不超过1M</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="直播简介" prop="liveIntroduce">
|
||||
<el-input
|
||||
v-model="form.liveIntroduce"
|
||||
type="textarea"
|
||||
placeholder="请输入直播简介(选填)"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
:rows="4"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">提交</el-button>
|
||||
<el-button @click="handleBack">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
|
||||
<ossManage
|
||||
ref="ossManage"
|
||||
:is-component="true"
|
||||
:initialize="picModelFlag"
|
||||
@callback="callbackSelected"
|
||||
/>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Picture } from "@element-plus/icons-vue";
|
||||
import { createLive } from "@/api/live";
|
||||
import ossManage from "@/views/sys/oss-manage/ossManage";
|
||||
|
||||
function getCharLength(str) {
|
||||
if (!str) return 0;
|
||||
let len = 0;
|
||||
for (const ch of str) {
|
||||
len += /[\u4e00-\u9fa5]/.test(ch) ? 2 : 1;
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
function buildEndTime(startTime, hours = 24) {
|
||||
const date = new Date(startTime.replace(/-/g, "/"));
|
||||
date.setHours(date.getHours() + hours);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "live-add",
|
||||
components: { Picture, ossManage },
|
||||
data() {
|
||||
const validateTitle = (rule, value, callback) => {
|
||||
const len = getCharLength(value);
|
||||
if (len < 6) {
|
||||
callback(new Error("直播间名字最短3个汉字"));
|
||||
return;
|
||||
}
|
||||
if (len > 34) {
|
||||
callback(new Error("直播间名字最长17个汉字"));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
return {
|
||||
picModelFlag: false,
|
||||
submitting: false,
|
||||
form: {
|
||||
title: "",
|
||||
displayMode: "0",
|
||||
startTime: "",
|
||||
coverImg: "",
|
||||
liveIntroduce: "",
|
||||
},
|
||||
rules: {
|
||||
title: [
|
||||
{ required: true, message: "请输入直播标题", trigger: "blur" },
|
||||
{ validator: validateTitle, trigger: "blur" },
|
||||
],
|
||||
displayMode: [{ required: true, message: "请选择显示模式", trigger: "change" }],
|
||||
startTime: [{ required: true, message: "请选择直播开始时间", trigger: "change" }],
|
||||
coverImg: [{ required: true, message: "请上传直播封面", trigger: "change" }],
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
openCoverPicker() {
|
||||
this.picModelFlag = true;
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.ossManage) {
|
||||
this.$refs.ossManage.selectImage = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
callbackSelected(val) {
|
||||
this.picModelFlag = false;
|
||||
if (val?.url) {
|
||||
this.form.coverImg = val.url;
|
||||
this.$refs.form?.validateField("coverImg");
|
||||
}
|
||||
},
|
||||
handleSubmit() {
|
||||
this.$refs.form.validate((valid) => {
|
||||
if (!valid) return;
|
||||
this.submitting = true;
|
||||
createLive({
|
||||
title: this.form.title,
|
||||
displayMode: this.form.displayMode,
|
||||
coverImg: this.form.coverImg,
|
||||
startTime: this.form.startTime,
|
||||
endTime: buildEndTime(this.form.startTime),
|
||||
liveIntroduce: this.form.liveIntroduce,
|
||||
})
|
||||
.then((res) => {
|
||||
this.submitting = false;
|
||||
if (res.success) {
|
||||
this.$Message.success("创建成功");
|
||||
this.$router.push({ path: "/live-list" });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.submitting = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
handleBack() {
|
||||
this.$router.push({ path: "/live-list" });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.live-form-page {
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
.live-form {
|
||||
max-width: 720px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.form-input {
|
||||
width: 460px;
|
||||
}
|
||||
.form-tip {
|
||||
margin-top: 6px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cover-uploader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.cover-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 8px;
|
||||
:deep(.el-form-item__content) {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
459
manager/src/views/live/components/CommentReview.vue
Normal file
459
manager/src/views/live/components/CommentReview.vue
Normal file
@@ -0,0 +1,459 @@
|
||||
<template>
|
||||
<div class="comment-review" :class="{ embedded }">
|
||||
<div v-if="embedded" class="embedded-toolbar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索用户ID/昵称/内容"
|
||||
clearable
|
||||
class="search-input"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleRefresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<template v-if="embedded">
|
||||
<el-table v-loading="messageLoading" border :data="messageList" style="width: 100%">
|
||||
<el-table-column label="用户" width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="user-cell">
|
||||
<el-avatar v-if="row.userFace" :src="row.userFace" :size="28" />
|
||||
<span>{{ row.userName || "-" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="评论内容" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="审核状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="authStatusTagType(row.authStatus)">
|
||||
{{ authStatusText(row.authStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发送时间" width="170">
|
||||
<template #default="{ row }">{{ row.createTime }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<a class="link-text" @click="handleAuth(row, 'PASS')">通过</a>
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="handleAuth(row, 'REJECT')">拒绝</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="mt_10 pager-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="messagePagination.pageNumber"
|
||||
v-model:page-size="messagePagination.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="messagePagination.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
size="small"
|
||||
@current-change="loadMessages"
|
||||
@size-change="onMessagePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-tabs v-else v-model="activeTab" @tab-change="onTabChange">
|
||||
<el-tab-pane label="评论审核" name="message">
|
||||
<el-table v-loading="messageLoading" border :data="messageList" style="width: 100%">
|
||||
<el-table-column label="用户" width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="user-cell">
|
||||
<el-avatar v-if="row.userFace" :src="row.userFace" :size="28" />
|
||||
<span>{{ row.userName || "-" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="评论内容" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="authStatusTagType(row.authStatus)">
|
||||
{{ authStatusText(row.authStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="170">
|
||||
<template #default="{ row }">{{row.createTime }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<a class="link-text" @click="handleAuth(row, 'PASS')">通过</a>
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="handleAuth(row, 'REJECT')">拒绝</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="mt_10 pager-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="messagePagination.pageNumber"
|
||||
v-model:page-size="messagePagination.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="messagePagination.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
size="small"
|
||||
@current-change="loadMessages"
|
||||
@size-change="onMessagePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="观众管理" name="user">
|
||||
<el-table v-loading="userLoading" border :data="userList" style="width: 100%">
|
||||
<el-table-column prop="userName" label="用户" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="禁言状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.muteFlag ? 'danger' : 'success'">
|
||||
{{ row.muteFlag ? "已禁言" : "正常" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<a class="link-text" @click="handleToggleMute(row)">
|
||||
{{ row.muteFlag ? "解除禁言" : "禁言" }}
|
||||
</a>
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="handleBlock(row)">拉黑</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="mt_10" style="display: flex; justify-content: flex-end">
|
||||
<el-pagination
|
||||
v-model:current-page="userPagination.pageNumber"
|
||||
v-model:page-size="userPagination.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="userPagination.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
size="small"
|
||||
@current-change="loadUsers"
|
||||
@size-change="onUserPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="黑名单" name="block">
|
||||
<el-table v-loading="blockLoading" border :data="blockList" style="width: 100%">
|
||||
<el-table-column prop="userName" label="用户" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="拉黑时间" width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.createTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<a class="link-text" @click="handleUnblock(row)">解除拉黑</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="mt_10" style="display: flex; justify-content: flex-end">
|
||||
<el-pagination
|
||||
v-model:current-page="blockPagination.pageNumber"
|
||||
v-model:page-size="blockPagination.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="blockPagination.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
size="small"
|
||||
@current-change="loadBlocks"
|
||||
@size-change="onBlockPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="blockDialogVisible" title="拉黑用户" width="420px" append-to-body>
|
||||
<p class="block-tip">确定要拉黑用户「{{ blockTarget?.userName || "" }}」吗?</p>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="拉黑原因" required>
|
||||
<el-input
|
||||
v-model="blockReason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入拉黑原因"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="blockDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="blockSubmitting" @click="confirmBlock">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Refresh } from "@element-plus/icons-vue";
|
||||
import {
|
||||
authLiveMessage,
|
||||
blockUser,
|
||||
editLiveUserMute,
|
||||
getLiveBlockPage,
|
||||
liveUserList,
|
||||
queryLiveMessagePage,
|
||||
unblockUser,
|
||||
} from "@/api/live";
|
||||
|
||||
export default {
|
||||
name: "CommentReview",
|
||||
components: { Refresh },
|
||||
props: {
|
||||
liveId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
embedded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeTab: "message",
|
||||
searchKeyword: "",
|
||||
messageLoading: false,
|
||||
userLoading: false,
|
||||
blockLoading: false,
|
||||
messageList: [],
|
||||
userList: [],
|
||||
blockList: [],
|
||||
messagePagination: {
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
},
|
||||
userPagination: {
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
},
|
||||
blockPagination: {
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
},
|
||||
blockDialogVisible: false,
|
||||
blockTarget: null,
|
||||
blockReason: "",
|
||||
blockSubmitting: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
liveId() {
|
||||
this.loadMessages();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadMessages();
|
||||
},
|
||||
methods: {
|
||||
formatTime(time) {
|
||||
if (!time) return "-";
|
||||
return this.$filters.unixToDate(time);
|
||||
},
|
||||
authStatusText(status) {
|
||||
const map = { PASS: "已通过", REFUSE: "已拒绝", TOBEAUDITED: "待审核" };
|
||||
return map[status] || status || "待审核";
|
||||
},
|
||||
authStatusTagType(status) {
|
||||
const map = { PASS: "success", REFUSE: "danger", TOBEAUDITED: "warning" };
|
||||
return map[status] || "info";
|
||||
},
|
||||
parsePageResult(res) {
|
||||
if (!res) return { records: [], total: 0 };
|
||||
if (res.records) {
|
||||
return { records: res.records || [], total: res.total || 0 };
|
||||
}
|
||||
if (res.result?.records) {
|
||||
return { records: res.result.records || [], total: res.result.total || 0 };
|
||||
}
|
||||
if (res.success && res.result) {
|
||||
return { records: res.result.records || [], total: res.result.total || 0 };
|
||||
}
|
||||
return { records: [], total: 0 };
|
||||
},
|
||||
onTabChange(tab) {
|
||||
if (tab === "message") this.loadMessages();
|
||||
if (tab === "user") this.loadUsers();
|
||||
if (tab === "block") this.loadBlocks();
|
||||
},
|
||||
loadMessages() {
|
||||
if (!this.liveId) return;
|
||||
this.messageLoading = true;
|
||||
const params = {
|
||||
liveStreamId: this.liveId,
|
||||
pageNumber: this.messagePagination.pageNumber,
|
||||
pageSize: this.messagePagination.pageSize,
|
||||
};
|
||||
if (this.searchKeyword?.trim()) {
|
||||
params.keyword = this.searchKeyword.trim();
|
||||
}
|
||||
queryLiveMessagePage(params)
|
||||
.then((res) => {
|
||||
this.messageLoading = false;
|
||||
if (res.success) {
|
||||
const page = this.parsePageResult(res);
|
||||
this.messageList = page.records;
|
||||
this.messagePagination.total = page.total;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.messageLoading = false;
|
||||
});
|
||||
},
|
||||
loadUsers() {
|
||||
if (!this.liveId) return;
|
||||
this.userLoading = true;
|
||||
liveUserList({
|
||||
liveRoomId: this.liveId,
|
||||
pageNumber: this.userPagination.pageNumber,
|
||||
pageSize: this.userPagination.pageSize,
|
||||
})
|
||||
.then((res) => {
|
||||
this.userLoading = false;
|
||||
if (res.success) {
|
||||
this.userList = res.result?.records || [];
|
||||
this.userPagination.total = res.result?.total || 0;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.userLoading = false;
|
||||
});
|
||||
},
|
||||
loadBlocks() {
|
||||
if (!this.liveId) return;
|
||||
this.blockLoading = true;
|
||||
getLiveBlockPage({
|
||||
liveRoomId: this.liveId,
|
||||
pageNumber: this.blockPagination.pageNumber,
|
||||
pageSize: this.blockPagination.pageSize,
|
||||
})
|
||||
.then((res) => {
|
||||
this.blockLoading = false;
|
||||
const page = this.parsePageResult(res);
|
||||
this.blockList = page.records;
|
||||
this.blockPagination.total = page.total;
|
||||
})
|
||||
.catch(() => {
|
||||
this.blockLoading = false;
|
||||
});
|
||||
},
|
||||
handleAuth(record, authStatus) {
|
||||
authLiveMessage({ id: record.id, authStatus }).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("操作成功");
|
||||
this.loadMessages();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleToggleMute(record) {
|
||||
const muteFlag = !record.muteFlag;
|
||||
editLiveUserMute({
|
||||
liveUserid: record.id,
|
||||
muteFlag,
|
||||
}).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success(muteFlag ? "已禁言" : "已解除禁言");
|
||||
this.loadUsers();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleBlock(record) {
|
||||
this.blockTarget = record;
|
||||
this.blockReason = "";
|
||||
this.blockDialogVisible = true;
|
||||
},
|
||||
confirmBlock() {
|
||||
if (!this.blockReason?.trim()) {
|
||||
this.$Message.warning("请输入拉黑原因");
|
||||
return;
|
||||
}
|
||||
this.blockSubmitting = true;
|
||||
blockUser({
|
||||
liveRoomId: this.liveId,
|
||||
userId: this.blockTarget?.userId || this.blockTarget?.id,
|
||||
reason: this.blockReason.trim(),
|
||||
})
|
||||
.then((res) => {
|
||||
this.blockSubmitting = false;
|
||||
if (res.success) {
|
||||
this.$Message.success("拉黑成功");
|
||||
this.blockDialogVisible = false;
|
||||
this.loadUsers();
|
||||
this.loadBlocks();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.blockSubmitting = false;
|
||||
});
|
||||
},
|
||||
handleUnblock(record) {
|
||||
unblockUser({
|
||||
liveRoomId: this.liveId,
|
||||
userId: record.userId || record.id,
|
||||
}).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("已解除拉黑");
|
||||
this.loadBlocks();
|
||||
}
|
||||
});
|
||||
},
|
||||
onMessagePageSizeChange() {
|
||||
this.messagePagination.pageNumber = 1;
|
||||
this.loadMessages();
|
||||
},
|
||||
onUserPageSizeChange() {
|
||||
this.userPagination.pageNumber = 1;
|
||||
this.loadUsers();
|
||||
},
|
||||
onBlockPageSizeChange() {
|
||||
this.blockPagination.pageNumber = 1;
|
||||
this.loadBlocks();
|
||||
},
|
||||
handleSearch() {
|
||||
this.messagePagination.pageNumber = 1;
|
||||
this.loadMessages();
|
||||
},
|
||||
handleRefresh() {
|
||||
this.loadMessages();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.comment-review {
|
||||
min-height: 320px;
|
||||
|
||||
&.embedded {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
.embedded-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.search-input {
|
||||
width: 220px;
|
||||
}
|
||||
}
|
||||
.pager-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.block-tip {
|
||||
margin: 0 0 12px;
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
306
manager/src/views/live/components/Coupon.vue
Normal file
306
manager/src/views/live/components/Coupon.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<div class="live-coupon">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCouponSelector">添加优惠券</el-button>
|
||||
<el-button type="danger" :disabled="!selectedIds.length" @click="handleBatchRemove">
|
||||
批量移除
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="table"
|
||||
v-loading="loading"
|
||||
border
|
||||
:data="couponList"
|
||||
row-key="id"
|
||||
style="width: 100%"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="52" align="center" />
|
||||
<el-table-column prop="couponName" label="优惠券名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="面额" width="120">
|
||||
<template #default="{ row }">{{ formatPrice(row.couponPrice) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="推荐" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.recommend ? 'success' : 'info'">
|
||||
{{ row.recommend ? "是" : "否" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<a v-if="!row.recommend" class="link-text" @click="handleRecommend(row)">推荐</a>
|
||||
<a v-else class="link-text" @click="handleCancelRecommend(row)">取消推荐</a>
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="handleRemove(row)">移除</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && !couponList.length" description="暂无直播优惠券" />
|
||||
|
||||
<el-dialog v-model="pickerVisible" title="选择优惠券" width="800px" append-to-body destroy-on-close>
|
||||
<el-form inline class="mb_10">
|
||||
<el-form-item label="优惠券名称">
|
||||
<el-input
|
||||
v-model="pickerSearch.couponName"
|
||||
placeholder="请输入优惠券名称"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="loadPickerCoupons"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadPickerCoupons">搜索</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table
|
||||
ref="pickerTable"
|
||||
v-loading="pickerLoading"
|
||||
border
|
||||
:data="pickerList"
|
||||
row-key="id"
|
||||
max-height="400"
|
||||
@selection-change="handlePickerSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="52" align="center" />
|
||||
<el-table-column prop="couponName" label="优惠券名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="面额/折扣" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.price">{{ formatPrice(row.price) }}</span>
|
||||
<span v-else>{{ row.couponDiscount }}折</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已领取/总量" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.receivedNum }}/{{ row.publishNum === 0 ? "不限制" : row.publishNum }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="promotionStatusTagType(row.promotionStatus)">
|
||||
{{ promotionStatusText(row.promotionStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="mt_10" style="display: flex; justify-content: flex-end">
|
||||
<el-pagination
|
||||
v-model:current-page="pickerSearch.pageNumber"
|
||||
v-model:page-size="pickerSearch.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="pickerTotal"
|
||||
layout="total, prev, pager, next"
|
||||
size="small"
|
||||
@current-change="loadPickerCoupons"
|
||||
@size-change="onPickerPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="pickerVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="pickerSubmitting" @click="confirmPicker">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
cancelRecommendLiveCoupon,
|
||||
getLiveCouponList,
|
||||
removeBatchLiveCoupon,
|
||||
saveBatchLiveCoupon,
|
||||
setRecommendLiveCoupon,
|
||||
} from "@/api/live";
|
||||
import { getPlatformCouponList } from "@/api/promotion";
|
||||
|
||||
export default {
|
||||
name: "LiveCoupon",
|
||||
props: {
|
||||
liveId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
liveRoomName: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
couponList: [],
|
||||
selectedIds: [],
|
||||
pickerVisible: false,
|
||||
pickerLoading: false,
|
||||
pickerSubmitting: false,
|
||||
pickerList: [],
|
||||
pickerTotal: 0,
|
||||
pickerSelected: [],
|
||||
pickerSearch: {
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
couponName: "",
|
||||
promotionStatus: "START",
|
||||
sort: "create_time",
|
||||
order: "desc",
|
||||
},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
liveId() {
|
||||
this.loadCoupons();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadCoupons();
|
||||
},
|
||||
methods: {
|
||||
formatPrice(price) {
|
||||
return this.$filters.unitPrice(price, "¥");
|
||||
},
|
||||
promotionStatusText(status) {
|
||||
const map = { NEW: "未开始", START: "已开始", END: "已结束", CLOSE: "已关闭" };
|
||||
return map[status] || "未知";
|
||||
},
|
||||
promotionStatusTagType(status) {
|
||||
const map = { NEW: "info", START: "success", END: "warning", CLOSE: "danger" };
|
||||
return map[status] || "info";
|
||||
},
|
||||
loadCoupons() {
|
||||
if (!this.liveId) return;
|
||||
this.loading = true;
|
||||
getLiveCouponList(this.liveId)
|
||||
.then((res) => {
|
||||
this.loading = false;
|
||||
if (res.success) {
|
||||
this.couponList = res.result || [];
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
handleSelectionChange(rows) {
|
||||
this.selectedIds = (rows || []).map((r) => r.id);
|
||||
},
|
||||
openCouponSelector() {
|
||||
this.pickerVisible = true;
|
||||
this.pickerSelected = [];
|
||||
this.pickerSearch.pageNumber = 1;
|
||||
this.loadPickerCoupons();
|
||||
},
|
||||
loadPickerCoupons() {
|
||||
this.pickerLoading = true;
|
||||
getPlatformCouponList(this.pickerSearch)
|
||||
.then((res) => {
|
||||
this.pickerLoading = false;
|
||||
if (res.success) {
|
||||
this.pickerList = res.result?.records || [];
|
||||
this.pickerTotal = res.result?.total || 0;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.pickerLoading = false;
|
||||
});
|
||||
},
|
||||
onPickerPageSizeChange() {
|
||||
this.pickerSearch.pageNumber = 1;
|
||||
this.loadPickerCoupons();
|
||||
},
|
||||
handlePickerSelectionChange(rows) {
|
||||
this.pickerSelected = rows || [];
|
||||
},
|
||||
buildLiveCouponPayload(item) {
|
||||
return {
|
||||
couponId: item.id,
|
||||
couponPrice: Number(item.price ?? 0),
|
||||
couponName: item.couponName || "",
|
||||
liveRoomId: this.liveId,
|
||||
liveRoomName: this.liveRoomName || "",
|
||||
hideFlag: false,
|
||||
recommend: false,
|
||||
};
|
||||
},
|
||||
confirmPicker() {
|
||||
if (!this.pickerSelected.length) {
|
||||
this.$Message.warning("请选择优惠券");
|
||||
return;
|
||||
}
|
||||
const existingCouponIds = new Set(this.couponList.map((c) => String(c.couponId)));
|
||||
const payload = this.pickerSelected
|
||||
.filter((item) => !existingCouponIds.has(String(item.id)))
|
||||
.map((item) => this.buildLiveCouponPayload(item));
|
||||
if (!payload.length) {
|
||||
this.$Message.warning("所选优惠券均已添加");
|
||||
return;
|
||||
}
|
||||
this.pickerSubmitting = true;
|
||||
saveBatchLiveCoupon(payload)
|
||||
.then((res) => {
|
||||
this.pickerSubmitting = false;
|
||||
if (res.success) {
|
||||
this.$Message.success("添加成功");
|
||||
this.pickerVisible = false;
|
||||
this.loadCoupons();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.pickerSubmitting = false;
|
||||
});
|
||||
},
|
||||
handleRemove(record) {
|
||||
this.$Modal.confirm({
|
||||
title: "确认移除",
|
||||
content: "确定要移除该优惠券吗?",
|
||||
onOk: () => {
|
||||
return removeBatchLiveCoupon([record.id]).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("移除成功");
|
||||
this.loadCoupons();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
handleBatchRemove() {
|
||||
this.$Modal.confirm({
|
||||
title: "确认批量移除",
|
||||
content: `确定要移除选中的 ${this.selectedIds.length} 个优惠券吗?`,
|
||||
onOk: () => {
|
||||
return removeBatchLiveCoupon(this.selectedIds).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("移除成功");
|
||||
this.selectedIds = [];
|
||||
this.loadCoupons();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
handleRecommend(record) {
|
||||
setRecommendLiveCoupon(record.id).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("已设为推荐");
|
||||
this.loadCoupons();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleCancelRecommend(record) {
|
||||
cancelRecommendLiveCoupon(record.id).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("已取消推荐");
|
||||
this.loadCoupons();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
326
manager/src/views/live/components/LiveGoods.vue
Normal file
326
manager/src/views/live/components/LiveGoods.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div class="live-goods">
|
||||
<div v-if="!readonly" class="toolbar">
|
||||
<el-button type="primary" @click="openChooseGoods">添加商品</el-button>
|
||||
<el-button type="danger" :disabled="!selectedIds.length" @click="handleBatchRemove">
|
||||
批量移除
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="table"
|
||||
v-loading="loading"
|
||||
border
|
||||
:data="goodsList"
|
||||
row-key="id"
|
||||
style="width: 100%"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column v-if="!readonly" type="selection" width="52" align="center" />
|
||||
<el-table-column label="商品" min-width="260">
|
||||
<template #default="{ row, $index }">
|
||||
<div class="flex-goods">
|
||||
<span v-if="$index === 0 || $index === 1" class="cover-dot" />
|
||||
<el-image
|
||||
:src="row.thumbnail || row.goodsImage"
|
||||
style="width: 50px; height: 50px"
|
||||
fit="cover"
|
||||
/>
|
||||
<span class="goods-name">{{ row.goodsName || row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="price">{{ formatPrice(row.price) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" width="90">
|
||||
<template #default="{ row }">{{ row.stock ?? row.quantity ?? "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="推荐" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.recommend ? 'success' : 'info'">
|
||||
{{ row.recommend ? "是" : "否" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="售罄" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="isSellOut(row) ? 'danger' : 'info'">
|
||||
{{ isSellOut(row) ? "是" : "否" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="热度" width="100">
|
||||
<template #default="{ row }">{{ row.popularity ?? "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="!readonly" label="操作" width="280" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<a v-if="!row.recommend" class="link-text" @click="handleRecommend(row)">推荐</a>
|
||||
<a v-else class="link-text" @click="handleCancelRecommend(row)">取消推荐</a>
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="handleToggleSoldOut(row)">
|
||||
{{ isSellOut(row) ? "取消售罄" : "设为售罄" }}
|
||||
</a>
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="openPopularityModal(row)">设置热度</a>
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="handleRemove(row)">移除</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="goodsList.length" class="tips">
|
||||
前两个商品将自动被选为封面,伴随直播间在直播列表中显示
|
||||
</div>
|
||||
<el-empty v-if="!loading && !goodsList.length" description="暂无直播商品" />
|
||||
|
||||
<sku-select ref="skuSelect" @selectedGoodsData="selectedGoodsData" />
|
||||
|
||||
<el-dialog v-model="popularityVisible" title="设置商品热度" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="热度值">
|
||||
<el-input-number v-model="popularityForm.popularity" :min="0" style="width: 200px" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="popularityVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSavePopularity">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
cancelRecommendLiveGoods,
|
||||
getLiveGoodsList,
|
||||
getLiveGoodsPopularity,
|
||||
removeBatchLiveGoods,
|
||||
saveBatchLiveGoods,
|
||||
setLiveGoodsPopularity,
|
||||
setLiveGoodsSoldOut,
|
||||
setRecommendLiveGoods,
|
||||
} from "@/api/live";
|
||||
import skuSelect from "@/components/lili-dialog";
|
||||
|
||||
export default {
|
||||
name: "LiveGoods",
|
||||
components: { skuSelect },
|
||||
props: {
|
||||
liveId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
goodsList: [],
|
||||
selectedIds: [],
|
||||
popularityVisible: false,
|
||||
currentGoods: null,
|
||||
popularityForm: {
|
||||
popularity: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
liveId() {
|
||||
this.loadGoods();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadGoods();
|
||||
},
|
||||
methods: {
|
||||
formatPrice(price) {
|
||||
return this.$filters.unitPrice(price, "¥");
|
||||
},
|
||||
isSellOut(row) {
|
||||
return !!row.soldOutFlag;
|
||||
},
|
||||
buildLiveGoodsPayload(item) {
|
||||
const price = Number(item.price ?? 0);
|
||||
const originPrice = Number(
|
||||
item.originPrice ?? item.cost ?? item.originalPrice ?? price
|
||||
);
|
||||
return {
|
||||
liveRoomId: this.liveId,
|
||||
goodsId: item.goodsId,
|
||||
skuId: item.id || item.skuId,
|
||||
goodsName: item.goodsName || "",
|
||||
stock: String(item.quantity ?? item.stock ?? ""),
|
||||
price,
|
||||
thumbnail: item.thumbnail || "",
|
||||
salesCount: Number(item.salesCount ?? item.buyCount ?? 0),
|
||||
storeId: item.storeId || "",
|
||||
storeName: item.storeName || "",
|
||||
originPrice,
|
||||
sellPoint: item.sellPoint || item.sellingPoint || "",
|
||||
popularity: 0,
|
||||
hideFlag: false,
|
||||
canBuyFlag: true,
|
||||
soldOutFlag: false,
|
||||
recommend: false,
|
||||
};
|
||||
},
|
||||
loadGoods() {
|
||||
if (!this.liveId) return;
|
||||
this.loading = true;
|
||||
getLiveGoodsList(this.liveId)
|
||||
.then((res) => {
|
||||
this.loading = false;
|
||||
if (res.success) {
|
||||
this.goodsList = res.result || [];
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
openChooseGoods() {
|
||||
this.$refs.skuSelect.open("goods");
|
||||
const data = JSON.parse(JSON.stringify(this.goodsList));
|
||||
data.forEach((e) => {
|
||||
if (e.skuId) e.id = e.skuId;
|
||||
});
|
||||
this.$refs.skuSelect.goodsData = data;
|
||||
},
|
||||
selectedGoodsData(selected) {
|
||||
if (!selected?.length) return;
|
||||
const existingSkuIds = new Set(this.goodsList.map((g) => String(g.skuId)));
|
||||
const payload = selected
|
||||
.filter((item) => !existingSkuIds.has(String(item.id || item.skuId)))
|
||||
.map((item) => this.buildLiveGoodsPayload(item));
|
||||
if (!payload.length) {
|
||||
this.$Message.warning("所选商品均已添加");
|
||||
return;
|
||||
}
|
||||
saveBatchLiveGoods(payload).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("添加成功");
|
||||
this.loadGoods();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSelectionChange(rows) {
|
||||
this.selectedIds = (rows || []).map((r) => r.id);
|
||||
},
|
||||
handleRemove(record) {
|
||||
this.$Modal.confirm({
|
||||
title: "确认移除",
|
||||
content: "确定要移除该商品吗?",
|
||||
onOk: () => {
|
||||
return removeBatchLiveGoods([record.id]).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("移除成功");
|
||||
this.loadGoods();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
handleBatchRemove() {
|
||||
this.$Modal.confirm({
|
||||
title: "确认批量移除",
|
||||
content: `确定要移除选中的 ${this.selectedIds.length} 个商品吗?`,
|
||||
onOk: () => {
|
||||
return removeBatchLiveGoods(this.selectedIds).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("移除成功");
|
||||
this.selectedIds = [];
|
||||
this.loadGoods();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
handleRecommend(record) {
|
||||
setRecommendLiveGoods(record.id).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("已设为推荐");
|
||||
this.loadGoods();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleCancelRecommend(record) {
|
||||
cancelRecommendLiveGoods(record.id).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("已取消推荐");
|
||||
this.loadGoods();
|
||||
}
|
||||
});
|
||||
},
|
||||
handleToggleSoldOut(record) {
|
||||
const soldOutFlag = !this.isSellOut(record);
|
||||
setLiveGoodsSoldOut(record.id, soldOutFlag).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success(soldOutFlag ? "已设为售罄" : "已取消售罄");
|
||||
this.loadGoods();
|
||||
}
|
||||
});
|
||||
},
|
||||
openPopularityModal(record) {
|
||||
this.currentGoods = record;
|
||||
getLiveGoodsPopularity(record.id, this.liveId).then((res) => {
|
||||
this.popularityForm.popularity = res.success
|
||||
? res.result?.popularity ?? record.popularity ?? 0
|
||||
: record.popularity ?? 0;
|
||||
this.popularityVisible = true;
|
||||
});
|
||||
},
|
||||
handleSavePopularity() {
|
||||
if (!this.currentGoods) return;
|
||||
setLiveGoodsPopularity(
|
||||
this.currentGoods.id,
|
||||
this.liveId,
|
||||
this.popularityForm.popularity
|
||||
).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("热度设置成功");
|
||||
this.popularityVisible = false;
|
||||
this.loadGoods();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.flex-goods {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cover-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #409eff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.goods-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.price {
|
||||
color: #ff5c58;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tips {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
382
manager/src/views/live/components/LivePurchaseRecords.vue
Normal file
382
manager/src/views/live/components/LivePurchaseRecords.vue
Normal file
@@ -0,0 +1,382 @@
|
||||
<template>
|
||||
<div class="live-purchase-records" v-loading="loading">
|
||||
<div class="records-scroll">
|
||||
<div class="order-table-head">
|
||||
<div class="col-goods">订单详情</div>
|
||||
<div class="col-store">店铺名称</div>
|
||||
<div class="col-price">应付</div>
|
||||
<div class="col-member">买家/收货人</div>
|
||||
<div class="col-status">订单状态</div>
|
||||
</div>
|
||||
|
||||
<template v-if="orderList.length">
|
||||
<div v-for="order in orderList" :key="order.sn" class="order-block">
|
||||
<div class="order-block-head">
|
||||
<span class="order-sn">
|
||||
订单编号:{{ order.sn }}
|
||||
<el-icon class="copy-icon" @click="copySn(order.sn)"><DocumentCopy /></el-icon>
|
||||
</span>
|
||||
<span class="order-time">下单时间:{{ formatTime(order.createTime) }}</span>
|
||||
</div>
|
||||
<div class="order-block-body">
|
||||
<div class="col-goods">
|
||||
<div v-for="(item, index) in getOrderItems(order)" :key="index" class="goods-item">
|
||||
<el-image :src="item.image" fit="cover" class="goods-img">
|
||||
<template #error>
|
||||
<div class="goods-img-fallback" />
|
||||
</template>
|
||||
</el-image>
|
||||
<div class="goods-info">
|
||||
<div class="goods-name" :title="item.name">{{ item.name || "-" }}</div>
|
||||
<div class="goods-price">
|
||||
{{ formatPrice(item.goodsPrice) }}
|
||||
<span v-if="item.num">x{{ item.num }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-source">
|
||||
<span class="status-dot" :class="clientTypeDotClass(order.clientType)" />
|
||||
{{ clientTypeText(order.clientType) }}
|
||||
</div>
|
||||
<div class="col-store">
|
||||
<span class="link-text">{{ order.storeName || "-" }}</span>
|
||||
</div>
|
||||
<div class="col-price">{{ formatPrice(order.flowPrice) }}</div>
|
||||
<div class="col-member-id">{{ order.memberId || "-" }}</div>
|
||||
<div class="col-member">
|
||||
<div class="link-text">{{ order.memberName || "-" }}</div>
|
||||
</div>
|
||||
<div class="col-promo">
|
||||
<span class="status-dot success" />
|
||||
{{ orderPromotionText(order.orderPromotionType) }}
|
||||
</div>
|
||||
<div class="col-status">
|
||||
<span class="status-dot" :class="orderStatusDotClass(order.orderStatus)" />
|
||||
{{ orderStatusText(order.orderStatus) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else :image-size="48" description="暂无购买记录" />
|
||||
</div>
|
||||
|
||||
<div class="records-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.pageNumber"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[5, 10, 20]"
|
||||
:total="pagination.total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
size="small"
|
||||
@current-change="loadData"
|
||||
@size-change="onPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { DocumentCopy } from "@element-plus/icons-vue";
|
||||
import { queryLiveOrderPage } from "@/api/live";
|
||||
|
||||
export default {
|
||||
name: "LivePurchaseRecords",
|
||||
components: { DocumentCopy },
|
||||
props: {
|
||||
liveId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
orderList: [],
|
||||
pagination: {
|
||||
pageNumber: 1,
|
||||
pageSize: 5,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
liveId() {
|
||||
this.pagination.pageNumber = 1;
|
||||
this.loadData();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadData();
|
||||
},
|
||||
methods: {
|
||||
refresh() {
|
||||
this.loadData();
|
||||
},
|
||||
formatTime(time) {
|
||||
if (!time) return "-";
|
||||
if (typeof time === "string" && time.includes("-")) return time;
|
||||
const ts = typeof time === "number" ? (time > 1e12 ? time / 1000 : time) : time;
|
||||
return this.$filters.unixToDate(ts);
|
||||
},
|
||||
formatPrice(val) {
|
||||
return this.$filters.unitPrice(val, "¥");
|
||||
},
|
||||
getOrderItems(order) {
|
||||
if (order.orderItems?.length) return order.orderItems;
|
||||
if (order.groupGoodsId) {
|
||||
return [
|
||||
{
|
||||
image: order.groupImages,
|
||||
name: order.groupName,
|
||||
goodsPrice: order.groupGoodsPrice,
|
||||
num: order.groupNum,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
clientTypeText(v) {
|
||||
const map = { H5: "移动端", PC: "PC端", WECHAT_MP: "小程序端", APP: "移动应用端" };
|
||||
return map[v] || v || "-";
|
||||
},
|
||||
clientTypeDotClass(v) {
|
||||
const map = { H5: "warning", PC: "primary", WECHAT_MP: "success", APP: "info" };
|
||||
return map[v] || "info";
|
||||
},
|
||||
orderPromotionText(v) {
|
||||
const map = {
|
||||
NORMAL: "普通订单",
|
||||
PINTUAN: "拼团订单",
|
||||
GIFT: "赠品订单",
|
||||
POINTS: "积分订单",
|
||||
KANJIA: "砍价订单",
|
||||
};
|
||||
return map[v] || v || "-";
|
||||
},
|
||||
orderStatusText(v) {
|
||||
const map = {
|
||||
UNPAID: "未付款",
|
||||
PAID: "已付款",
|
||||
UNDELIVERED: "待发货",
|
||||
STAY_PICKED_UP: "待自提",
|
||||
PARTS_DELIVERED: "部分发货",
|
||||
DELIVERED: "已发货",
|
||||
COMPLETED: "已完成",
|
||||
TAKE: "待核验",
|
||||
CANCELLED: "已取消",
|
||||
};
|
||||
return map[v] || v || "-";
|
||||
},
|
||||
orderStatusDotClass(v) {
|
||||
const map = {
|
||||
UNPAID: "danger",
|
||||
PAID: "primary",
|
||||
UNDELIVERED: "info",
|
||||
STAY_PICKED_UP: "info",
|
||||
PARTS_DELIVERED: "warning",
|
||||
DELIVERED: "warning",
|
||||
COMPLETED: "success",
|
||||
TAKE: "warning",
|
||||
CANCELLED: "danger",
|
||||
};
|
||||
return map[v] || "info";
|
||||
},
|
||||
copySn(sn) {
|
||||
if (!sn) return;
|
||||
navigator.clipboard.writeText(sn);
|
||||
this.$Message.success("订单编号已复制");
|
||||
},
|
||||
onPageSizeChange() {
|
||||
this.pagination.pageNumber = 1;
|
||||
this.loadData();
|
||||
},
|
||||
loadData() {
|
||||
if (!this.liveId) return;
|
||||
this.loading = true;
|
||||
queryLiveOrderPage({
|
||||
liveRoomId: this.liveId,
|
||||
pageNumber: this.pagination.pageNumber,
|
||||
pageSize: this.pagination.pageSize,
|
||||
})
|
||||
.then((res) => {
|
||||
this.loading = false;
|
||||
if (res.success) {
|
||||
this.orderList = res.result?.records || [];
|
||||
this.pagination.total = res.result?.total || 0;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.live-purchase-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.records-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.order-table-head,
|
||||
.order-block-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 2fr) 90px 100px 90px 120px 110px 100px 90px;
|
||||
min-width: 920px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.order-table-head {
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-bottom: none;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.order-block {
|
||||
border: 1px solid #ebeef5;
|
||||
border-top: none;
|
||||
|
||||
&:first-of-type {
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
}
|
||||
|
||||
.order-block-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 8px 12px;
|
||||
background: #f5f7fa;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.order-sn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
cursor: pointer;
|
||||
color: #909399;
|
||||
|
||||
&:hover {
|
||||
color: $theme_color;
|
||||
}
|
||||
}
|
||||
|
||||
.order-block-body {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.col-goods {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.goods-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.goods-img,
|
||||
.goods-img-fallback {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.goods-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.goods-name {
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.goods-price {
|
||||
margin-top: 4px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.col-source,
|
||||
.col-store,
|
||||
.col-price,
|
||||
.col-member-id,
|
||||
.col-member,
|
||||
.col-promo,
|
||||
.col-status {
|
||||
padding: 0 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.col-price {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
color: #409eff;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
background: #909399;
|
||||
|
||||
&.primary {
|
||||
background: #409eff;
|
||||
}
|
||||
&.success {
|
||||
background: #67c23a;
|
||||
}
|
||||
&.warning {
|
||||
background: #e6a23c;
|
||||
}
|
||||
&.danger {
|
||||
background: #f56c6c;
|
||||
}
|
||||
&.info {
|
||||
background: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.records-pager {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 12px;
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
905
manager/src/views/live/control-panel.vue
Normal file
905
manager/src/views/live/control-panel.vue
Normal file
@@ -0,0 +1,905 @@
|
||||
<template>
|
||||
<div class="live-control-panel" v-loading="loading">
|
||||
<div class="control-top-bar">
|
||||
<div class="control-title">
|
||||
<span class="title-text">直播中控台</span>
|
||||
<span v-if="liveDetail.title" class="title-sub">{{ liveDetail.title }}</span>
|
||||
<el-tag :type="liveStatusTagType(liveStatus)" size="small">{{ liveStatusText(liveStatus) }}</el-tag>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<el-button v-if="liveDetail.liveStatus === 'NEW'" type="primary" @click="handleStart">
|
||||
开始直播
|
||||
</el-button>
|
||||
<el-button v-if="liveDetail.liveStatus === 'LIVING'" type="warning" @click="handleEnd">
|
||||
结束直播
|
||||
</el-button>
|
||||
<el-button @click="handleShare">复制分享链接</el-button>
|
||||
<el-button @click="refreshAll">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-grid">
|
||||
<div class="col-left">
|
||||
<div class="area-preview panel-box">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">直播预览</span>
|
||||
<div class="ratio-switch">
|
||||
<button
|
||||
type="button"
|
||||
class="ratio-btn"
|
||||
:class="{ active: aspectRatio === '16:9' }"
|
||||
@click="aspectRatio = '16:9'"
|
||||
>
|
||||
16:9
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ratio-btn"
|
||||
:class="{ active: aspectRatio === '4:3' }"
|
||||
@click="aspectRatio = '4:3'"
|
||||
>
|
||||
4:3
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="player-box" :class="aspectRatio === '16:9' ? 'ratio-169' : 'ratio-43'">
|
||||
<div v-if="videoUrl" id="live-dplayer" class="player-inner" />
|
||||
<div v-else class="player-empty">
|
||||
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
||||
<span>暂无拉流地址</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="area-basic panel-box">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">基本信息</span>
|
||||
</div>
|
||||
<el-descriptions :column="1" border size="small" label-width="100px" class="basic-desc">
|
||||
<el-descriptions-item label="店铺">
|
||||
{{ liveDetail.storeName || liveDetail.streamerName || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="liveStatusTagType(liveStatus)" size="small">
|
||||
{{ liveStatusText(liveStatus) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">
|
||||
{{ formatTime(liveDetail.startTime) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间">
|
||||
{{ formatTime(liveDetail.endTime) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="推流服务器">
|
||||
<div class="copy-field">
|
||||
<span class="copy-field-text">{{ liveDetail.pushStreamServer || "-" }}</span>
|
||||
<el-button
|
||||
v-if="liveDetail.pushStreamServer"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="copyText(liveDetail.pushStreamServer, '推流服务器')"
|
||||
>
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="推流码">
|
||||
<div class="copy-field">
|
||||
<span class="copy-field-text">{{ liveDetail.pushStreamCode || "-" }}</span>
|
||||
<el-button
|
||||
v-if="liveDetail.pushStreamCode"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="copyText(liveDetail.pushStreamCode, '推流码')"
|
||||
>
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
复制
|
||||
</el-button>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-right">
|
||||
<div class="col-right-top">
|
||||
<div class="area-chat panel-box">
|
||||
<el-tabs v-model="chatTab" class="panel-tabs fill-tabs">
|
||||
<el-tab-pane label="互动聊天" name="chat">
|
||||
<div class="tab-body chat-placeholder">
|
||||
<el-empty :image-size="64" description="暂无聊天消息" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="在线用户" name="online">
|
||||
<div class="tab-body online-tab-body">
|
||||
<div class="online-table-wrap">
|
||||
<el-table v-loading="onlineLoading" :data="onlineUserList" size="small" style="width: 100%">
|
||||
<el-table-column prop="userName" label="用户" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.muteFlag ? 'danger' : 'success'" size="small">
|
||||
{{ row.muteFlag ? "禁言" : "正常" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="online-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="onlinePagination.pageNumber"
|
||||
v-model:page-size="onlinePagination.pageSize"
|
||||
:total="onlinePagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
size="small"
|
||||
@current-change="loadOnlineUsers"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<div class="area-stats panel-box">
|
||||
<el-tabs v-model="statsTab" class="panel-tabs fill-tabs">
|
||||
<el-tab-pane label="通用数据" name="general">
|
||||
<div class="tab-body stat-tab-body">
|
||||
<div class="data-card">
|
||||
<div class="data-card-hero">
|
||||
<div class="data-label">当前观看人数</div>
|
||||
<div class="data-hero-value">{{ currentViewers }}</div>
|
||||
</div>
|
||||
<div class="data-grid">
|
||||
<div class="data-item">
|
||||
<div class="data-label">评论人数</div>
|
||||
<div class="data-value">{{ commentPeopleNumber }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">互动率</div>
|
||||
<div class="data-value">{{ interactionRate }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">累计观看人数</div>
|
||||
<div class="data-value">{{ cumulativeViewers }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">直播时长</div>
|
||||
<div class="data-value">{{ liveDuration }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="电商数据" name="commerce">
|
||||
<div class="tab-body stat-tab-body">
|
||||
<div class="data-card">
|
||||
<div class="data-card-hero">
|
||||
<div class="data-label">下单GMV</div>
|
||||
<div class="data-hero-value">¥{{ formatAmount(orderStats.totalAmount) }}</div>
|
||||
</div>
|
||||
<div class="data-grid">
|
||||
<div class="data-item">
|
||||
<div class="data-label">销量</div>
|
||||
<div class="data-value">{{ orderStats.salesVolume ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">成交人数</div>
|
||||
<div class="data-value">{{ orderStats.dealUserCount ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">客单价</div>
|
||||
<div class="data-value">¥{{ formatAmount(orderStats.averageOrderValue) }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">成交转化率</div>
|
||||
<div class="data-value">{{ dealConversionRate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="购买记录" name="purchase">
|
||||
<div class="tab-body purchase-tab-body">
|
||||
<LivePurchaseRecords ref="purchaseRecords" :live-id="liveId" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="area-manage panel-box">
|
||||
<el-tabs v-model="manageTab" class="panel-tabs fill-tabs">
|
||||
<el-tab-pane label="评论审核" name="comment">
|
||||
<div class="tab-body manage-tab-body">
|
||||
<CommentReview :live-id="liveId" embedded />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="直播商品" name="goods">
|
||||
<div class="tab-body manage-tab-body">
|
||||
<LiveGoods :live-id="liveId" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="直播优惠券" name="coupon">
|
||||
<div class="tab-body manage-tab-body">
|
||||
<Coupon :live-id="liveId" :live-room-name="liveDetail.title" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DPlayer from "dplayer";
|
||||
import { DocumentCopy, Loading, Refresh } from "@element-plus/icons-vue";
|
||||
import {
|
||||
endLive,
|
||||
getLiveInfo,
|
||||
getLiveOrderStatistics,
|
||||
liveUserList,
|
||||
startLive,
|
||||
} from "@/api/live";
|
||||
import { getSetting } from "@/api/index";
|
||||
import config from "@/config";
|
||||
import CommentReview from "./components/CommentReview.vue";
|
||||
import Coupon from "./components/Coupon.vue";
|
||||
import LiveGoods from "./components/LiveGoods.vue";
|
||||
import LivePurchaseRecords from "./components/LivePurchaseRecords.vue";
|
||||
|
||||
export default {
|
||||
name: "live-control-panel",
|
||||
components: { CommentReview, Coupon, LiveGoods, LivePurchaseRecords, DocumentCopy, Loading, Refresh },
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
liveId: "",
|
||||
videoUrl: "",
|
||||
wapUrl: "",
|
||||
aspectRatio: "16:9",
|
||||
chatTab: "chat",
|
||||
statsTab: "general",
|
||||
manageTab: "comment",
|
||||
liveDetail: {},
|
||||
orderStats: {},
|
||||
onlineUserList: [],
|
||||
onlineLoading: false,
|
||||
onlinePagination: {
|
||||
pageNumber: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
},
|
||||
player: null,
|
||||
nowTick: Date.now(),
|
||||
durationTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
liveStatus() {
|
||||
return this.liveDetail.liveStatus || this.liveDetail.status || "";
|
||||
},
|
||||
cumulativeViewers() {
|
||||
return Number(this.liveDetail.actualViewNumber ?? 0);
|
||||
},
|
||||
currentViewers() {
|
||||
return this.cumulativeViewers;
|
||||
},
|
||||
commentPeopleNumber() {
|
||||
return Number(this.liveDetail.commentPeopleNumber ?? 0);
|
||||
},
|
||||
interactionRate() {
|
||||
if (!this.cumulativeViewers) return "0.00%";
|
||||
return `${((this.commentPeopleNumber / this.cumulativeViewers) * 100).toFixed(2)}%`;
|
||||
},
|
||||
dealConversionRate() {
|
||||
if (!this.cumulativeViewers) return "0%";
|
||||
const dealCount = Number(this.orderStats.dealUserCount ?? 0);
|
||||
return `${((dealCount / this.cumulativeViewers) * 100).toFixed(0)}%`;
|
||||
},
|
||||
liveDuration() {
|
||||
void this.nowTick;
|
||||
const startMs = this.parseDateTime(this.liveDetail.startTime);
|
||||
if (!startMs || Date.now() < startMs) return "00:00:00";
|
||||
let endMs = Date.now();
|
||||
if (this.liveStatus === "ENDED") {
|
||||
const endTimeMs = this.parseDateTime(this.liveDetail.endTime);
|
||||
if (endTimeMs && endTimeMs > startMs) {
|
||||
endMs = endTimeMs;
|
||||
}
|
||||
}
|
||||
return this.formatDuration(endMs - startMs);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
videoUrl() {
|
||||
this.initPlayer();
|
||||
},
|
||||
aspectRatio() {
|
||||
this.$nextTick(() => this.initPlayer());
|
||||
},
|
||||
chatTab(tab) {
|
||||
if (tab === "online") this.loadOnlineUsers();
|
||||
},
|
||||
statsTab(tab) {
|
||||
if (tab === "purchase") this.$refs.purchaseRecords?.refresh();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.liveId = String(this.$route.query.id || "");
|
||||
if (!this.liveId) {
|
||||
this.$Message.error("缺少直播ID");
|
||||
return;
|
||||
}
|
||||
this.loadWapSetting();
|
||||
this.refreshAll();
|
||||
this.startDurationTimer();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.destroyPlayer();
|
||||
this.stopDurationTimer();
|
||||
},
|
||||
methods: {
|
||||
parseDateTime(value) {
|
||||
if (!value) return null;
|
||||
if (typeof value === "string" && value.includes("-")) {
|
||||
return new Date(value.replace(/-/g, "/")).getTime();
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return value > 1e12 ? value : value * 1000;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
formatDuration(ms) {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
},
|
||||
startDurationTimer() {
|
||||
this.stopDurationTimer();
|
||||
this.durationTimer = setInterval(() => {
|
||||
this.nowTick = Date.now();
|
||||
}, 1000);
|
||||
},
|
||||
stopDurationTimer() {
|
||||
if (this.durationTimer) {
|
||||
clearInterval(this.durationTimer);
|
||||
this.durationTimer = null;
|
||||
}
|
||||
},
|
||||
formatTime(time) {
|
||||
if (!time) return "-";
|
||||
if (typeof time === "string" && time.includes("-")) return time;
|
||||
const ts = typeof time === "number" ? (time > 1e12 ? time / 1000 : time) : time;
|
||||
return this.$filters.unixToDate(ts);
|
||||
},
|
||||
formatAmount(val) {
|
||||
return Number(val || 0).toFixed(2);
|
||||
},
|
||||
liveStatusText(status) {
|
||||
const map = { NEW: "未开始", LIVING: "直播中", ENDED: "已结束" };
|
||||
return map[status] || "未知";
|
||||
},
|
||||
liveStatusTagType(status) {
|
||||
const map = { NEW: "info", LIVING: "success", ENDED: "warning" };
|
||||
return map[status] || "info";
|
||||
},
|
||||
refreshAll() {
|
||||
this.loadDetail();
|
||||
this.loadOrderStats();
|
||||
this.$refs.purchaseRecords?.refresh();
|
||||
if (this.chatTab === "online") this.loadOnlineUsers();
|
||||
},
|
||||
destroyPlayer() {
|
||||
if (this.player) {
|
||||
this.player.destroy();
|
||||
this.player = null;
|
||||
}
|
||||
},
|
||||
initPlayer() {
|
||||
this.destroyPlayer();
|
||||
if (!this.videoUrl) return;
|
||||
this.$nextTick(() => {
|
||||
const container = document.getElementById("live-dplayer");
|
||||
if (!container) return;
|
||||
this.player = new DPlayer({
|
||||
container,
|
||||
live: this.liveStatus === "LIVING",
|
||||
autoplay: true,
|
||||
video: {
|
||||
url: this.videoUrl,
|
||||
type: "auto",
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
loadDetail() {
|
||||
this.loading = true;
|
||||
getLiveInfo(this.liveId)
|
||||
.then((res) => {
|
||||
this.loading = false;
|
||||
if (res.success) {
|
||||
const data = res.result || {};
|
||||
this.liveDetail = data;
|
||||
this.videoUrl = data.pullStreamUrl || data.streamUrl || "";
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
loadOrderStats() {
|
||||
getLiveOrderStatistics(this.liveId).then((res) => {
|
||||
if (res.success) {
|
||||
this.orderStats = res.result || {};
|
||||
}
|
||||
});
|
||||
},
|
||||
loadOnlineUsers() {
|
||||
this.onlineLoading = true;
|
||||
liveUserList({
|
||||
liveRoomId: this.liveId,
|
||||
pageNumber: this.onlinePagination.pageNumber,
|
||||
pageSize: this.onlinePagination.pageSize,
|
||||
})
|
||||
.then((res) => {
|
||||
this.onlineLoading = false;
|
||||
if (res.success) {
|
||||
this.onlineUserList = res.result?.records || [];
|
||||
this.onlinePagination.total = res.result?.total || 0;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.onlineLoading = false;
|
||||
});
|
||||
},
|
||||
buildShareUrl() {
|
||||
const base = (this.wapUrl || BASE?.WAP_URL || config.website || "").replace(/\/?$/, "/");
|
||||
return `${base}pages/promotions/live/detail?id=${this.liveId}`;
|
||||
},
|
||||
handleShare() {
|
||||
const url = this.buildShareUrl();
|
||||
if (!url) {
|
||||
this.$Message.warning("未配置移动端地址");
|
||||
return;
|
||||
}
|
||||
this.copyText(url, "分享链接");
|
||||
},
|
||||
copyText(text, label = "内容") {
|
||||
if (!text) {
|
||||
this.$Message.warning(`${label}为空,无法复制`);
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text);
|
||||
this.$Message.success(`${label}已复制`);
|
||||
},
|
||||
handleStart() {
|
||||
this.$Modal.confirm({
|
||||
title: "确认开播",
|
||||
content: "确定要开始该直播吗?",
|
||||
onOk: () => {
|
||||
return startLive(this.liveId).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("开播成功");
|
||||
this.refreshAll();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
handleEnd() {
|
||||
this.$Modal.confirm({
|
||||
title: "确认结束",
|
||||
content: "确定要结束该直播吗?",
|
||||
onOk: () => {
|
||||
return endLive(this.liveId).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("直播已结束");
|
||||
this.destroyPlayer();
|
||||
this.refreshAll();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
loadWapSetting() {
|
||||
getSetting("BASE_SETTING")
|
||||
.then((res) => {
|
||||
if (res.success && res.result?.buyerDomain) {
|
||||
this.wapUrl = res.result.buyerDomain;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.live-control-panel {
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.control-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.control-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.title-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.title-sub {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
|
||||
.control-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 40fr) minmax(0, 60fr);
|
||||
gap: 16px;
|
||||
height: calc(100vh - 120px);
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
.col-left {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 7fr) minmax(0, 3fr);
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.col-right {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 2fr) minmax(0, 3fr);
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.col-right-top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.area-preview {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.area-basic {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.area-chat,
|
||||
.area-stats {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.area-manage {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-box {
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.area-basic,
|
||||
.area-manage {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.area-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.ratio-switch {
|
||||
display: flex;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ratio-btn {
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
background: #fff;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
& + & {
|
||||
border-left: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $theme_color;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.player-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: #000;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.player-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.basic-panel .basic-desc,
|
||||
.area-basic .basic-desc {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
|
||||
:deep(.el-descriptions__body) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__label) {
|
||||
width: 100px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__content) {
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.copy-field-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fill-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item.is-active) {
|
||||
color: $theme_color;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__active-bar) {
|
||||
background-color: $theme_color;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tab-pane) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-body {
|
||||
padding: 12px 16px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.area-chat .tab-body,
|
||||
.area-stats .tab-body {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.online-tab-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.online-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.online-pager {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 12px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.stat-tab-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 16px 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.area-stats .data-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.area-stats .data-card-hero {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.area-stats .data-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.area-stats .data-hero-value {
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: #67c23a;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.area-stats .data-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px 20px;
|
||||
}
|
||||
|
||||
.area-stats .data-item {
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
background: #f8faf9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.area-stats .data-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.purchase-tab-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
padding: 8px 12px 12px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.manage-tab-body {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.area-manage .fill-tabs {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.chat-placeholder {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.control-grid {
|
||||
grid-template-columns: 1fr;
|
||||
height: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.col-left,
|
||||
.col-right {
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.col-right-top {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.player-box {
|
||||
min-height: 240px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
123
manager/src/views/live/detail.vue
Normal file
123
manager/src/views/live/detail.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="live-detail">
|
||||
<el-card v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>直播详情</span>
|
||||
<el-button @click="handleBack">返回列表</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="直播标题">{{ detail.title || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="直播状态">
|
||||
<el-tag :type="liveStatusTagType(liveStatus)">{{ liveStatusText(liveStatus) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺名称">
|
||||
{{ detail.storeName || detail.streamerName || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">{{ formatTime(detail.startTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间">{{ formatTime(detail.endTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="推流服务器">{{ detail.pushStreamServer || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="推流码">{{ detail.pushStreamCode || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="拉流地址">
|
||||
{{ detail.pullStreamUrl || detail.streamUrl || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="直播封面" :span="2">
|
||||
<el-image
|
||||
v-if="detail.coverImg"
|
||||
:src="detail.coverImg"
|
||||
style="width: 200px"
|
||||
fit="contain"
|
||||
/>
|
||||
<span v-else class="text-muted">暂无封面</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="goods-section">
|
||||
<h4>直播商品</h4>
|
||||
<LiveGoods :live-id="liveId" readonly />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getLiveInfo } from "@/api/live";
|
||||
import LiveGoods from "./components/LiveGoods.vue";
|
||||
|
||||
export default {
|
||||
name: "live-detail",
|
||||
components: { LiveGoods },
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
liveId: "",
|
||||
detail: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
liveStatus() {
|
||||
return this.detail.liveStatus || this.detail.status || "";
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.liveId = String(this.$route.query.id || "");
|
||||
if (!this.liveId) {
|
||||
this.$Message.error("缺少直播ID");
|
||||
this.handleBack();
|
||||
return;
|
||||
}
|
||||
this.loadDetail();
|
||||
},
|
||||
methods: {
|
||||
formatTime(time) {
|
||||
if (!time) return "-";
|
||||
return this.$filters.unixToDate(time);
|
||||
},
|
||||
liveStatusText(status) {
|
||||
const map = { NEW: "未开始", LIVING: "直播中", ENDED: "已结束" };
|
||||
return map[status] || "未知";
|
||||
},
|
||||
liveStatusTagType(status) {
|
||||
const map = { NEW: "info", LIVING: "success", ENDED: "warning" };
|
||||
return map[status] || "info";
|
||||
},
|
||||
loadDetail() {
|
||||
this.loading = true;
|
||||
getLiveInfo(this.liveId)
|
||||
.then((res) => {
|
||||
this.loading = false;
|
||||
if (res.success) {
|
||||
this.detail = res.result || {};
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
this.handleBack();
|
||||
});
|
||||
},
|
||||
handleBack() {
|
||||
this.$router.push({ path: "/live-list" });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.goods-section {
|
||||
margin-top: 24px;
|
||||
h4 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
.text-muted {
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
318
manager/src/views/live/edit.vue
Normal file
318
manager/src/views/live/edit.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="live-form-page">
|
||||
<el-card v-loading="loading">
|
||||
<template #header>
|
||||
<span class="page-title">编辑直播</span>
|
||||
</template>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-width="100px" class="live-form">
|
||||
<el-form-item v-if="form.liveStatus !== 'NEW'" label="直播状态">
|
||||
<el-tag :type="liveStatusTagType(form.liveStatus)">{{ liveStatusText(form.liveStatus) }}</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="直播标题" prop="title">
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="请输入直播标题"
|
||||
maxlength="34"
|
||||
show-word-limit
|
||||
:disabled="form.liveStatus !== 'NEW'"
|
||||
class="form-input"
|
||||
/>
|
||||
<div class="form-tip">直播间名字,最短3个汉字,最长17个汉字,1个汉字相当于2个字符</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示模式" prop="displayMode">
|
||||
<el-radio-group v-model="form.displayMode" :disabled="form.liveStatus !== 'NEW'">
|
||||
<el-radio value="0">横屏</el-radio>
|
||||
<el-radio value="1">竖屏</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="开始时间" prop="startTime">
|
||||
<el-date-picker
|
||||
v-model="form.startTime"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
placeholder="请选择日期"
|
||||
:disabled="form.liveStatus !== 'NEW'"
|
||||
class="form-input"
|
||||
/>
|
||||
<div class="form-tip">请选择直播开始时间</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="直播封面" prop="coverImg">
|
||||
<div class="cover-uploader">
|
||||
<div class="cover-preview">
|
||||
<el-image v-if="form.coverImg" :src="form.coverImg" fit="cover" class="cover-image" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<el-icon :size="32"><Picture /></el-icon>
|
||||
<span>暂无封面图片</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button v-if="form.liveStatus === 'NEW'" @click="openCoverPicker">
|
||||
<el-icon><Picture /></el-icon>
|
||||
选择图片
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="form-tip">建议尺寸 1080x1920,大小不超过1M</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="直播简介" prop="liveIntroduce">
|
||||
<el-input
|
||||
v-model="form.liveIntroduce"
|
||||
type="textarea"
|
||||
placeholder="请输入直播简介(选填)"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
:rows="4"
|
||||
:disabled="form.liveStatus !== 'NEW'"
|
||||
class="form-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="form-actions">
|
||||
<el-button
|
||||
v-if="form.liveStatus === 'NEW'"
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
提交
|
||||
</el-button>
|
||||
<el-button @click="handleBack">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="picModelFlag" width="1200px" append-to-body destroy-on-close>
|
||||
<ossManage
|
||||
ref="ossManage"
|
||||
:is-component="true"
|
||||
:initialize="picModelFlag"
|
||||
@callback="callbackSelected"
|
||||
/>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Picture } from "@element-plus/icons-vue";
|
||||
import { getLiveInfo, updateLive } from "@/api/live";
|
||||
import ossManage from "@/views/sys/oss-manage/ossManage";
|
||||
|
||||
function getCharLength(str) {
|
||||
if (!str) return 0;
|
||||
let len = 0;
|
||||
for (const ch of str) {
|
||||
len += /[\u4e00-\u9fa5]/.test(ch) ? 2 : 1;
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
function buildEndTime(startTime, hours = 24) {
|
||||
const date = new Date(startTime.replace(/-/g, "/"));
|
||||
date.setHours(date.getHours() + hours);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
function normalizeDisplayMode(mode) {
|
||||
const map = { HORIZONTAL: "0", VERTICAL: "1", TRIPLE: "2", "0": "0", "1": "1", "2": "2" };
|
||||
return map[mode] ?? "0";
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "";
|
||||
if (typeof value === "string" && value.includes("-")) {
|
||||
return value.length === 16 ? `${value}:00` : value;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
const ts = value > 1e12 ? Math.floor(value / 1000) : value;
|
||||
const d = new Date(ts * 1000);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "live-edit",
|
||||
components: { Picture, ossManage },
|
||||
data() {
|
||||
const validateTitle = (rule, value, callback) => {
|
||||
const len = getCharLength(value);
|
||||
if (len < 6) {
|
||||
callback(new Error("直播间名字最短3个汉字"));
|
||||
return;
|
||||
}
|
||||
if (len > 34) {
|
||||
callback(new Error("直播间名字最长17个汉字"));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
return {
|
||||
picModelFlag: false,
|
||||
loading: false,
|
||||
submitting: false,
|
||||
liveId: "",
|
||||
endTime: "",
|
||||
form: {
|
||||
title: "",
|
||||
displayMode: "0",
|
||||
startTime: "",
|
||||
coverImg: "",
|
||||
liveIntroduce: "",
|
||||
liveStatus: "NEW",
|
||||
},
|
||||
rules: {
|
||||
title: [
|
||||
{ required: true, message: "请输入直播标题", trigger: "blur" },
|
||||
{ validator: validateTitle, trigger: "blur" },
|
||||
],
|
||||
displayMode: [{ required: true, message: "请选择显示模式", trigger: "change" }],
|
||||
startTime: [{ required: true, message: "请选择直播开始时间", trigger: "change" }],
|
||||
coverImg: [{ required: true, message: "请上传直播封面", trigger: "change" }],
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.liveId = String(this.$route.query.id || "");
|
||||
if (!this.liveId) {
|
||||
this.$Message.error("缺少直播ID");
|
||||
this.handleBack();
|
||||
return;
|
||||
}
|
||||
this.loadDetail();
|
||||
},
|
||||
methods: {
|
||||
liveStatusText(status) {
|
||||
const map = { NEW: "未开始", LIVING: "直播中", ENDED: "已结束" };
|
||||
return map[status] || "未知";
|
||||
},
|
||||
liveStatusTagType(status) {
|
||||
const map = { NEW: "info", LIVING: "success", ENDED: "warning" };
|
||||
return map[status] || "info";
|
||||
},
|
||||
openCoverPicker() {
|
||||
this.picModelFlag = true;
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.ossManage) {
|
||||
this.$refs.ossManage.selectImage = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
callbackSelected(val) {
|
||||
this.picModelFlag = false;
|
||||
if (val?.url) {
|
||||
this.form.coverImg = val.url;
|
||||
this.$refs.form?.validateField("coverImg");
|
||||
}
|
||||
},
|
||||
loadDetail() {
|
||||
this.loading = true;
|
||||
getLiveInfo(this.liveId)
|
||||
.then((res) => {
|
||||
this.loading = false;
|
||||
if (res.success) {
|
||||
const data = res.result || {};
|
||||
this.form.title = data.title || "";
|
||||
this.form.displayMode = normalizeDisplayMode(data.displayMode);
|
||||
this.form.coverImg = data.coverImg || "";
|
||||
this.form.liveIntroduce = data.liveIntroduce || "";
|
||||
this.form.liveStatus = data.liveStatus || data.status || "NEW";
|
||||
this.endTime = formatDateTime(data.endTime);
|
||||
this.form.startTime = formatDateTime(data.startTime);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
handleSubmit() {
|
||||
this.$refs.form.validate((valid) => {
|
||||
if (!valid) return;
|
||||
let endTime = this.endTime;
|
||||
if (!endTime || endTime <= this.form.startTime) {
|
||||
endTime = buildEndTime(this.form.startTime);
|
||||
}
|
||||
this.submitting = true;
|
||||
updateLive(this.liveId, {
|
||||
title: this.form.title,
|
||||
displayMode: this.form.displayMode,
|
||||
coverImg: this.form.coverImg,
|
||||
startTime: this.form.startTime,
|
||||
endTime,
|
||||
liveIntroduce: this.form.liveIntroduce,
|
||||
})
|
||||
.then((res) => {
|
||||
this.submitting = false;
|
||||
if (res.success) {
|
||||
this.$Message.success("保存成功");
|
||||
this.$router.push({ path: "/live-list" });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.submitting = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
handleBack() {
|
||||
this.$router.push({ path: "/live-list" });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.live-form-page {
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
.live-form {
|
||||
max-width: 720px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.form-input {
|
||||
width: 460px;
|
||||
}
|
||||
.form-tip {
|
||||
margin-top: 6px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cover-uploader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.cover-preview {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 8px;
|
||||
:deep(.el-form-item__content) {
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
319
manager/src/views/live/list.vue
Normal file
319
manager/src/views/live/list.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="live-list search">
|
||||
<el-card>
|
||||
<el-form
|
||||
ref="searchForm"
|
||||
:model="searchForm"
|
||||
inline
|
||||
label-width="90px"
|
||||
class="search-form"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<el-form-item label="直播标题" prop="title">
|
||||
<el-input
|
||||
v-model="searchForm.title"
|
||||
placeholder="请输入直播标题"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="店铺名称" prop="storeName">
|
||||
<el-input
|
||||
v-model="searchForm.storeName"
|
||||
placeholder="请输入店铺名称"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="mt_10">
|
||||
<el-tabs v-model="activeStatus" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="全部" name="" />
|
||||
<el-tab-pane label="未开始" name="NEW" />
|
||||
<el-tab-pane label="直播中" name="LIVING" />
|
||||
<el-tab-pane label="已结束" name="ENDED" />
|
||||
</el-tabs>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">创建直播</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="table"
|
||||
v-loading="loading"
|
||||
border
|
||||
:data="data"
|
||||
class="mt_10"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="id" label="直播间ID" width="120" />
|
||||
<el-table-column label="直播封面" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row && row.coverImg"
|
||||
:src="row.coverImg"
|
||||
style="width: 80px; height: 60px"
|
||||
fit="cover"
|
||||
/>
|
||||
<span v-else class="text-muted">暂无封面</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="直播标题" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="直播状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row" :type="liveStatusTagType(row.liveStatus)">
|
||||
{{ liveStatusText(row.liveStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开始时间" width="170">
|
||||
<template #default="{ row }">{{ row.startTime }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束时间" width="170">
|
||||
<template #default="{ row }">{{ row.endTime }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="storeName" label="店铺名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="300" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row">
|
||||
<!-- <a class="link-text" @click="handleView(row)">查看</a>
|
||||
<span class="op-split">|</span> -->
|
||||
<template v-if="row.liveStatus === 'NEW'">
|
||||
<a class="link-text" @click="handleEdit(row)">编辑</a>
|
||||
<span class="op-split">|</span>
|
||||
</template>
|
||||
<a class="link-text" @click="handleControl(row)">中控台</a>
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="handleShare(row)">分享</a>
|
||||
<template v-if="row.liveStatus === 'NEW'">
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="handleStart(row)">开播</a>
|
||||
</template>
|
||||
<template v-if="row.liveStatus === 'LIVING'">
|
||||
<span class="op-split">|</span>
|
||||
<a class="link-text" @click="handleEnd(row)">结束</a>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt_10" style="display: flex; justify-content: flex-end">
|
||||
<el-pagination
|
||||
v-model:current-page="searchForm.pageNumber"
|
||||
v-model:page-size="searchForm.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
size="small"
|
||||
@current-change="getData"
|
||||
@size-change="changePageSize"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="shareVisible" title="分享直播" width="560px">
|
||||
<p class="share-title">{{ shareTitle }}</p>
|
||||
<el-form label-width="60px">
|
||||
<el-form-item label="H5">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="shareH5Url" readonly />
|
||||
<el-button type="primary" @click="copyShareUrl">复制</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="shareVisible = false">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { queryLivePage, startLive, endLive } from "@/api/live";
|
||||
import { getSetting } from "@/api/index";
|
||||
import config from "@/config";
|
||||
|
||||
export default {
|
||||
name: "live-list",
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
data: [],
|
||||
total: 0,
|
||||
activeStatus: "",
|
||||
searchForm: {
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
title: "",
|
||||
storeName: "",
|
||||
},
|
||||
shareVisible: false,
|
||||
shareTitle: "",
|
||||
shareH5Url: "",
|
||||
wapUrl: "",
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadWapSetting();
|
||||
this.getData();
|
||||
},
|
||||
methods: {
|
||||
formatTime(time) {
|
||||
if (!time) return "-";
|
||||
return this.$filters.unixToDate(time);
|
||||
},
|
||||
liveStatusText(status) {
|
||||
const map = { NEW: "未开始", LIVING: "直播中", ENDED: "已结束" };
|
||||
return map[status] || "未知";
|
||||
},
|
||||
liveStatusTagType(status) {
|
||||
const map = { NEW: "info", LIVING: "success", ENDED: "warning" };
|
||||
return map[status] || "info";
|
||||
},
|
||||
buildParams() {
|
||||
const params = {
|
||||
pageNumber: this.searchForm.pageNumber,
|
||||
pageSize: this.searchForm.pageSize,
|
||||
};
|
||||
if (this.searchForm.title) params.title = this.searchForm.title;
|
||||
if (this.searchForm.storeName) params.storeName = this.searchForm.storeName;
|
||||
if (this.activeStatus) params.liveStatus = this.activeStatus;
|
||||
return params;
|
||||
},
|
||||
getData() {
|
||||
this.loading = true;
|
||||
queryLivePage(this.buildParams())
|
||||
.then((res) => {
|
||||
this.loading = false;
|
||||
if (res.success) {
|
||||
this.data = res.result?.records || [];
|
||||
this.total = res.result?.total || 0;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
},
|
||||
handleSearch() {
|
||||
this.searchForm.pageNumber = 1;
|
||||
this.getData();
|
||||
},
|
||||
handleReset() {
|
||||
this.searchForm.title = "";
|
||||
this.searchForm.storeName = "";
|
||||
this.searchForm.pageNumber = 1;
|
||||
this.getData();
|
||||
},
|
||||
handleTabChange() {
|
||||
this.searchForm.pageNumber = 1;
|
||||
this.getData();
|
||||
},
|
||||
changePageSize() {
|
||||
this.searchForm.pageNumber = 1;
|
||||
this.getData();
|
||||
},
|
||||
handleAdd() {
|
||||
this.$router.push({ path: "/live-add" });
|
||||
},
|
||||
handleView(row) {
|
||||
this.$router.push({ path: "/live-manage-detail", query: { id: row.id } });
|
||||
},
|
||||
handleEdit(row) {
|
||||
this.$router.push({ path: "/live-edit", query: { id: row.id } });
|
||||
},
|
||||
handleControl(row) {
|
||||
const route = this.$router.resolve({
|
||||
path: "/live-control-panel",
|
||||
query: { id: row.id },
|
||||
});
|
||||
window.open(route.href, "_blank");
|
||||
},
|
||||
buildH5ShareUrl(liveId) {
|
||||
const base = (this.wapUrl || BASE?.WAP_URL || config.website || "").replace(/\/?$/, "/");
|
||||
return `${base}pages/promotions/live/detail?id=${liveId}`;
|
||||
},
|
||||
handleShare(row) {
|
||||
this.shareTitle = row.title || "";
|
||||
this.shareH5Url = this.buildH5ShareUrl(row.id);
|
||||
this.shareVisible = true;
|
||||
},
|
||||
copyShareUrl() {
|
||||
if (!this.shareH5Url) return;
|
||||
navigator.clipboard.writeText(this.shareH5Url);
|
||||
this.$Message.success("链接已复制");
|
||||
},
|
||||
handleStart(row) {
|
||||
if (row.liveStatus === "LIVING") {
|
||||
this.$Message.warning("直播间已在直播中");
|
||||
return;
|
||||
}
|
||||
if (row.liveStatus === "ENDED") {
|
||||
this.$Message.warning("直播已结束,无法开播");
|
||||
return;
|
||||
}
|
||||
this.$Modal.confirm({
|
||||
title: "确认开播",
|
||||
content: "确定要开始该直播吗?",
|
||||
onOk: () => {
|
||||
return startLive(row.id).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("开播成功");
|
||||
this.getData();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
handleEnd(row) {
|
||||
if (row.liveStatus !== "LIVING") {
|
||||
this.$Message.warning("仅直播中的直播间可以结束");
|
||||
return;
|
||||
}
|
||||
this.$Modal.confirm({
|
||||
title: "确认结束",
|
||||
content: "确定要结束该直播吗?",
|
||||
onOk: () => {
|
||||
return endLive(row.id).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("直播已结束");
|
||||
this.getData();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
loadWapSetting() {
|
||||
getSetting("BASE_SETTING")
|
||||
.then((res) => {
|
||||
if (res.success && res.result?.buyerDomain) {
|
||||
this.wapUrl = res.result.buyerDomain;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.live-list {
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.text-muted {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.share-title {
|
||||
margin: 0 0 16px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
160
manager/src/views/sys/setting-manage/setting/LIVE_SETTING.vue
Normal file
160
manager/src/views/sys/setting-manage/setting/LIVE_SETTING.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<el-form
|
||||
ref="formValidate"
|
||||
label-width="180px"
|
||||
label-position="right"
|
||||
:model="formValidate"
|
||||
:rules="ruleValidate"
|
||||
>
|
||||
<h4 class="section-title">推流 / 拉流</h4>
|
||||
<el-form-item label="直播推流域名" prop="domain">
|
||||
<el-input v-model="formValidate.domain" placeholder="请输入直播推流域名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="小程序直播推流域名" prop="mpPushDomain">
|
||||
<el-input v-model="formValidate.mpPushDomain" placeholder="请输入小程序直播推流域名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="直播拉流域名" prop="pullDomain">
|
||||
<el-input v-model="formValidate.pullDomain" placeholder="请输入直播拉流域名" />
|
||||
</el-form-item>
|
||||
|
||||
<h4 class="section-title">应用配置</h4>
|
||||
<el-form-item label="直播应用名称" prop="appName">
|
||||
<el-input v-model="formValidate.appName" placeholder="请输入直播应用名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="直播流名称" prop="streamName">
|
||||
<el-input v-model="formValidate.streamName" placeholder="请输入直播流名称" />
|
||||
</el-form-item>
|
||||
|
||||
<h4 class="section-title">密钥配置</h4>
|
||||
<el-form-item label="密钥Id" prop="secretId">
|
||||
<el-input v-model="formValidate.secretId" placeholder="请输入密钥Id" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="密钥Key" prop="secretKey">
|
||||
<el-input v-model="formValidate.secretKey" placeholder="请输入密钥Key" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="推流鉴权秘钥" :prop="'key'">
|
||||
<el-input v-model="formValidate.key" placeholder="请输入推流鉴权秘钥" show-password />
|
||||
</el-form-item>
|
||||
|
||||
<h4 class="section-title">回调配置</h4>
|
||||
<el-form-item label="直播回调地址" prop="callBackUrl">
|
||||
<el-input v-model="formValidate.callBackUrl" placeholder="请输入直播回调地址" />
|
||||
</el-form-item>
|
||||
|
||||
<h4 class="section-title">IM 配置</h4>
|
||||
<el-form-item label="直播IM SDK APPID" prop="imSdkAppid">
|
||||
<el-input v-model="formValidate.imSdkAppid" placeholder="请输入直播IM SDK APPID" />
|
||||
</el-form-item>
|
||||
<el-form-item label="直播IM SDK 密钥" prop="imSdkSecretKey">
|
||||
<el-input v-model="formValidate.imSdkSecretKey" placeholder="请输入直播IM SDK 密钥" show-password />
|
||||
</el-form-item>
|
||||
|
||||
<div class="label-btns">
|
||||
<el-button type="primary" @click="submit('formValidate')">保存</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { setSetting } from "@/api/index";
|
||||
|
||||
const DEFAULT_FORM = {
|
||||
domain: "",
|
||||
mpPushDomain: "",
|
||||
pullDomain: "",
|
||||
appName: "",
|
||||
streamName: "",
|
||||
secretId: "",
|
||||
secretKey: "",
|
||||
key: "",
|
||||
callBackUrl: "",
|
||||
imSdkAppid: "",
|
||||
imSdkSecretKey: "",
|
||||
};
|
||||
|
||||
const OPTIONAL_FIELDS = ["mpPushDomain", "callBackUrl"];
|
||||
|
||||
export default {
|
||||
props: ["res", "type"],
|
||||
data() {
|
||||
return {
|
||||
ruleValidate: {},
|
||||
formValidate: { ...DEFAULT_FORM },
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.init();
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
const result = this.res ? JSON.parse(this.res) : {};
|
||||
this.formValidate = { ...DEFAULT_FORM };
|
||||
Object.keys(DEFAULT_FORM).forEach((field) => {
|
||||
if (result[field] != null && result[field] !== undefined) {
|
||||
this.formValidate[field] = result[field];
|
||||
}
|
||||
});
|
||||
this.ruleValidate = {};
|
||||
Object.keys(DEFAULT_FORM).forEach((item) => {
|
||||
if (OPTIONAL_FIELDS.includes(item)) return;
|
||||
this.ruleValidate[item] = [
|
||||
{
|
||||
required: true,
|
||||
message: "请填写必填项",
|
||||
trigger: "blur",
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
submit(name) {
|
||||
this.$refs[name].validate((valid) => {
|
||||
if (valid) {
|
||||
this.setupSetting();
|
||||
} else {
|
||||
this.$Message.error("请正确填写内容!");
|
||||
}
|
||||
});
|
||||
},
|
||||
setupSetting() {
|
||||
const payload = { ...DEFAULT_FORM };
|
||||
Object.keys(DEFAULT_FORM).forEach((field) => {
|
||||
payload[field] = this.formValidate[field] ?? "";
|
||||
});
|
||||
setSetting(this.type, payload).then((res) => {
|
||||
if (res.success) {
|
||||
this.$Message.success("保存成功!");
|
||||
} else {
|
||||
this.$Message.error("保存失败!");
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "./style.scss";
|
||||
|
||||
.section-title {
|
||||
margin: 16px 0 8px 180px;
|
||||
padding-left: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input) {
|
||||
width: 420px !important;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
width: 420px;
|
||||
}
|
||||
</style>
|
||||
@@ -71,6 +71,10 @@ export default {
|
||||
type: "IM_SETTING",
|
||||
name: "客服设置",
|
||||
},
|
||||
{
|
||||
type: "LIVE_SETTING",
|
||||
name: "直播设置",
|
||||
},
|
||||
],
|
||||
authLogin: [
|
||||
// 登录设置
|
||||
|
||||
@@ -7,6 +7,7 @@ import LOGISTICS_SETTING from "./setting/LOGISTICS_SETTING";
|
||||
import OSS_SETTING from "./setting/OSS_SETTING";
|
||||
import SMS_SETTING from "./setting/SMS_SETTING";
|
||||
import IM_SETTING from "./setting/IM_SETTING";
|
||||
import LIVE_SETTING from "./setting/LIVE_SETTING";
|
||||
import WITHDRAWAL_SETTING from "./setting/WITHDRAWAL_SETTING";
|
||||
import ALIPAY_PAYMENT from "./pay/ALIPAY_PAYMENT";
|
||||
import WECHAT_PAYMENT from "./pay/WECHAT_PAYMENT";
|
||||
@@ -24,6 +25,7 @@ export default {
|
||||
OSS_SETTING: markRaw(OSS_SETTING),
|
||||
SMS_SETTING: markRaw(SMS_SETTING),
|
||||
IM_SETTING: markRaw(IM_SETTING),
|
||||
LIVE_SETTING: markRaw(LIVE_SETTING),
|
||||
WITHDRAWAL_SETTING: markRaw(WITHDRAWAL_SETTING),
|
||||
PAYMENT_SUPPORT: markRaw(PAYMENT_SUPPORT),
|
||||
WECHAT_PAYMENT: markRaw(WECHAT_PAYMENT),
|
||||
|
||||
Reference in New Issue
Block a user