Files
web-fusion/src/components/HostListDialog.vue

672 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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>
<Teleport to="body">
<div v-if="visible" class="fixed inset-0 bg-black/50 flex items-center justify-center" style="z-index: 9999">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col mx-4">
<!-- 头部 -->
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-600">主播管理</h3>
<span class="text-sm text-gray-700">
已选 {{ selectedCount }} / {{ filteredHosts.length }}
</span>
</div>
<button @click="onClose" class="text-gray-700 hover:text-gray-700 text-xl"></button>
</div>
<!-- 工具栏 -->
<div class="p-4 border-b border-gray-100 space-y-3">
<div class="flex flex-wrap gap-2">
<button @click="showAddDialog = true"
class="px-3 py-1.5 text-sm bg-green-100 text-green-700 hover:bg-green-200 rounded border border-green-300">
+ 添加主播
</button>
<button @click="selectAll"
class="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 hover:bg-blue-200 rounded border border-blue-300">全选</button>
<button @click="selectNone"
class="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 hover:bg-slate-200 rounded border border-slate-300">全不选</button>
<button @click="invertSelect"
class="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 hover:bg-slate-200 rounded border border-slate-300">反选</button>
<button @click="deleteSelected" :disabled="!selectedCount"
class="px-3 py-1.5 text-sm bg-red-100 text-red-600 hover:bg-red-200 rounded disabled:opacity-50">
删除选中
</button>
</div>
<!-- 筛选 -->
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.gold" class="w-4 h-4" />
<span class="text-yellow-600">进阶票</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="filters.ordinary" class="w-4 h-4" />
<span>普票</span>
</label>
<span class="text-gray-700">在线人数</span>
<input type="number" placeholder="最小" v-model="filters.minOnlineFans"
class="w-20 px-2 py-1 border border-gray-300 rounded text-sm" />
<span>~</span>
<input type="number" placeholder="最大" v-model="filters.maxOnlineFans"
class="w-20 px-2 py-1 border border-gray-300 rounded text-sm" />
<!-- 等级过滤 -->
<div class="relative border-l border-gray-200 pl-4 ml-2">
<button @click="showLevelDropdown = !showLevelDropdown"
class="flex items-center gap-2 px-3 py-1 border border-gray-300 rounded text-sm hover:bg-gray-50">
<span class="text-gray-700 font-medium">等级过滤</span>
<span class="text-xs text-blue-600">
{{ selectedLevels.size > 0 ? `已选 ${selectedLevels.size}` : '全部' }}
</span>
<svg :class="['w-4 h-4 transition-transform', showLevelDropdown ? 'rotate-180' : '']"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"
d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- 下拉菜单 -->
<div v-if="showLevelDropdown"
class="absolute top-full left-4 mt-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 p-3 w-72">
<div class="text-xs text-gray-500 mb-2">选择接收的主播等级不勾选则过滤掉</div>
<div class="space-y-2 max-h-60 overflow-auto">
<div v-for="parent in LEVEL_OPTIONS" :key="parent.value"
class="border border-gray-100 rounded p-2">
<label class="flex items-center gap-2 cursor-pointer font-medium text-gray-700">
<input type="checkbox" :checked="isParentSelected(parent).allSelected"
:indeterminate="isParentSelected(parent).partialSelected"
@change="toggleParentLevel(parent.value)" class="w-4 h-4" />
{{ parent.label }}
<span class="text-xs text-gray-400">({{
isParentSelected(parent).selectedChildCount }}/{{
parent.children.length
}})</span>
</label>
<div class="flex flex-wrap gap-2 mt-1 ml-6">
<label v-for="child in parent.children" :key="child.value"
class="flex items-center gap-1 cursor-pointer text-gray-600">
<input type="checkbox" :checked="selectedLevels.has(child.value)"
@change="toggleLevel(child.value)" class="w-3 h-3" />
<span class="text-xs">{{ child.label }}</span>
</label>
</div>
</div>
</div>
<div class="mt-2 pt-2 border-t border-gray-100 flex justify-between">
<div class="flex gap-2">
<button @click="selectAllLevels()"
class="text-xs text-gray-500 hover:text-gray-700">
全选
</button>
<button @click="selectNoneLevels()"
class="text-xs text-gray-500 hover:text-gray-700">
全不选
</button>
</div>
<button @click="showLevelDropdown = false"
class="text-xs text-blue-600 hover:text-blue-700">
完成
</button>
</div>
</div>
</div>
<!-- 接收上限 - 紧凑布局 -->
<div class="flex items-center gap-2 border-l border-gray-200 pl-4 ml-2">
<span class="text-gray-700 font-medium whitespace-nowrap">接收上限</span>
<input type="number" min="0" placeholder="无限制" v-model.number="maxCount"
@change="updateMaxCount(maxCount)"
class="w-20 px-2 py-1 border border-gray-300 rounded text-sm focus:border-blue-500 focus:outline-none" />
</div>
</div>
</div>
<!-- 主播列表 -->
<div class="flex-1 overflow-auto p-4">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div v-for="host in filteredHosts" :key="host.id" @click="toggleSelect(host.id)" :class="[
'p-3 rounded-lg border cursor-pointer transition-all',
selected.has(host.id) ? 'border-blue-500 bg-blue-50 shadow' : 'border-gray-200 hover:border-gray-300 hover:shadow-sm'
]">
<div class="flex items-center justify-between mb-1">
<span class="font-medium text-sm truncate flex-1" :title="host.anchorId">
{{ host.anchorId }}
</span>
<span :class="host.state ? 'text-green-500' : 'text-red-500'">
{{ host.state ? '✓' : '✗' }}
</span>
</div>
<div class="flex items-center justify-between text-xs text-gray-700">
<span>{{ host.country || '—' }}</span>
<div class="flex items-center gap-1">
<span v-if="host.hostsLevel"
class="px-1.5 py-0.5 rounded bg-purple-100 text-purple-600 text-xs">
{{ host.hostsLevel }}
</span>
<span :class="[
'px-1.5 py-0.5 rounded border',
host.invitationType === 2 ? 'text-yellow-600 border-yellow-400' : 'border-gray-300'
]">
{{ host.invitationType === 2 ? '进阶票' : '普票' }}
</span>
</div>
</div>
</div>
</div>
<div v-if="filteredHosts.length === 0" class="text-center text-gray-700 py-12">
暂无主播数据
</div>
</div>
<!-- 底部 -->
<div class="p-4 border-t border-gray-200 flex justify-end gap-3">
<button @click="onClose" class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
关闭
</button>
<button @click="handleSave"
class="px-4 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600">
保存
</button>
</div>
</div>
</div>
<!-- 添加主播弹窗 -->
<div v-if="showAddDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center"
style="z-index: 10000">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4">
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-600">添加主播</h3>
<button @click="closeAddDialog" class="text-gray-700 hover:text-gray-700 text-xl"></button>
</div>
<div class="p-4 space-y-4">
<!-- 主播ID输入 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">主播ID每行一个支持批量粘贴</label>
<textarea v-model="addForm.idsText" rows="6"
placeholder="粘贴主播ID每行一个&#10;例如:&#10;anchor_001&#10;anchor_002&#10;anchor_003"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:border-blue-500 focus:outline-none resize-none font-mono"></textarea>
<div class="text-xs text-gray-400 mt-1">
已输入 {{ parsedIds.length }} 个ID
</div>
</div>
<!-- 邀请类型 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">邀请类型</label>
<div class="flex gap-3">
<button @click="addForm.invitationType = '1'"
:class="['px-4 py-2 rounded-lg text-sm border transition-all', addForm.invitationType === '1' ? 'bg-blue-500 text-white border-blue-500' : 'bg-white text-gray-600 border-gray-300 hover:border-blue-300']">
普票
</button>
<button @click="addForm.invitationType = '2'"
:class="['px-4 py-2 rounded-lg text-sm border transition-all', addForm.invitationType === '2' ? 'bg-yellow-500 text-white border-yellow-500' : 'bg-white text-gray-600 border-gray-300 hover:border-yellow-300']">
进阶票
</button>
</div>
</div>
<!-- 国家选择 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">国家</label>
<select v-model="addForm.country"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:border-blue-500 focus:outline-none">
<option v-for="c in COUNTRY_OPTIONS" :key="c.value" :value="c.value">{{ c.label }}</option>
</select>
</div>
<!-- 等级选择 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">主播等级</label>
<select v-model="addForm.hostsLevel"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:border-blue-500 focus:outline-none">
<optgroup v-for="parent in LEVEL_OPTIONS" :key="parent.value" :label="parent.label + '级'">
<option v-for="child in parent.children" :key="child.value" :value="child.value">
{{ child.label }}
</option>
</optgroup>
</select>
</div>
</div>
<!-- 底部按钮 -->
<div class="p-4 border-t border-gray-200 flex justify-between items-center">
<span v-if="addStatus"
:class="['text-sm', addStatus.type === 'success' ? 'text-green-600' : 'text-red-600']">
{{ addStatus.message }}
</span>
<span v-else></span>
<div class="flex gap-3">
<button @click="closeAddDialog"
class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
取消
</button>
<button @click="handleAddHosts" :disabled="addLoading || parsedIds.length === 0"
class="px-4 py-2 text-sm bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed">
{{ addLoading ? '导入中...' : `导入 ${parsedIds.length} 个主播` }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { isElectron } from '../utils/electronBridge'
const props = defineProps({
visible: { type: Boolean, required: true }
})
const emit = defineEmits(['close', 'save'])
// Level Options
const LEVEL_OPTIONS = [
{
label: 'A', value: 'A',
children: [
{ label: 'A1', value: 'A1' },
{ label: 'A2', value: 'A2' },
{ label: 'A3', value: 'A3' },
]
},
{
label: 'B', value: 'B',
children: [
{ label: 'B1', value: 'B1' },
{ label: 'B2', value: 'B2' },
{ label: 'B3', value: 'B3' },
{ label: 'B4', value: 'B4' },
{ label: 'B5', value: 'B5' },
]
},
{
label: 'C', value: 'C',
children: [
{ label: 'C1', value: 'C1' },
{ label: 'C2', value: 'C2' },
{ label: 'C3', value: 'C3' },
{ label: 'C4', value: 'C4' },
{ label: 'C5', value: 'C5' },
]
},
{
label: 'D', value: 'D',
children: [
{ label: 'D1', value: 'D1' },
{ label: 'D2', value: 'D2' },
{ label: 'D3', value: 'D3' },
{ label: 'D4', value: 'D4' },
{ label: 'D5', value: 'D5' },
]
}
]
// State
const hosts = ref([])
const selected = ref(new Set())
const filters = ref({
gold: true,
ordinary: true,
minOnlineFans: '',
maxOnlineFans: '',
})
const maxCount = ref()
const selectedLevels = ref(new Set())
const showLevelDropdown = ref(false)
// 添加主播弹窗状态
const showAddDialog = ref(false)
const addLoading = ref(false)
const addStatus = ref(null)
const addForm = ref({
idsText: '',
invitationType: '1',
country: '美国',
hostsLevel: 'A1',
})
// 国家选项
const COUNTRY_OPTIONS = [
{ label: '美国', value: '美国', eng: 'United States' },
{ label: '英国', value: '英国', eng: 'United Kingdom' },
{ label: '加拿大', value: '加拿大', eng: 'Canada' },
{ label: '澳大利亚', value: '澳大利亚', eng: 'Australia' },
{ label: '德国', value: '德国', eng: 'Germany' },
{ label: '法国', value: '法国', eng: 'France' },
{ label: '日本', value: '日本', eng: 'Japan' },
{ label: '韩国', value: '韩国', eng: 'South Korea' },
{ label: '巴西', value: '巴西', eng: 'Brazil' },
{ label: '印度尼西亚', value: '印度尼西亚', eng: 'Indonesia' },
{ label: '墨西哥', value: '墨西哥', eng: 'Mexico' },
{ label: '菲律宾', value: '菲律宾', eng: 'Philippines' },
{ label: '越南', value: '越南', eng: 'Vietnam' },
{ label: '泰国', value: '泰国', eng: 'Thailand' },
{ label: '马来西亚', value: '马来西亚', eng: 'Malaysia' },
{ label: '沙特阿拉伯', value: '沙特阿拉伯', eng: 'Saudi Arabia' },
{ label: '西班牙', value: '西班牙', eng: 'Spain' },
{ label: '意大利', value: '意大利', eng: 'Italy' },
{ label: '土耳其', value: '土耳其', eng: 'Turkey' },
{ label: '埃及', value: '埃及', eng: 'Egypt' },
{ label: '尼日利亚', value: '尼日利亚', eng: 'Nigeria' },
{ label: '哥伦比亚', value: '哥伦比亚', eng: 'Colombia' },
{ label: '阿根廷', value: '阿根廷', eng: 'Argentina' },
{ label: '智利', value: '智利', eng: 'Chile' },
{ label: '秘鲁', value: '秘鲁', eng: 'Peru' },
{ label: '以色列', value: '以色列', eng: 'Israel' },
{ label: '伊拉克', value: '伊拉克', eng: 'Iraq' },
{ label: '约旦', value: '约旦', eng: 'Jordan' },
]
// 解析输入的主播ID列表
const parsedIds = computed(() => {
return addForm.value.idsText
.split('\n')
.map(line => line.trim())
.filter(id => id.length > 0)
})
// Lifecycle
watch(() => props.visible, (newVal) => {
if (newVal) {
document.body.style.overflow = 'hidden'
loadHosts()
loadConfig()
} else {
document.body.style.overflow = ''
}
})
onMounted(() => {
if (props.visible) {
document.body.style.overflow = 'hidden'
loadHosts()
loadConfig()
}
})
// 监听过滤器变化,同步到后端配置
watch(() => filters.value, async (newFilters) => {
if (!isElectron()) return
try {
await window.electronAPI.updateAutomationConfig({
filters: {
gold: newFilters.gold,
ordinary: newFilters.ordinary,
minOnlineFans: newFilters.minOnlineFans ? parseInt(newFilters.minOnlineFans) : 0,
maxOnlineFans: newFilters.maxOnlineFans ? parseInt(newFilters.maxOnlineFans) : 0,
}
})
console.log('[HostListDialog] 过滤配置已同步:', newFilters)
} catch (e) {
console.error('同步过滤配置失败:', e)
}
}, { deep: true })
// Loading
const loadHosts = async () => {
if (!isElectron()) return
try {
const data = await window.electronAPI.loadAnchorData()
console.log('加载主播数据:', data)
hosts.value = data
selected.value = new Set()
} catch (e) {
console.error('加载主播数据失败:', e)
}
}
const loadConfig = async () => {
if (!isElectron()) return
try {
const config = await window.electronAPI.getAutomationConfig()
if (config?.filters?.maxAnchorCount !== undefined) {
// 如果后端返回 9999999前端显示为空
maxCount.value = config.filters.maxAnchorCount === 9999999 ? '' : config.filters.maxAnchorCount
}
if (config?.filters?.hostsLevelList) {
// 计算反向等级列表:后端存储的是要过滤的等级,前端需要的是要显示的等级
const allLevels = LEVEL_OPTIONS.flatMap(parent => parent.children.map(child => child.value))
const excludedLevels = config.filters.hostsLevelList
const includedLevels = allLevels.filter(level => !excludedLevels.includes(level))
selectedLevels.value = new Set(includedLevels)
}
// 加载进阶票/普票过滤配置
if (config?.filters?.gold !== undefined) {
filters.value.gold = config.filters.gold
}
if (config?.filters?.ordinary !== undefined) {
filters.value.ordinary = config.filters.ordinary
}
// 加载在线人数过滤配置
if (config?.filters?.minOnlineFans !== undefined && config.filters.minOnlineFans > 0) {
filters.value.minOnlineFans = config.filters.minOnlineFans
}
if (config?.filters?.maxOnlineFans !== undefined && config.filters.maxOnlineFans > 0 && config.filters.maxOnlineFans < 9999999999999) {
filters.value.maxOnlineFans = config.filters.maxOnlineFans
}
} catch (e) {
console.error('加载配置失败:', e)
}
}
// Helpers
const getAllChildLevels = (parentValue) => {
const parent = LEVEL_OPTIONS.find(p => p.value === parentValue)
return parent ? parent.children.map(c => c.value) : []
}
// Filtering
const isParentSelected = (parent) => {
const childLevels = parent.children.map(c => c.value)
const selectedChildCount = childLevels.filter(l => selectedLevels.value.has(l)).length
const allSelected = selectedChildCount === childLevels.length
const partialSelected = selectedChildCount > 0 && !allSelected
return { allSelected, partialSelected, selectedChildCount }
}
const updateLevelFilter = async (levels) => {
selectedLevels.value = levels
if (!isElectron()) return
try {
// 计算反向等级列表:前端勾选的是要显示的,后端需要的是要过滤的(不显示的)
const allLevels = LEVEL_OPTIONS.flatMap(parent => parent.children.map(child => child.value))
const includedLevels = Array.from(levels)
// 当全不选时,传给后端的是空数组(不过滤任何等级)
// 当有选择时,传给后端的是未选择的等级(过滤这些等级)
let excludedLevels = []
if (includedLevels.length > 0) {
excludedLevels = allLevels.filter(level => !includedLevels.includes(level))
}
await window.electronAPI.updateAutomationConfig({
filters: { hostsLevelList: excludedLevels }
})
console.log('[HostListDialog] 等级过滤已更新:', excludedLevels)
} catch (e) {
console.error('更新等级配置失败:', e)
}
}
const toggleLevel = (level) => {
const newSet = new Set(selectedLevels.value)
if (newSet.has(level)) {
newSet.delete(level)
} else {
newSet.add(level)
}
updateLevelFilter(newSet)
}
const toggleParentLevel = (parentValue) => {
const childLevels = getAllChildLevels(parentValue)
const allSelected = childLevels.every(l => selectedLevels.value.has(l))
const newSet = new Set(selectedLevels.value)
if (allSelected) {
childLevels.forEach(l => newSet.delete(l))
} else {
childLevels.forEach(l => newSet.add(l))
}
updateLevelFilter(newSet)
}
const selectAllLevels = () => {
// 全选所有等级
const allLevels = LEVEL_OPTIONS.flatMap(parent => parent.children.map(child => child.value))
updateLevelFilter(new Set(allLevels))
}
const selectNoneLevels = () => {
// 全不选所有等级
updateLevelFilter(new Set())
}
const updateMaxCount = async (value) => {
// value is already updated via v-model
if (!isElectron()) return
try {
// 如果不填写,传 9999999 表示无限制
const maxAnchorCount = value || 9999999
await window.electronAPI.updateAutomationConfig({
filters: { maxAnchorCount }
})
console.log('[HostListDialog] 主播数据上限已更新:', maxAnchorCount)
} catch (e) {
console.error('更新配置失败:', e)
}
}
const filteredHosts = computed(() => {
return hosts.value.filter(h => {
if (!filters.value.gold && h.invitationType === 2) return false
if (!filters.value.ordinary && h.invitationType === 1) return false
if (filters.value.minOnlineFans && h.onlineFans !== undefined) {
if (h.onlineFans < parseInt(filters.value.minOnlineFans)) return false
}
if (filters.value.maxOnlineFans && h.onlineFans !== undefined) {
if (h.onlineFans > parseInt(filters.value.maxOnlineFans)) return false
}
if (selectedLevels.value.size > 0 && h.hostsLevel) {
if (!selectedLevels.value.has(h.hostsLevel)) return false
}
return true
})
})
const selectedCount = computed(() => selected.value.size)
const toggleSelect = (id) => {
const next = new Set(selected.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selected.value = next
}
const selectAll = () => {
selected.value = new Set(filteredHosts.value.map(h => h.id))
}
const selectNone = () => {
selected.value = new Set()
}
const invertSelect = () => {
const next = new Set()
filteredHosts.value.forEach(h => {
if (!selected.value.has(h.anchorId)) next.add(h.anchorId)
})
selected.value = next
}
const deleteSelected = async () => {
if (!selected.value.size) return
if (!confirm(`确认删除选中的 ${selected.value.size} 项吗?`)) return
console.log(selected.value)
if (isElectron()) {
try {
const ids = Array.from(selected.value)
const result = await window.electronAPI.deleteAnchorData(ids)
if (result.success) {
console.log('[HostListDialog] 删除成功,重新加载数据')
await loadHosts()
} else {
console.error('[HostListDialog] 删除失败:', result.error)
// fallback: 前端本地删除
hosts.value = hosts.value.filter(h => !selected.value.has(h.anchorId))
selected.value = new Set()
}
} catch (e) {
console.error('[HostListDialog] 删除异常:', e)
hosts.value = hosts.value.filter(h => !selected.value.has(h.anchorId))
selected.value = new Set()
}
} else {
hosts.value = hosts.value.filter(h => !selected.value.has(h.anchorId))
selected.value = new Set()
}
}
const onClose = () => emit('close')
const handleSave = async () => {
emit('save', hosts.value)
onClose()
}
// 关闭添加弹窗
const closeAddDialog = () => {
showAddDialog.value = false
addForm.value = { idsText: '', invitationType: '1', country: '美国', hostsLevel: 'A1' }
addStatus.value = null
addLoading.value = false
}
// 批量添加主播
const handleAddHosts = async () => {
const ids = parsedIds.value
if (ids.length === 0) return
addLoading.value = true
addStatus.value = null
// 查找国家英文名
const countryObj = COUNTRY_OPTIONS.find(c => c.value === addForm.value.country)
// 构造批量记录
const records = ids.map(hostsId => ({
hostsId,
invitationType: addForm.value.invitationType,
country: addForm.value.country || null,
countryEng: countryObj?.eng || null,
hostsLevel: addForm.value.hostsLevel || null,
createTime: Date.now(),
}))
if (isElectron()) {
try {
const result = await window.electronAPI.addAnchorData(records)
if (result.success) {
addStatus.value = { type: 'success', message: `成功导入 ${ids.length} 个主播` }
// 重新加载列表
await loadHosts()
// 延迟关闭弹窗
setTimeout(() => closeAddDialog(), 1000)
} else {
addStatus.value = { type: 'error', message: result.error || '导入失败' }
}
} catch (e) {
console.error('[HostListDialog] 添加主播失败:', e)
addStatus.value = { type: 'error', message: '导入异常: ' + String(e) }
}
} else {
addStatus.value = { type: 'error', message: '非 Electron 环境,无法添加' }
}
addLoading.value = false
}
</script>