腾讯云直播

This commit is contained in:
chc
2026-06-29 11:31:29 +08:00
parent ec948b5496
commit 22f7b1a343
16 changed files with 12346 additions and 0 deletions

8608
manager/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

96
manager/src/api/live.js Normal file
View 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 })

View File

@@ -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
View 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
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -71,6 +71,10 @@ export default {
type: "IM_SETTING",
name: "客服设置",
},
{
type: "LIVE_SETTING",
name: "直播设置",
},
],
authLogin: [
// 登录设置

View File

@@ -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),