直播场次

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

View File

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

View File

@@ -1,121 +1,134 @@
// pythonBridge.js // pythonBridge.js
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
const bridge = ref(null); 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 // 初始化 QWebChannel
const initBridge = () => { const initBridge = () => {
if (/localhost/.test(window.location.href)) return if (/localhost/.test(window.location.href)) return;
patchQWebChannel();
new QWebChannel(qt.webChannelTransport, (channel) => { 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; bridge.value = channel.objects.bridge;
}); });
}; };
export function usePythonBridge() { export function usePythonBridge() {
// 调用 Python 方法 // 调用 Python 方法
const fetchDataConfig = (data) => { const fetchDataConfig = (data) => {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
if (bridge.value) { if (!bridge.value) return resolve(null);
bridge.value.fetchDataConfig(data, function (result) { callBridge('fetchDataConfig', data, (result) => {
resolve(result); resolve(result);
}); });
}
}); });
}; };
// 查询获取主播的数据 // 查询获取主播的数据
const fetchDataCount = () => { const fetchDataCount = () => {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
if (bridge.value) { if (!bridge.value) return resolve(null);
bridge.value.fetchDataCount(function (result) { callBridge('fetchDataCount', (result) => {
resolve(result); resolve(result);
}); });
}
}); });
}; };
// 打开tk后台 // 打开 tk 后台
const loginTikTok = () => { const loginTikTok = () => {
if (bridge.value) { callBridge('loginTikTok');
bridge.value.loginTikTok(function (result) {
});
}
}; };
// 登录tk后台 // 登录 tk 后台
const loginBackStage = (data) => { const loginBackStage = (data) => {
if (bridge.value) { if (data.index == 0) {
if (data.index == 0) { callBridge('loginBackStage', JSON.stringify(data));
bridge.value.loginBackStage(JSON.stringify(data)); } else if (data.index == 1) {
} else if (data.index == 1) { callBridge('loginBackStageCopy', JSON.stringify(data));
bridge.value.loginBackStageCopy(JSON.stringify(data));
}
} }
}; };
//跳转到主播页面 // 跳转到主播页面
const givePyAnchorId = (id) => { const givePyAnchorId = (id) => {
callBridge('givePyAnchorId', id);
if (bridge.value) {
bridge.value.givePyAnchorId(id, function (result) {
});
}
}; };
//查询登录状态 // 查询登录状态
const backStageloginStatus = () => { const backStageloginStatus = () => {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
if (bridge.value) { if (!bridge.value) return resolve(null);
bridge.value.backStageloginStatus(function (result) { callBridge('backStageloginStatus', (result) => {
resolve(result); resolve(result);
}); });
}
}); });
}; };
//查询登录状态
// 查询登录状态(副账号)
const backStageloginStatusCopy = () => { const backStageloginStatusCopy = () => {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
if (bridge.value) { if (!bridge.value) return resolve(null);
bridge.value.backStageloginStatusCopy(function (result) { callBridge('backStageloginStatusCopy', (result) => {
resolve(result); resolve(result);
}); });
}
}); });
}; };
//导出表格 // 导出表格
const exportToExcel = (data) => { const exportToExcel = (data) => {
if (bridge.value) { callBridge('exportToExcel', JSON.stringify(data));
bridge.value.exportToExcel(JSON.stringify(data));
}
}; };
const stopScript = () => { const stopScript = () => {
if (bridge.value) { callBridge('stopScript');
bridge.value.stopScript();
}
}; };
//获取版本号 // 获取版本号
const getVersion = () => { const getVersion = () => {
return new Promise((resolve, reject) => { return new Promise((resolve) => {
if (bridge.value) { if (!bridge.value) return resolve(null);
bridge.value.currentVersion(function (result) { callBridge('currentVersion', (result) => {
resolve(result); resolve(result);
}); });
}
}); });
}; };
// 在组件挂载时初始化桥接 // 在组件挂载时初始化桥接
onMounted(initBridge); onMounted(initBridge);
@@ -129,6 +142,6 @@ export function usePythonBridge() {
backStageloginStatusCopy, backStageloginStatusCopy,
exportToExcel, exportToExcel,
stopScript, stopScript,
getVersion getVersion,
}; };
} }

View File

@@ -46,12 +46,20 @@
<el-table-column prop="invitationType" :label="$t('hostList.invitationType')" width="80"> <el-table-column prop="invitationType" :label="$t('hostList.invitationType')" width="80">
<template #default="scope"> <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') }} {{ scope.row.invitationType == 1 ? $t('hostList.invitationType1') : $t('hostList.invitationType2') }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </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" <el-table-column v-for="label in labelList" :key="label.paramCode" :prop="label.paramCode"
:label="label.paramCodeMeaning" width="120"> :label="label.paramCodeMeaning" width="120">
<template v-if="label.paramCode != 'createDt'" #default="scope"> <template v-if="label.paramCode != 'createDt'" #default="scope">
@@ -139,6 +147,9 @@
</template> </template>
</el-dialog> </el-dialog>
<LiveRecordDialog v-model:modelValue="liveDetailDialogVisible" :rows="liveDetailRecords"
@select="handleLiveSelect" />
</div> </div>
@@ -154,6 +165,7 @@ import { ref, reactive, onMounted } from 'vue';
// import { ElMessage, ElMessageBox } from 'element-plus' // import { ElMessage, ElMessageBox } from 'element-plus'
// import { color } from 'echarts'; // import { color } from 'echarts';
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import LiveRecordDialog from '@/components/LiveRecordDialog.vue'
const { t } = useI18n() const { t } = useI18n()
@@ -224,6 +236,9 @@ let staffId = ref({})
let commentInfo = ref('') let commentInfo = ref('')
//备注信息主播 //备注信息主播
let commentHost = ref('') let commentHost = ref('')
let liveDetailDialogVisible = ref(false)
let liveDetailRecords = ref([])
let liveDetailLoading = ref(false)
//分页 //分页
let pageSize = ref(10) let pageSize = ref(10)
let page = ref(1) let page = ref(1)
@@ -359,17 +374,29 @@ function handleClose(done) {
} }
function getliveHost() { function getliveHost(hostId) {
liveDetailLoading.value = true
liveHostDetail( liveHostDetail(
{ {
"hostsId": "1296peahh", "hostsId": hostId,
"tenantId": userInfo.value.tenantId "tenantId": userInfo.value.tenantId
} }
).then(res => { ).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) { // function handleSelectChange(event, data) {
@@ -518,6 +545,8 @@ function openHTML(id) {
box-sizing: border-box; box-sizing: border-box;
// height: 100vh; // height: 100vh;
padding: 40px; padding: 40px;
background: linear-gradient(135deg, #f7fbff 0%, #f2f9f8 100%);
border-radius: 16px;
/* 页面无法选中 */ /* 页面无法选中 */
// -webkit-user-select: none; // -webkit-user-select: none;
@@ -527,12 +556,38 @@ function openHTML(id) {
.hostTable { .hostTable {
width: 100%; 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 { .hostIdText {
text-decoration: underline; text-decoration: underline;
cursor: pointer; 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 { .serch-button {