Files
web-fusion/src/views/tk/FanWorkbench.vue
2026-02-09 21:04:09 +08:00

581 lines
22 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="h-full w-full overflow-y-auto bg-gray-50 p-6">
<div class="bg-white dark:bg-slate-900 rounded-3xl shadow-sm border border-slate-100 dark:border-slate-800 p-6 h-full flex flex-col">
<!-- 顶部筛选区域 -->
<div class="mb-6 space-y-4">
<!-- 第一行主要筛选条件 -->
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center flex-wrap gap-4">
<el-checkbox v-model="queryFormData.isFilter" :label="$t('hostsList.filterPrivateUsers') || '过滤私密账号'" size="large"
class="!mr-0" />
<!-- Coins Input -->
<div class="flex flex-col gap-1">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1">
<span>💰</span> {{ $t('hostsList.coins') || '金币' }}
</label>
<div class="flex items-center gap-2">
<el-input v-model="queryFormData.coinMin" :placeholder="$t('hostsList.minCoins') || '最小'" style="width: 100px"
type="number" :disabled="streamdialogVisibletext || isRunnings" />
<span class="text-slate-300">/</span>
<el-input v-model="queryFormData.coinMax" :placeholder="$t('hostsList.maxCoins') || '最大'" style="width: 100px"
type="number" :disabled="streamdialogVisibletext || isRunnings" />
</div>
</div>
<!-- Level Input -->
<div class="flex flex-col gap-1">
<label class="text-xs font-bold text-slate-400 uppercase tracking-wider flex items-center gap-1">
<span>📊</span> {{ $t('hostsList.level') || '等级' }}
</label>
<div class="flex items-center gap-2">
<el-input v-model="queryFormData.levelMin" :placeholder="$t('hostsList.minLevel') || '最小'" style="width: 100px"
type="number" :disabled="streamdialogVisibletext || isRunnings" />
<span class="text-slate-300">/</span>
<el-input v-model="queryFormData.levelMax" :placeholder="$t('hostsList.maxLevel') || '最大'" style="width: 100px"
type="number" :disabled="streamdialogVisibletext || isRunnings" />
</div>
</div>
</div>
<!-- Info Pill -->
<div class="bg-white/60 border border-slate-200 rounded-full px-5 py-2 flex items-center gap-4 text-sm shadow-sm">
<span class="font-medium text-slate-500">{{ $t('hostsList.runningTime') || '运行时间' }}:</span>
<span class="font-mono font-bold text-slate-700">
{{ String(hourstuo).padStart(2, '0') }}:{{ String(minutestuo).padStart(2, '0') }}:{{ String(secondstuo).padStart(2, '0') }}
</span>
<div class="w-px h-4 bg-slate-200"></div>
<span class="font-medium text-slate-500">{{ $t('hostsList.total') || '总数' }}:</span>
<span class="font-bold text-slate-700">{{ getBrotherInfodata.total }}</span>
<div class="w-px h-4 bg-slate-200"></div>
<span class="font-medium text-slate-500">{{ $t('hostsList.valid') || '有效' }}:</span>
<span class="font-bold text-primary">{{ getBrotherInfodata.valid }}</span>
</div>
<!-- Right Action Buttons -->
<div class="flex items-center gap-3">
<el-button @click="streamdialogVisible = true" :disabled="isRunnings" type="primary"
class="!rounded-xl !font-semibold shadow-lg shadow-blue-500/20">
<span class="mr-1">📍</span>
{{ streamdialogVisibletext ? ($t('hostsList.specifiedRooms') || '已指定') : ($t('hostsList.specifyRooms') || '指定直播间') }}
</el-button>
<el-button v-show="!isRunnings" type="success" @click="getBigBrother"
class="!rounded-xl !font-semibold shadow-lg shadow-emerald-500/20">
{{ $t('hostsList.start') || '开始' }}
</el-button>
<el-button v-show="isRunnings" type="danger" @click="BigBrotherstop"
class="!rounded-xl !font-semibold shadow-lg shadow-red-500/20">
{{ $t('hostsList.end') || '结束' }}
</el-button>
</div>
</div>
<div class="h-px bg-slate-100 w-full"></div>
<!-- 第二行搜索和操作 -->
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
<el-select v-model="searchForm.region" filterable :placeholder="$t('hostsList.selectCountry') || '选择国家'" style="width: 160px">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-input v-model="searchForm.displayId" :placeholder="$t('hostsList.bigBrotherId') || '大哥ID'" style="width: 180px" clearable>
<template #prefix>
<span class="font-semibold text-slate-400">@</span>
</template>
</el-input>
<el-button @click="serch">
<span class="mr-1">🔍</span> {{ $t('hostsList.search') || '搜索' }}
</el-button>
<el-button @click="reset">
<span class="mr-1">🔄</span> {{ $t('hostsList.reset') || '重置' }}
</el-button>
<el-button :disabled="tableData.length == 0" @click="exportList">
<span class="mr-1">📥</span> {{ $t('hostsList.exportExcel') || '导出' }}
</el-button>
<el-button @click="filterdialogVisible = true">
<span class="mr-1"></span> {{ $t('hostsList.moreFilters') || '更多筛选' }}
</el-button>
<el-button @click="openTikTok">
<span class="mr-1">🎵</span> {{ $t('hostsList.openTikTok') || '打开TK' }}
</el-button>
</div>
<!-- Status Info -->
<div class="bg-slate-50 px-4 py-2 rounded-xl border border-slate-100 text-sm flex items-center gap-2">
<span class="text-slate-500">{{ $t('hostsList.currentNetwork') || '当前网络' }}:</span>
<span class="font-bold text-primary">{{ countryData }}</span>
<button @click="refreshCountry" :disabled="isRefreshingCountry"
class="p-1 rounded-md hover:bg-slate-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:title="$t('hostsList.refreshCountry') || '刷新国家'">
<span class="material-icons-round text-slate-500 text-base" :class="{ 'animate-spin': isRefreshingCountry }">refresh</span>
</button>
</div>
</div>
</div>
<!-- 表格区域 -->
<div class="flex-1 overflow-hidden border border-slate-100 rounded-xl">
<el-table ref="multipleTableRef" :data="tableData" stripe v-loading="loading" height="100%"
@cell-dblclick="handleCellDbClick" @selection-change="handleSelectionChange">
<el-table-column fixed prop="displayId" :label="$t('hostsList.id') || 'ID'" min-width="120">
<template #default="scope">
<div class="text-primary font-semibold cursor-pointer hover:underline" @click="openHTML(scope.row.displayId)">
{{ scope.row.displayId }}
</div>
</template>
</el-table-column>
<el-table-column prop="hostDisplayId" :label="$t('hostsList.hostId') || '主播ID'" min-width="120">
<template #default="scope">
<div class="text-primary font-semibold cursor-pointer hover:underline" @click.ctrl.exact="handleLongPress(scope.row.hostDisplayId)">
{{ scope.row.hostDisplayId }}
</div>
</template>
</el-table-column>
<el-table-column v-for="label in labelList" :key="label.paramCode" :prop="label.paramCode"
:label="label.paramCodeMeaning" min-width="120">
</el-table-column>
</el-table>
</div>
<!-- 分页区域 -->
<div class="mt-4 flex justify-between items-center">
<div></div>
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" background
layout="sizes, prev, pager, next" :total="total" :page-sizes="[10, 20, 50, 100, 500, 1000]"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</div>
<!-- 更多筛选弹窗 -->
<el-dialog v-model="filterdialogVisible" :title="$t('hostsList.moreFilters') || '更多筛选'" width="700px">
<div class="space-y-4">
<div class="grid grid-cols-12 gap-4 items-center">
<div class="col-span-3 text-right text-gray-600">{{ $t('hostsList.time') || '时间' }}</div>
<div class="col-span-9">
<el-date-picker v-model="createTimes" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss"
:placeholder="$t('hostsList.selectTime') || '选择时间'" class="w-full" />
</div>
</div>
<div v-for="(field, index) in fields" :key="index" class="grid grid-cols-12 gap-4 items-center">
<div class="col-span-3 text-right text-gray-600">{{ field.label }}</div>
<div class="col-span-9 flex gap-2 items-center">
<el-input type="number" v-model.number="searchForm[field.minModel]" :placeholder="$t('hostsList.minValue') || '最小值'" />
<span>-</span>
<el-input type="number" v-model.number="searchForm[field.maxModel]" :placeholder="$t('hostsList.maxValue') || '最大值'" />
</div>
</div>
<div class="grid grid-cols-12 gap-4 items-center">
<div class="col-span-3 text-right text-gray-600">{{ $t('hostsList.sort') || '排序' }}</div>
<div class="col-span-9 flex gap-4">
<el-select v-model="sortData.sortName" class="w-full">
<el-option v-for="item in sortNameOptions" :key="item.type" :label="item.label" :value="item.type" />
</el-select>
<el-select v-model="sortData.sort" class="w-full">
<el-option :label="$t('hostsList.ascending') || '升序'" value="asc" />
<el-option :label="$t('hostsList.descending') || '降序'" value="desc" />
</el-select>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="reset">{{ $t('hostsList.reset') || '重置' }}</el-button>
<el-button type="primary" @click="handelClick">{{ $t('hostsList.confirm') || '确认' }}</el-button>
</span>
</template>
</el-dialog>
<!-- 指定直播间弹窗 -->
<el-dialog v-model="streamdialogVisible" :title="$t('hostsList.specifyRooms') || '指定直播间'" width="600px">
<el-input v-model="textarea" :rows="10" type="textarea"
:placeholder="$t('hostsList.enterRoomIds') || '请输入房间ID每行一个'" @input="handleInput" />
<div class="mt-2 text-sm text-slate-500">
{{ currentLineCount }} / {{ maxSpecifyLines }}
</div>
<template #footer>
<div class="flex justify-end gap-2">
<el-button @click="specifyCancel">{{ $t('hostsList.cancelSpecify') || '取消指定' }}</el-button>
<el-button @click="specifyreset">{{ $t('hostsList.specifyReset') || '重置' }}</el-button>
<el-button type="primary" @click="specifyClick">{{ $t('hostsList.specifyConfirm') || '确认' }}</el-button>
<el-button type="success" @click="specifyClickStart">{{ $t('hostsList.specifyStart') || '确认并开始' }}</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onBeforeUnmount, computed } from "vue";
import { usePythonBridge } from "@/utils/pythonBridge";
import { getUser } from "@/utils/storage";
import { getCountryName } from "@/utils/countryUtil";
import { ElMessage, ElMessageBox, ElLoading } from "element-plus";
import { useI18n } from 'vue-i18n';
import { useCountryStore } from '@/stores/countryStore';
// Mock API calls if not present
// Ideally we should import these from api file, but for simplicity I will mock them or use empty callbacks
// if the user hasn't provided the api file content.
// Based on hostsList.vue reading, it uses `tkhostdata` from `@/api/account`.
// I will attempt to import, but if it fails I might need to create it.
// Assuming verify step will catch missing API functions.
import { tkhostdata, getCountryinfo } from "@/api/account";
const { t, locale } = useI18n();
const countryStore = useCountryStore();
// Component State
const queryFormData = ref({
coinMin: "",
coinMax: "",
levelMin: "",
levelMax: "",
isFilter: false,
isRunning: false,
anchor_ids: [],
});
const searchForm = ref({});
const createTimes = ref([]);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const tableData = ref([]);
const loading = ref(false);
const sortData = ref({ sortName: "createTime", sort: "desc" });
const hourstuo = ref(0);
const minutestuo = ref(0);
const secondstuo = ref(0);
const startTime = ref(null);
const getBrotherInfodata = ref({
total: 0,
valid: 0,
});
const streamdialogVisible = ref(false);
const streamdialogVisibletext = ref(false);
const filterdialogVisible = ref(false);
const textarea = ref("");
// 使用共享 store 的国家信息
const countryData = computed(() => countryStore.countryData);
const isRefreshingCountry = computed(() => countryStore.isLoading);
const userInfo = ref({});
const options = ref([]);
const labelList = ref([
{ paramCode: "userIdStr", paramCodeMeaning: t("hostsList.userId") || "用户ID" },
{ paramCode: "level", paramCodeMeaning: t("hostsList.level") || "等级" },
{ paramCode: "fansLevel", paramCodeMeaning: t("hostsList.fansLevel") || "粉丝等级" },
{ paramCode: "hostcoins", paramCodeMeaning: t("hostsList.coins") || "金币" },
{ paramCode: "region", paramCodeMeaning: t("hostsList.region") || "地区" },
{ paramCode: "followerCount", paramCodeMeaning: t("hostsList.followerCount") || "粉丝数" },
{ paramCode: "followingCount", paramCodeMeaning: t("hostsList.followingCount") || "关注数" },
{ paramCode: "createTime", paramCodeMeaning: t("hostsList.createTime") || "创建时间" },
{ paramCode: "totalGiftCoins", paramCodeMeaning: t("hostsList.totalGiftCoins") || "打赏总额" },
]);
const fields = [
{ label: t("hostsList.level") || "等级", minModel: "levelMin", maxModel: "levelMax" },
];
const sortNameOptions = ref([
{ label: t("hostsList.createTime") || "创建时间", type: "createTime" },
{ label: t("hostsList.coins") || "金币", type: "hostsCoins" },
{ label: t("hostsList.totalGiftCoins") || "打赏总额", type: "totalGiftCoins" },
{ label: t("hostsList.level") || "等级", type: "level" },
]);
// Bridge
const {
givePyAnchorId,
exportToExcel,
loginTikTok,
controlTask,
getBrotherInfo,
Specifystreaming,
readSetInfos,
storageSetInfos,
openAnchorIdRooms,
setClipboards,
} = usePythonBridge();
const isRunnings = ref(false);
const timerId = ref(null);
// Lifecycle
onMounted(async () => {
userInfo.value = getUser() || { tenantId: 0, id: 0 };
// 使用共享 store 初始化国家信息
await countryStore.initCountryInfo(t);
getCountry();
getlist();
const savedSettings = await readSetInfos({ key: "UserSettings" });
if (savedSettings) {
try {
// savedSettings might be object already if backend returned object, or string
const data = typeof savedSettings === 'string' ? JSON.parse(savedSettings) : savedSettings;
queryFormData.value = data;
if (data.anchor_ids && data.anchor_ids.length > 0) {
streamdialogVisibletext.value = true;
textarea.value = data.anchor_ids.join("\n");
}
} catch(e) { console.error("Error parsing settings", e); }
}
});
onBeforeUnmount(() => {
stopTimerfun();
if (timerId.value) clearInterval(timerId.value);
});
// Methods
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
const seconds = String(date.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
const getlist = () => {
loading.value = true;
// Use API if available, else mock
if (typeof tkhostdata === 'function') {
tkhostdata({
tenantId: Number(userInfo.value.tenantId),
sort: sortData.value.sort,
sortName: sortData.value.sortName,
current: page.value,
pageSize: pageSize.value,
createTimeStart: createTimes.value[0],
createTimeEnd: createTimes.value[1],
...searchForm.value,
}).then((res) => {
loading.value = false;
if (res && res.records) {
total.value = Number(res.total);
tableData.value = res.records.map((item) => ({
...item,
createTime: formatDate(new Date(item.createTime)),
}));
}
}).catch(e => {
console.log("Mocking data due to error", e);
loading.value = false;
// Mock data
tableData.value = [];
});
} else {
console.warn("tkhostdata API not found");
loading.value = false;
}
};
function getBigBrother() {
const settingData = { ...queryFormData.value, tenantId: userInfo.value.tenantId, region: countryData.value };
// Save settings
storageSetInfos({ key: "UserSettings", data: settingData });
controlTask(JSON.stringify(settingData)).then(() => {
isRunnings.value = true;
queryFormData.value.isRunning = true;
startTimerfun();
// Start polling stats
timerId.value = setInterval(() => {
getBrotherInfo().then(res => {
getBrotherInfodata.value = res;
});
}, 1000);
});
}
function BigBrotherstop() {
stopTimerfun();
isRunnings.value = false;
queryFormData.value.isRunning = false;
if (timerId.value) {
clearInterval(timerId.value);
timerId.value = null;
}
// Send stop command (logic in controlTask might handle toggle or we need stop logic)
// Original uses controlTask to START, but maybe stop logic is handled by setting isRunning=false in payload?
// Original code calls controlTask with payload again.
const settingData = { ...queryFormData.value, tenantId: userInfo.value.tenantId, region: countryData.value, isRunning: false };
controlTask(JSON.stringify(settingData));
}
function startTimerfun() {
stopTimerfun();
startTime.value = setInterval(() => {
secondstuo.value++;
if (secondstuo.value >= 60) {
secondstuo.value = 0;
minutestuo.value++;
if (minutestuo.value >= 60) {
minutestuo.value = 0;
hourstuo.value++;
}
}
}, 1000);
}
function stopTimerfun() {
if (startTime.value) clearInterval(startTime.value);
}
// Specify Room Logic
// 动态计算最大行数限制tenantId=12741 为 5000 条,其他为 50 条
const maxSpecifyLines = computed(() => {
return userInfo.value.tenantId == 12384 ? 5000 : 50;
});
// 当前行数
const currentLineCount = computed(() => {
if (!textarea.value || textarea.value.trim() === "") {
return 0;
}
return textarea.value.split("\n").filter(line => line.trim() !== "").length;
});
function handleInput(value) {
if (typeof value !== "string") return;
const lines = value.split("\n");
if (lines.length > maxSpecifyLines.value) {
textarea.value = lines.slice(0, maxSpecifyLines.value).join("\n");
}
}
function specifyClickStart() {
if (!textarea.value.trim()) {
ElMessage.error(t('hostsList.enterRoomId') || "请输入房间ID");
return;
}
queryFormData.value.anchor_ids = textarea.value.split("\n").filter(id => id.trim());
streamdialogVisible.value = false;
streamdialogVisibletext.value = true;
getBigBrother();
}
function specifyClick() {
if (!textarea.value.trim()) {
streamdialogVisibletext.value = false;
queryFormData.value.anchor_ids = [];
} else {
streamdialogVisibletext.value = true;
queryFormData.value.anchor_ids = textarea.value.split("\n").filter(id => id.trim());
}
streamdialogVisible.value = false;
}
function specifyCancel() {
streamdialogVisible.value = false;
streamdialogVisibletext.value = false;
queryFormData.value.anchor_ids = [];
textarea.value = "";
}
function specifyreset() {
textarea.value = "";
}
// Table / Filter Logic
function serch() {
page.value = 1;
getlist();
}
function reset() {
searchForm.value = {};
createTimes.value = [];
sortData.value = { sortName: "createTime", sort: "desc" };
getlist();
}
function handelClick() {
filterdialogVisible.value = false;
getlist();
}
function handleSizeChange(val) {
pageSize.value = val;
getlist();
}
function handleCurrentChange(val) {
page.value = val;
getlist();
}
function handleSelectionChange(val) {
//
}
function openHTML(id) {
givePyAnchorId(id);
}
function handleLongPress(id) {
openAnchorIdRooms(id);
}
function handleCellDbClick(row, column, cell) {
const text = cell?.textContent?.trim();
if (text) {
setClipboards(text).then(() => ElMessage.success("Copied"));
}
}
function exportList() {
exportToExcel(tableData.value);
}
function openTikTok() {
loginTikTok();
}
// 刷新国家信息 - 使用共享 store
const refreshCountry = async () => {
await countryStore.refreshCountry(t);
};
function getCountry() {
if (typeof getCountryinfo === 'function') {
getCountryinfo({})
.then((res) => {
res.forEach((item) => {
if (item.countryGroupName) {
options.value.push({ value: item.countryGroupName, label: item.countryGroupName });
}
});
})
.catch(() => {});
}
}
</script>