直播场次

This commit is contained in:
2026-01-07 18:57:30 +08:00
parent b5f605dce9
commit 62dd79a2fc
4 changed files with 265 additions and 271 deletions

View File

@@ -1,240 +1,166 @@
<!-- LiveRecordDialog.vue -->
<template>
<el-dialog v-model="visible" title="直播记录" width="86vw" top="6vh" :close-on-click-modal="false" destroy-on-close>
<!-- 顶部工具栏 -->
<div class="toolbar">
<div class="left">
<el-input v-model="kw" placeholder="搜索hostsId / userId / tenantId / id" clearable
style="width: 320px" />
<el-select v-model="sortKey" style="width: 170px" placeholder="排序字段">
<el-option label="开始时间" value="startTimeFormatted" />
<el-option label="结束时间" value="endTimeFormatted" />
<el-option label="点赞数" value="likeCount" />
<el-option label="粉丝团" value="fansClubCount" />
</el-select>
<el-select v-model="sortOrder" style="width: 140px" placeholder="排序方式">
<el-option label="降序" value="desc" />
<el-option label="升序" value="asc" />
</el-select>
<el-dialog v-model="visible" title="直播记录" width="80vw" top="6vh" :close-on-click-modal="false" destroy-on-close>
<div class="toolbar">
<div class="left">
<el-checkbox v-model="onlyAbnormal">只看异常</el-checkbox>
</div>
<div class="right">
<el-tag type="info">总条数{{ filteredRows.length }}</el-tag>
<el-tag type="success">点赞合计{{ totalLikes }}</el-tag>
<el-tag type="warning">无点赞{{ zeroLikeCount }}</el-tag>
</div>
</div>
<el-checkbox v-model="onlyAbnormal">只看异常</el-checkbox>
<el-table :data="filteredRows" border height="62vh" style="width: 100%"
:default-sort="{ prop: 'startTimeFormatted', order: 'descending' }" table-layout="auto">
<el-table-column prop="hostsId" label="主播id" />
<el-table-column prop="startTimeFormatted" label="开始时间" />
<el-table-column prop="endTimeFormatted" label="结束时间" />
<el-table-column prop="durationFormatted" label="时长" />
<el-button @click="reset">重置</el-button>
</div>
<div class="right">
<el-tag type="info">总条数{{ filteredRows.length }}</el-tag>
<el-tag type="success">点赞合计{{ totalLikes }}</el-tag>
<el-tag type="warning">like=0{{ zeroLikeCount }}</el-tag>
</div>
</div>
<el-table :data="pagedRows" border height="62vh" style="width: 100%"
:default-sort="{ prop: 'startTimeFormatted', order: 'descending' }">
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="userId" label="userId" width="90" />
<el-table-column prop="hostsId" label="hostsId" width="130" />
<el-table-column prop="tenantId" label="tenantId" width="100" />
<el-table-column prop="fansClubCount" label="粉丝团" width="90" />
<el-table-column prop="lightedVsTotalGifts" label="点亮/礼物" width="110" />
<el-table-column prop="startTimeFormatted" label="开始时间" width="170" />
<el-table-column prop="endTimeFormatted" label="结束时间" width="170" />
<el-table-column prop="durationFormatted" label="时长" width="120" />
<el-table-column prop="likeCount" label="点赞" width="110" sortable>
<template #default="{ row }">
<el-tag v-if="row.likeCount === 0" type="danger">0</el-tag>
<span v-else>{{ row.likeCount }}</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="入库时间" width="170" />
<el-table-column label="操作" fixed="right" width="160">
<template #default="{ row }">
<el-button size="small" @click="copyRow(row)">复制</el-button>
<el-button size="small" type="primary" @click="emitSelect(row)">选中</el-button>
</template>
</el-table-column>
</el-table>
<div class="footer">
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper" :total="filteredRows.length" />
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
<el-table-column prop="likeCount" label="点赞" sortable>
<template #default="{ row }">
<el-tag v-if="row.likeCount === 0" type="danger">0</el-tag>
<span v-else>{{ row.likeCount }}</span>
</template>
</el-dialog>
</el-table-column>
<el-table-column prop="createTime" label="入库时间" />
</el-table>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
<script setup lang="js">
import { computed, ref } from "vue";
import { ElMessage } from "element-plus";
type Row = {
id: number;
userId: number;
hostsId: string;
tenantId: number;
fansClubCount: number;
lightedVsTotalGifts: string;
startTimeFormatted: string;
endTimeFormatted: string;
likeCount: number;
durationFormatted: string;
createTime: string;
};
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
rows: {
type: Array,
default: () => [],
},
});
const props = defineProps<{
modelValue: boolean;
rows: Row[];
}>();
const emit = defineEmits<{
(e: "update:modelValue", v: boolean): void;
(e: "select", row: Row): void;
}>();
const emit = defineEmits(["update:modelValue", "select"]);
const visible = computed({
get: () => props.modelValue,
set: (v) => emit("update:modelValue", v),
get: () => props.modelValue,
set: (v) => emit("update:modelValue", v),
});
const kw = ref("");
const sortKey = ref<keyof Row>("startTimeFormatted");
const sortOrder = ref<"asc" | "desc">("desc");
const sortKey = ref("startTimeFormatted");
const sortOrder = ref("desc");
const onlyAbnormal = ref(false);
const page = ref(1);
const pageSize = ref(20);
watch([kw, sortKey, sortOrder, onlyAbnormal], () => {
page.value = 1;
});
function parseTime(s?: string) {
// "2025-12-18 13:12:11" -> Date
if (!s) return 0;
return new Date(s.replace(" ", "T")).getTime() || 0;
function parseTime(s) {
if (!s) return 0;
return new Date(s.replace(" ", "T")).getTime() || 0;
}
function durationSeconds(durationFormatted?: string) {
// 例:"2小时49分钟55秒" / "18分钟31秒" / "25秒"
if (!durationFormatted) return 0;
const h = Number((durationFormatted.match(/(\d+)\s*小时/) || [])[1] || 0);
const m = Number((durationFormatted.match(/(\d+)\s*分钟/) || [])[1] || 0);
const s = Number((durationFormatted.match(/(\d+)\s*秒/) || [])[1] || 0);
return h * 3600 + m * 60 + s;
function durationSeconds(durationFormatted) {
if (!durationFormatted) return 0;
const h = Number((durationFormatted.match(/(\d+)\s*小时/) || [])[1] || 0);
const m = Number((durationFormatted.match(/(\d+)\s*分钟/) || [])[1] || 0);
const s = Number((durationFormatted.match(/(\d+)\s*/) || [])[1] || 0);
return h * 3600 + m * 60 + s;
}
const filteredRows = computed(() => {
const k = kw.value.trim().toLowerCase();
const k = kw.value.trim().toLowerCase();
let arr = (props.rows || []).filter((r) => {
if (!k) return true;
const hay = `${r.id} ${r.userId} ${r.hostsId} ${r.tenantId}`.toLowerCase();
return hay.includes(k);
});
let arr = (props.rows || []).filter((r) => {
if (!k) return true;
const hay = `${r.id} ${r.userId} ${r.hostsId} ${r.tenantId}`.toLowerCase();
return hay.includes(k);
});
if (onlyAbnormal.value) {
arr = arr.filter((r) => r.likeCount === 0 || durationSeconds(r.durationFormatted) < 60);
if (onlyAbnormal.value) {
arr = arr.filter((r) => r.likeCount === 0 || durationSeconds(r.durationFormatted) < 60);
}
const key = sortKey.value;
const order = sortOrder.value;
arr = [...arr].sort((a, b) => {
const av = a[key];
const bv = b[key];
if (key === "startTimeFormatted" || key === "endTimeFormatted" || key === "createTime") {
const aa = parseTime(String(av));
const bb = parseTime(String(bv));
return order === "desc" ? bb - aa : aa - bb;
}
// 排序
const key = sortKey.value;
const order = sortOrder.value;
if (typeof av === "number" && typeof bv === "number") {
return order === "desc" ? bv - av : av - bv;
}
arr = [...arr].sort((a, b) => {
const av = a[key] as any;
const bv = b[key] as any;
return order === "desc"
? String(bv).localeCompare(String(av))
: String(av).localeCompare(String(bv));
});
// 时间字段特殊处理
if (key === "startTimeFormatted" || key === "endTimeFormatted" || key === "createTime") {
const aa = parseTime(String(av));
const bb = parseTime(String(bv));
return order === "desc" ? bb - aa : aa - bb;
}
// 数字
if (typeof av === "number" && typeof bv === "number") {
return order === "desc" ? bv - av : av - bv;
}
// 字符串
return order === "desc"
? String(bv).localeCompare(String(av))
: String(av).localeCompare(String(bv));
});
return arr;
});
const pagedRows = computed(() => {
const start = (page.value - 1) * pageSize.value;
return filteredRows.value.slice(start, start + pageSize.value);
return arr;
});
const totalLikes = computed(() =>
filteredRows.value.reduce((sum, r) => sum + (Number(r.likeCount) || 0), 0)
filteredRows.value.reduce((sum, r) => sum + (Number(r.likeCount) || 0), 0)
);
const zeroLikeCount = computed(() => filteredRows.value.filter((r) => r.likeCount === 0).length);
function reset() {
kw.value = "";
sortKey.value = "startTimeFormatted";
sortOrder.value = "desc";
onlyAbnormal.value = false;
page.value = 1;
pageSize.value = 20;
kw.value = "";
sortKey.value = "startTimeFormatted";
sortOrder.value = "desc";
onlyAbnormal.value = false;
}
async function copyRow(row: Row) {
try {
await navigator.clipboard.writeText(JSON.stringify(row, null, 2));
ElMessage.success("已复制该行 JSON");
} catch {
ElMessage.warning("复制失败:浏览器不支持或无权限");
}
async function copyRow(row) {
try {
await navigator.clipboard.writeText(JSON.stringify(row, null, 2));
ElMessage.success("已复制该行 JSON");
} catch {
ElMessage.warning("复制失败:浏览器不支持或无权限");
}
}
function emitSelect(row: Row) {
emit("select", row);
ElMessage.success(`已选中ID ${row.id}`);
function emitSelect(row) {
emit("select", row);
ElMessage.success(`已选中ID ${row.id}`);
}
</script>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
flex-wrap: wrap;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar .left {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.toolbar .right {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.footer {
display: flex;
justify-content: flex-end;
margin-top: 12px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
</style>

View File

@@ -17,8 +17,8 @@ let baseURL = ''
if (process.env.NODE_ENV === 'development') {
// 生产环境
// baseURL = "https://api.tkpage.yolozs.com"
baseURL = "http://192.168.2.21:8101"
// baseURL = "https://crawlclient.api.yolozs.com"
// baseURL = "http://192.168.2.21:8101"
baseURL = "https://crawlclient.api.yolozs.com"
} else {
// 测试环境
// baseURL = "http://120.26.251.180:8085/"

View File

@@ -1,121 +1,134 @@
// pythonBridge.js
import { ref, onMounted } from 'vue';
const bridge = ref(null);
// 统一安全调用,确保 Qt 响应有回调可执行
const callBridge = (method, ...args) => {
if (!bridge.value || typeof bridge.value[method] !== 'function') return;
const last = args[args.length - 1];
const hasCallback = typeof last === 'function';
const callback = hasCallback ? args.pop() : () => { };
bridge.value[method](...args, callback);
};
// 防御:若 Qt 返回了未知 id忽略以免 execCallbacks 报错
const patchQWebChannel = () => {
if (!window.QWebChannel || QWebChannel.__patchedIgnoreMissing) return;
const originalHandleResponse = QWebChannel.prototype.handleResponse;
QWebChannel.__patchedIgnoreMissing = true;
QWebChannel.prototype.handleResponse = function (message) {
const cb = this.execCallbacks && this.execCallbacks[message.id];
if (message.id && typeof cb !== 'function') {
console.warn('忽略未知的 WebChannel 响应', message);
return;
}
return originalHandleResponse.call(this, message);
};
};
// 初始化 QWebChannel
const initBridge = () => {
if (/localhost/.test(window.location.href)) return
if (/localhost/.test(window.location.href)) return;
patchQWebChannel();
new QWebChannel(qt.webChannelTransport, (channel) => {
// 兜底:任何缺失的回调都返回空函数,避免 execCallbacks 报错
channel.execCallbacks = new Proxy(channel.execCallbacks || {}, {
get(target, prop) {
const val = target[prop];
if (typeof val === 'function') return val;
// 返回空函数,确保 handleResponse 可调用
return () => {};
},
set(target, prop, value) {
target[prop] = value;
return true;
},
});
bridge.value = channel.objects.bridge;
});
};
export function usePythonBridge() {
// 调用 Python 方法
const fetchDataConfig = (data) => {
return new Promise((resolve, reject) => {
if (bridge.value) {
bridge.value.fetchDataConfig(data, function (result) {
resolve(result);
});
}
return new Promise((resolve) => {
if (!bridge.value) return resolve(null);
callBridge('fetchDataConfig', data, (result) => {
resolve(result);
});
});
};
// 查询获取主播的数据
const fetchDataCount = () => {
return new Promise((resolve, reject) => {
if (bridge.value) {
bridge.value.fetchDataCount(function (result) {
resolve(result);
});
}
return new Promise((resolve) => {
if (!bridge.value) return resolve(null);
callBridge('fetchDataCount', (result) => {
resolve(result);
});
});
};
// 打开tk后台
// 打开 tk 后台
const loginTikTok = () => {
if (bridge.value) {
bridge.value.loginTikTok(function (result) {
});
}
callBridge('loginTikTok');
};
// 登录tk后台
// 登录 tk 后台
const loginBackStage = (data) => {
if (bridge.value) {
if (data.index == 0) {
bridge.value.loginBackStage(JSON.stringify(data));
} else if (data.index == 1) {
bridge.value.loginBackStageCopy(JSON.stringify(data));
}
if (data.index == 0) {
callBridge('loginBackStage', JSON.stringify(data));
} else if (data.index == 1) {
callBridge('loginBackStageCopy', JSON.stringify(data));
}
};
//跳转到主播页面
// 跳转到主播页面
const givePyAnchorId = (id) => {
if (bridge.value) {
bridge.value.givePyAnchorId(id, function (result) {
});
}
callBridge('givePyAnchorId', id);
};
//查询登录状态
// 查询登录状态
const backStageloginStatus = () => {
return new Promise((resolve, reject) => {
if (bridge.value) {
bridge.value.backStageloginStatus(function (result) {
resolve(result);
});
}
return new Promise((resolve) => {
if (!bridge.value) return resolve(null);
callBridge('backStageloginStatus', (result) => {
resolve(result);
});
});
};
//查询登录状态
// 查询登录状态(副账号)
const backStageloginStatusCopy = () => {
return new Promise((resolve, reject) => {
if (bridge.value) {
bridge.value.backStageloginStatusCopy(function (result) {
resolve(result);
});
}
return new Promise((resolve) => {
if (!bridge.value) return resolve(null);
callBridge('backStageloginStatusCopy', (result) => {
resolve(result);
});
});
};
//导出表格
// 导出表格
const exportToExcel = (data) => {
if (bridge.value) {
bridge.value.exportToExcel(JSON.stringify(data));
}
callBridge('exportToExcel', JSON.stringify(data));
};
const stopScript = () => {
if (bridge.value) {
bridge.value.stopScript();
}
callBridge('stopScript');
};
//获取版本号
// 获取版本号
const getVersion = () => {
return new Promise((resolve, reject) => {
if (bridge.value) {
bridge.value.currentVersion(function (result) {
resolve(result);
});
}
return new Promise((resolve) => {
if (!bridge.value) return resolve(null);
callBridge('currentVersion', (result) => {
resolve(result);
});
});
};
// 在组件挂载时初始化桥接
onMounted(initBridge);
@@ -129,6 +142,6 @@ export function usePythonBridge() {
backStageloginStatusCopy,
exportToExcel,
stopScript,
getVersion
getVersion,
};
}

View File

@@ -46,12 +46,20 @@
<el-table-column prop="invitationType" :label="$t('hostList.invitationType')" width="80">
<template #default="scope">
<el-tag :type="scope.row.invitationType == 1 ? 'success' : 'warning'" @click="getliveHost">
<el-tag :type="scope.row.invitationType == 1 ? 'success' : 'warning'">
{{ scope.row.invitationType == 1 ? $t('hostList.invitationType1') : $t('hostList.invitationType2') }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="直播场次" width="120">
<template #default="scope">
<el-button class="live-btn" size="small" @click="getliveHost(scope.row.hostId)">
查看场次
</el-button>
</template>
</el-table-column>
<el-table-column v-for="label in labelList" :key="label.paramCode" :prop="label.paramCode"
:label="label.paramCodeMeaning" width="120">
<template v-if="label.paramCode != 'createDt'" #default="scope">
@@ -139,6 +147,9 @@
</template>
</el-dialog>
<LiveRecordDialog v-model:modelValue="liveDetailDialogVisible" :rows="liveDetailRecords"
@select="handleLiveSelect" />
</div>
@@ -154,6 +165,7 @@ import { ref, reactive, onMounted } from 'vue';
// import { ElMessage, ElMessageBox } from 'element-plus'
// import { color } from 'echarts';
import { useI18n } from 'vue-i18n'
import LiveRecordDialog from '@/components/LiveRecordDialog.vue'
const { t } = useI18n()
@@ -224,6 +236,9 @@ let staffId = ref({})
let commentInfo = ref('')
//备注信息主播
let commentHost = ref('')
let liveDetailDialogVisible = ref(false)
let liveDetailRecords = ref([])
let liveDetailLoading = ref(false)
//分页
let pageSize = ref(10)
let page = ref(1)
@@ -359,17 +374,29 @@ function handleClose(done) {
}
function getliveHost() {
function getliveHost(hostId) {
liveDetailLoading.value = true
liveHostDetail(
{
"hostsId": "1296peahh",
"hostsId": hostId,
"tenantId": userInfo.value.tenantId
}
).then(res => {
console.log("直播间信息列表", JSON.stringify(res))
const detailList = Array.isArray(res) ? res : (res?.records || [])
liveDetailRecords.value = detailList
liveDetailDialogVisible.value = true
}).catch(err => {
console.log('liveHostDetail error', err)
}).finally(() => {
liveDetailLoading.value = false
})
}
function handleLiveSelect(row) {
console.log('selected live row', row)
liveDetailDialogVisible.value = false
}
//修改主播维护状态
// function handleSelectChange(event, data) {
@@ -518,6 +545,8 @@ function openHTML(id) {
box-sizing: border-box;
// height: 100vh;
padding: 40px;
background: linear-gradient(135deg, #f7fbff 0%, #f2f9f8 100%);
border-radius: 16px;
/* 页面无法选中 */
// -webkit-user-select: none;
@@ -527,12 +556,38 @@ function openHTML(id) {
.hostTable {
width: 100%;
padding: 40px 0;
max-width: 100%;
overflow: hidden;
padding: 16px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.04);
box-sizing: border-box;
.hostIdText {
text-decoration: underline;
cursor: pointer;
}
.live-btn {
background: linear-gradient(90deg, #45a1ff, #5ad9ff);
border: none;
color: #fff;
}
.live-btn:hover {
filter: brightness(1.05);
}
:deep(.el-table) {
width: 100% !important;
table-layout: fixed;
}
:deep(.el-table__header-wrapper),
:deep(.el-table__body-wrapper) {
overflow-x: auto;
}
}
.serch-button {