大哥 主播 即时消息 三合一
This commit is contained in:
File diff suppressed because it is too large
Load Diff
785
src/pages/ConfigPage.vue
Normal file
785
src/pages/ConfigPage.vue
Normal file
@@ -0,0 +1,785 @@
|
||||
<template>
|
||||
<div class="min-h-screen overflow-auto bg-gradient-to-br from-slate-100 to-slate-200 p-6">
|
||||
<div class="max-w-5xl mx-auto pb-8">
|
||||
<!-- 白色卡片容器 -->
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="flex items-end justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900">自动私信工作台</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
配置账号 · 设置 AI 回复策略 · 一键运行任务
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span :class="[
|
||||
'px-3 py-1 rounded-full text-xs font-medium',
|
||||
aiConfigured ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'
|
||||
]">
|
||||
AI 人设:{{ aiConfigured ? '已配置' : '未配置' }}
|
||||
</span>
|
||||
<span v-if="expireTime"
|
||||
class="text-xs text-gray-600 bg-orange-50 px-3 py-1 rounded-full border border-orange-200">
|
||||
到期时间:{{ expireTime }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 bg-gray-100 px-3 py-1 rounded-full">
|
||||
{{ currentTime }}
|
||||
</span>
|
||||
<!-- 分隔线 -->
|
||||
<span class="w-px h-6 bg-gray-200" />
|
||||
<!-- 退出登录 -->
|
||||
<button @click="emit('logout')"
|
||||
class="px-3 py-1.5 text-xs text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors">
|
||||
退出登录
|
||||
</button>
|
||||
<!-- 打开浏览器 -->
|
||||
<button @click="emit('goToBrowser')"
|
||||
class="px-4 py-2 text-sm bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-lg hover:from-blue-600 hover:to-cyan-600 transition-all shadow-sm flex items-center gap-2">
|
||||
<span>打开浏览器</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示条 -->
|
||||
<div
|
||||
class="flex items-center gap-2 mb-6 px-4 py-2 rounded-full text-sm text-gray-700 bg-gradient-to-r from-blue-50 to-green-50 border border-blue-200 w-fit">
|
||||
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
建议:最多配置 3 个账号轮询发送,开启 AI 自动回复可提升转化率。
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
<!-- 左侧:运行配置 -->
|
||||
<div class="col-span-2">
|
||||
<div
|
||||
class="bg-gradient-to-b from-white to-gray-50 rounded-xl border border-gray-200 shadow-sm p-6">
|
||||
<!-- 卡片头部 -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-1 h-4 rounded-full bg-gradient-to-b from-blue-500 to-green-500" />
|
||||
<span class="font-medium text-gray-900">运行配置</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!isRunning" class="relative group">
|
||||
<button @click="handleStart()"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-400 hover:to-blue-500 transition-all shadow-sm">
|
||||
<span class="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
|
||||
开始运行
|
||||
<!-- 下拉箭头 -->
|
||||
<svg class="w-4 h-4 ml-1 opacity-80" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 下拉菜单 -->
|
||||
<div class="absolute top-full right-0 pt-2 w-40 hidden group-hover:block z-20">
|
||||
<div
|
||||
class="bg-white rounded-xl shadow-xl border border-gray-100 overflow-hidden transform transition-all duration-200 origin-top-right">
|
||||
<div class="py-1">
|
||||
<button v-for="(group, index) in visibleGroups" :key="index"
|
||||
@click="handleStart(config.accountGroups.indexOf(group))"
|
||||
class="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600 transition-colors flex items-center justify-between group/item">
|
||||
<span>运行 {{ group.name }}</span>
|
||||
<span
|
||||
class="text-xs text-gray-400 group-hover/item:text-blue-400">
|
||||
{{ config.accountGroups.indexOf(group) + 1 }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button v-else @click="handleStop"
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium bg-gradient-to-r from-red-500 to-red-600 text-white hover:from-red-400 hover:to-red-500 transition-all shadow-sm">
|
||||
<span class="w-2 h-2 rounded-full bg-white animate-pulse" />
|
||||
停止运行
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 账号组 -->
|
||||
<div class="space-y-4">
|
||||
<label class="text-sm font-medium text-gray-700">账号组(最多3组)</label>
|
||||
|
||||
<div v-for="(group, gIndex) in visibleGroups" :key="gIndex" :class="[
|
||||
'rounded-lg p-4 border-2 transition-all duration-300',
|
||||
isGroupActive(group) ? 'bg-blue-50 border-blue-400 shadow-md' : hasRunningAccounts(group) ? 'bg-gray-50 border-gray-300' : 'bg-gray-50 border-gray-200'
|
||||
]">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm font-medium text-gray-800 flex items-center gap-2">
|
||||
<input type="text" :value="getGroupName(group, gIndex)"
|
||||
@input="updateGroupName($event.target.value, gIndex)"
|
||||
:placeholder="`第${gIndex + 1}组`"
|
||||
class="w-24 px-2 py-1 text-sm text-gray-900 font-medium border border-gray-300 rounded bg-white hover:border-blue-400 focus:border-blue-500 focus:outline-none" />
|
||||
<span v-if="isRunning && currentGroupIndex === gIndex"
|
||||
class="px-2 py-0.5 rounded text-xs bg-green-500 text-white">
|
||||
运行中
|
||||
</span>
|
||||
<!-- 运行模式标签 -->
|
||||
<span v-if="getGroupMode(group)"
|
||||
:class="['px-2 py-0.5 rounded text-xs', getGroupMode(group) === 'active' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-600']">
|
||||
{{ getGroupMode(group) === 'active' ? '全功能' : '仅回复' }}
|
||||
</span>
|
||||
<!-- 活跃组标记 + 运行时间 -->
|
||||
<span v-if="isGroupActive(group)"
|
||||
class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-700 flex items-center gap-1">
|
||||
活跃
|
||||
<span class="font-mono">{{ elapsedTime }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<button @click="addAccount(gIndex)" :disabled="group.accounts.length >= 3"
|
||||
class="text-xs text-blue-600 hover:text-blue-700 disabled:opacity-50">
|
||||
新增账号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-for="(acc, aIndex) in group.accounts" :key="aIndex"
|
||||
class="flex items-center gap-2 mb-2 p-2 rounded-lg bg-white border border-gray-200">
|
||||
<input type="email" placeholder="邮箱" :value="acc.email"
|
||||
@input="updateAccount(gIndex, aIndex, 'email', $event.target.value)"
|
||||
class="flex-1 px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
|
||||
<div class="flex-1 relative">
|
||||
<input :type="showPasswordMap[`${gIndex}-${aIndex}`] ? 'text' : 'password'"
|
||||
placeholder="密码" :value="acc.pwd"
|
||||
@input="updateAccount(gIndex, aIndex, 'pwd', $event.target.value)"
|
||||
class="w-full px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none pr-10" />
|
||||
<button @click="togglePasswordVisibility(gIndex, aIndex)"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none p-1">
|
||||
<svg v-if="showPasswordMap[`${gIndex}-${aIndex}`]" class="w-4 h-4"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 账号运行状态 -->
|
||||
<span v-if="getAccountStatus(gIndex, aIndex)"
|
||||
:class="['px-2 py-1 rounded text-xs font-medium whitespace-nowrap', getAccountStatus(gIndex, aIndex).mode === 'active' ? 'bg-emerald-50 text-emerald-600' : 'bg-gray-100 text-gray-500']">
|
||||
视图{{ getAccountViewId(gIndex, aIndex) }}
|
||||
</span>
|
||||
<button @click="removeAccount(gIndex, aIndex)"
|
||||
:disabled="group.accounts.length === 1"
|
||||
class="text-xs text-red-500 hover:text-red-600 disabled:opacity-50 px-2">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-2">每组最多 3 个账号,将按组轮换运行</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 轮换设置 -->
|
||||
<div class="mt-6 space-y-4">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700 w-28">国家语言</label>
|
||||
<select :value="config.lang || 'en'"
|
||||
@change="updateConfig('lang', $event.target.value)"
|
||||
class="flex-1 px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none bg-white">
|
||||
<option v-for="lang in languageList" :key="lang.code" :value="lang.code">
|
||||
{{ lang.name }} ({{ lang.code }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700 w-28">轮换账号组</label>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" :checked="config.rotateEnabled"
|
||||
@change="updateConfig('rotateEnabled', $event.target.checked)"
|
||||
class="sr-only peer" />
|
||||
<div
|
||||
class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500">
|
||||
</div>
|
||||
</label>
|
||||
<span class="text-xs text-gray-500">关闭时只跑当前组不切换</span>
|
||||
</div>
|
||||
|
||||
<template v-if="config.rotateEnabled">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700 w-28">账号组数量</label>
|
||||
<div class="flex gap-4">
|
||||
<label v-for="num in [2, 3]" :key="num"
|
||||
class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" :checked="config.groupCount === num"
|
||||
@change="updateConfig('groupCount', num)"
|
||||
class="w-4 h-4 text-blue-500" />
|
||||
<span class="text-sm text-gray-700">{{ num }}组</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700 w-28">轮换间隔(分钟)</label>
|
||||
<input type="number" min="1"
|
||||
:value="config.switchMinutes === 0 ? '' : config.switchMinutes"
|
||||
@input="handleNumberInput('switchMinutes', $event.target.value)"
|
||||
@blur="handleNumberBlur('switchMinutes', $event.target.value, 1)"
|
||||
class="w-20 px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
|
||||
<span class="text-xs text-gray-500">每隔 N 分钟切换到下一组</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- AI 回复 -->
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700 w-28">AI 自动回复</label>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" :checked="config.aiReply"
|
||||
@change="updateConfig('aiReply', $event.target.checked)"
|
||||
class="sr-only peer" />
|
||||
<div
|
||||
class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500">
|
||||
</div>
|
||||
</label>
|
||||
<span class="text-xs text-gray-500">开启后由 AI 自动根据对话内容生成回复</span>
|
||||
</div>
|
||||
|
||||
<!-- 第一条消息 -->
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700 w-28">第一条消息</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" :checked="config.sendInviteFirst === false"
|
||||
@change="updateConfig('sendInviteFirst', false)"
|
||||
class="w-4 h-4 text-blue-500" />
|
||||
<span class="text-sm text-gray-700">打招呼</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" :checked="config.sendInviteFirst === true"
|
||||
@change="updateConfig('sendInviteFirst', true)"
|
||||
class="w-4 h-4 text-blue-500" />
|
||||
<span class="text-sm text-gray-700">发送邀请链接</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 睡眠时间 -->
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700 w-28">睡眠时间(秒)</label>
|
||||
<input type="number" min="0" placeholder="0"
|
||||
:value="config.sleepTime < 0 ? '' : config.sleepTime"
|
||||
@input="handleSleepTimeInput($event.target.value)"
|
||||
class="w-20 px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
|
||||
</div>
|
||||
|
||||
<!-- 邀请阈值 -->
|
||||
<div v-if="!config.sendInviteFirst" class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700 w-28">链接发送时机</label>
|
||||
<input type="number" min="1"
|
||||
:value="config.inviteThreshold === 0 ? '' : config.inviteThreshold"
|
||||
@input="handleNumberInput('inviteThreshold', $event.target.value)"
|
||||
@blur="handleNumberBlur('inviteThreshold', $event.target.value, 1)"
|
||||
class="w-20 px-3 py-2 text-sm text-gray-900 rounded-lg border border-gray-300 focus:border-blue-500 focus:outline-none" />
|
||||
<span class="text-xs text-gray-500">打招呼后回复 N 句再发送邀请链接</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:快捷操作 -->
|
||||
<div class="col-span-1">
|
||||
<div
|
||||
class="bg-gradient-to-b from-white to-gray-50 rounded-xl border border-gray-200 shadow-sm p-6">
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<span class="w-1 h-4 rounded-full bg-gradient-to-b from-blue-500 to-green-500" />
|
||||
<span class="font-medium text-gray-900">快捷操作</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<button @click="showHostDialog = true"
|
||||
class="w-full text-left px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
||||
执行主播库
|
||||
</button>
|
||||
<button @click="showGreetingDialog = true"
|
||||
class="w-full text-left px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
||||
打招呼内容配置
|
||||
</button>
|
||||
<button @click="showAIDialog = true"
|
||||
class="w-full text-left px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
||||
配置 / 修改 AI 人设
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 配置弹窗 -->
|
||||
<AIConfigDialog :visible="showAIDialog" :config="aiConfig" @close="showAIDialog = false"
|
||||
@save="handleSaveAIConfig" @change="(key, value) => aiConfig[key] = value" />
|
||||
|
||||
<!-- 主播列表弹窗 -->
|
||||
<HostListDialog :visible="showHostDialog" @close="showHostDialog = false" @save="() => { }" />
|
||||
|
||||
<!-- 打招呼内容弹窗 -->
|
||||
<GreetingDialog :visible="showGreetingDialog" @close="showGreetingDialog = false" @confirm="handleGreetingConfirm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import HostListDialog from '../components/HostListDialog.vue'
|
||||
import GreetingDialog from '../components/GreetingDialog.vue'
|
||||
import AIConfigDialog from '../components/AIConfigDialog.vue'
|
||||
import { isElectron } from '../utils/electronBridge'
|
||||
|
||||
const emit = defineEmits(['goToBrowser', 'logout'])
|
||||
|
||||
const CONFIG_KEY = 'autoDm_runConfig'
|
||||
|
||||
// Default Config
|
||||
const defaultConfig = {
|
||||
rotateEnabled: false,
|
||||
groupCount: 1,
|
||||
accountGroups: [
|
||||
{ name: '第1组', accounts: [{ email: '', pwd: '' }] },
|
||||
{ name: '第2组', accounts: [{ email: '', pwd: '' }] },
|
||||
{ name: '第3组', accounts: [{ email: '', pwd: '' }] },
|
||||
],
|
||||
aiReply: true,
|
||||
sendInviteFirst: false,
|
||||
sleepTime: 30,
|
||||
inviteThreshold: 3,
|
||||
switchMinutes: 60,
|
||||
prologueList: {},
|
||||
needTranslate: false,
|
||||
maxAnchorCount: 100,
|
||||
lang: 'en'
|
||||
}
|
||||
|
||||
// Language List
|
||||
const languageList = [
|
||||
{ name: '中文 (简体)', code: 'zh-CN' },
|
||||
{ name: 'English', code: 'en' },
|
||||
{ name: 'ภาษาไทย', code: 'th-TH' },
|
||||
{ name: 'العربية', code: 'ar' },
|
||||
{ name: 'Bahasa Indonesia', code: 'id-ID' },
|
||||
{ name: 'Русский', code: 'ru-RU' },
|
||||
{ name: 'Tiếng Việt', code: 'vi-VN' },
|
||||
{ name: 'Bahasa Melayu', code: 'ms-MY' },
|
||||
{ name: '日本語', code: 'ja-JP' },
|
||||
{ name: 'Türkçe', code: 'tr-TR' },
|
||||
{ name: 'Português', code: 'pt-PT' },
|
||||
{ name: '한국어', code: 'ko-KR' },
|
||||
{ name: 'Español', code: 'es-ES' },
|
||||
{ name: '中文 (繁體)', code: 'zh-Hant-TW' },
|
||||
{ name: 'Deutsch', code: 'de-DE' },
|
||||
{ name: 'Italiano', code: 'it-IT' },
|
||||
{ name: 'Français', code: 'fr-FR' },
|
||||
{ name: 'Română', code: 'ro-RO' },
|
||||
{ name: 'Polski', code: 'pl-PL' },
|
||||
{ name: 'Nederlands', code: 'nl-NL' },
|
||||
{ name: 'Svenska', code: 'sv-SE' },
|
||||
]
|
||||
|
||||
// State
|
||||
const config = ref(JSON.parse(JSON.stringify(defaultConfig)))
|
||||
const aiConfig = ref({
|
||||
agentName: '',
|
||||
guildName: '',
|
||||
contactTool: '',
|
||||
contact: '',
|
||||
})
|
||||
const isRunning = ref(false)
|
||||
const currentGroupIndex = ref(0)
|
||||
const showAIDialog = ref(false)
|
||||
const showHostDialog = ref(false)
|
||||
const showGreetingDialog = ref(false)
|
||||
const aiConfigured = ref(false)
|
||||
const configLoaded = ref(false)
|
||||
const rotationStatus = ref(null)
|
||||
const elapsedTime = ref('00:00')
|
||||
const expireTime = ref('')
|
||||
const currentTime = ref('')
|
||||
const showPasswordMap = ref({})
|
||||
|
||||
const isElectronEnv = isElectron()
|
||||
let timeInterval = null
|
||||
let rotationTimer = null
|
||||
let saveTimer = null
|
||||
|
||||
// Computed
|
||||
const visibleGroups = computed(() => {
|
||||
return config.value.rotateEnabled
|
||||
? config.value.accountGroups.slice(0, config.value.groupCount)
|
||||
: config.value.accountGroups.slice(0, 1)
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
updateCurrentTime()
|
||||
timeInterval = setInterval(updateCurrentTime, 1000)
|
||||
|
||||
// Load User Data
|
||||
try {
|
||||
const userData = localStorage.getItem('user_data')
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData)
|
||||
if (user.aiExpireTime) {
|
||||
const timestamp = Number(user.aiExpireTime)
|
||||
const date = new Date(timestamp)
|
||||
expireTime.value = date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
await loadConfig()
|
||||
checkAIConfig()
|
||||
|
||||
if (isElectronEnv) {
|
||||
const status = await window.electronAPI.getRotationStatus()
|
||||
rotationStatus.value = status
|
||||
if (status?.instanceModes?.length > 0) {
|
||||
setIsRunning(true)
|
||||
}
|
||||
handleStatusChange(status)
|
||||
|
||||
const unsubRotation = window.electronAPI.onRotationStatusChanged((status) => {
|
||||
rotationStatus.value = status
|
||||
setIsRunning(status?.instanceModes?.length > 0)
|
||||
handleStatusChange(status)
|
||||
})
|
||||
|
||||
const unsubSave = window.electronAPI.onRequestSaveConfig(() => {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveToLocalStorage()
|
||||
saveToFile()
|
||||
})
|
||||
|
||||
// Cleanup function for listeners is not directly supported in onMounted unless we store unsubscribers
|
||||
// We will just add window listener for unload as in React
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
|
||||
// Store unsubs for unmount
|
||||
// Note: In Vue component unmount, we should clean up.
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeInterval) clearInterval(timeInterval)
|
||||
if (rotationTimer) clearInterval(rotationTimer)
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
saveToLocalStorage()
|
||||
})
|
||||
|
||||
const updateCurrentTime = () => {
|
||||
currentTime.value = new Date().toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// Config Loading/Saving
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
if (isElectronEnv) {
|
||||
const saved = await window.electronAPI.loadRunConfig()
|
||||
if (saved) {
|
||||
config.value = { ...config.value, ...saved }
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(saved))
|
||||
} else {
|
||||
const localSaved = localStorage.getItem(CONFIG_KEY)
|
||||
if (localSaved) {
|
||||
config.value = { ...config.value, ...JSON.parse(localSaved) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const localSaved = localStorage.getItem(CONFIG_KEY)
|
||||
if (localSaved) {
|
||||
config.value = { ...config.value, ...JSON.parse(localSaved) }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载配置失败:', e)
|
||||
} finally {
|
||||
configLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const saveToLocalStorage = () => {
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config.value))
|
||||
}
|
||||
|
||||
const saveToFile = async () => {
|
||||
if (!isElectronEnv) return
|
||||
try {
|
||||
await window.electronAPI.saveRunConfig(JSON.parse(JSON.stringify(config.value)))
|
||||
} catch (e) {
|
||||
console.error('保存配置失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
saveToLocalStorage()
|
||||
saveToFile()
|
||||
}
|
||||
|
||||
// Auto Save
|
||||
watch(config, (newVal) => {
|
||||
if (!configLoaded.value) return
|
||||
saveToLocalStorage()
|
||||
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
saveToFile()
|
||||
}, 500)
|
||||
}, { deep: true })
|
||||
|
||||
const checkAIConfig = async () => {
|
||||
if (!isElectronEnv) return
|
||||
try {
|
||||
const saved = await window.electronAPI.loadAIConfig()
|
||||
if (saved && (saved.agentName || saved.guildName || saved.contactTool || saved.contact)) {
|
||||
aiConfig.value = saved
|
||||
aiConfigured.value = true
|
||||
} else {
|
||||
aiConfigured.value = false
|
||||
}
|
||||
} catch {
|
||||
aiConfigured.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAIConfig = async () => {
|
||||
if (isElectronEnv) {
|
||||
await window.electronAPI.saveAIConfig(JSON.parse(JSON.stringify(aiConfig.value)))
|
||||
}
|
||||
aiConfigured.value = true
|
||||
showAIDialog.value = false
|
||||
}
|
||||
|
||||
// Rotation Timer Logic
|
||||
const handleStatusChange = (status) => {
|
||||
if (rotationTimer) {
|
||||
clearInterval(rotationTimer)
|
||||
rotationTimer = null
|
||||
}
|
||||
|
||||
if (status?.modeStartTime) {
|
||||
const updateTimer = () => {
|
||||
const elapsed = Math.floor((Date.now() - status.modeStartTime) / 1000)
|
||||
const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0')
|
||||
const seconds = (elapsed % 60).toString().padStart(2, '0')
|
||||
elapsedTime.value = `${minutes}:${seconds}`
|
||||
}
|
||||
updateTimer()
|
||||
rotationTimer = setInterval(updateTimer, 1000)
|
||||
} else {
|
||||
elapsedTime.value = '00:00'
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
const updateConfig = (key, value) => {
|
||||
config.value[key] = value
|
||||
}
|
||||
|
||||
const updateGroupName = (val, index) => {
|
||||
config.value.accountGroups[index].name = val || `第${index + 1}组`
|
||||
}
|
||||
|
||||
const addAccount = (groupIndex) => {
|
||||
if (config.value.accountGroups[groupIndex].accounts.length < 3) {
|
||||
config.value.accountGroups[groupIndex].accounts.push({ email: '', pwd: '' })
|
||||
}
|
||||
}
|
||||
|
||||
const removeAccount = (groupIndex, accountIndex) => {
|
||||
if (config.value.accountGroups[groupIndex].accounts.length > 1) {
|
||||
config.value.accountGroups[groupIndex].accounts.splice(accountIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAccount = (groupIndex, accountIndex, field, value) => {
|
||||
config.value.accountGroups[groupIndex].accounts[accountIndex][field] = value
|
||||
}
|
||||
|
||||
// Input Handlers
|
||||
const handleNumberInput = (key, val) => {
|
||||
if (val === '') {
|
||||
config.value[key] = 0
|
||||
} else {
|
||||
const num = parseInt(val)
|
||||
if (!isNaN(num)) config.value[key] = num
|
||||
}
|
||||
}
|
||||
|
||||
const handleNumberBlur = (key, val, min) => {
|
||||
if (!val || parseInt(val) < min) {
|
||||
config.value[key] = min
|
||||
}
|
||||
}
|
||||
|
||||
const handleSleepTimeInput = (val) => {
|
||||
if (val === '') {
|
||||
config.value.sleepTime = -1
|
||||
} else {
|
||||
config.value.sleepTime = parseInt(val) || 0
|
||||
}
|
||||
}
|
||||
|
||||
// Start/Stop
|
||||
const handleStart = async (specificGroupIndex) => {
|
||||
const activeGroupIndex = specificGroupIndex ?? 0
|
||||
const activeGroup = config.value.accountGroups[activeGroupIndex]
|
||||
|
||||
if (!activeGroup) return alert('请选择要运行的账号组')
|
||||
|
||||
const hasValidAccount = activeGroup.accounts.some(a => a.email && a.pwd)
|
||||
if (!hasValidAccount) return alert('请至少填写一组有效的账号(邮箱和密码)')
|
||||
|
||||
const errors = []
|
||||
if (config.value.rotateEnabled && (!config.value.switchMinutes || config.value.switchMinutes < 1)) {
|
||||
errors.push('轮换间隔必须大于 0 分钟')
|
||||
}
|
||||
if (config.value.sleepTime < 0) {
|
||||
errors.push('请填写睡眠时间')
|
||||
}
|
||||
if (!config.value.sendInviteFirst && (!config.value.inviteThreshold || config.value.inviteThreshold < 1)) {
|
||||
errors.push('链接发送时机必须大于 0')
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return alert('配置检查失败:\n\n' + errors.map((e, i) => `${i + 1}. ${e}`).join('\n'))
|
||||
}
|
||||
|
||||
if (!isElectronEnv) return alert('非 Electron 环境无法运行')
|
||||
|
||||
// Deep clone to remove Vue reactivity (Proxy)
|
||||
const prologueList = JSON.parse(JSON.stringify(config.value.prologueList || {}))
|
||||
const activeGroupName = activeGroup.name
|
||||
|
||||
await window.electronAPI.updateAutomationConfig({
|
||||
aiReplyEnabled: config.value.aiReply,
|
||||
isGreetFirst: config.value.sendInviteFirst,
|
||||
sleepTime: config.value.sleepTime,
|
||||
inviteThreshold: config.value.inviteThreshold,
|
||||
prologueList,
|
||||
needTranslate: config.value.needTranslate, // 添加翻译开关配置
|
||||
maxAnchorCount: config.value.maxAnchorCount,
|
||||
rotationEnabled: config.value.rotateEnabled,
|
||||
rotationIntervalMinutes: config.value.switchMinutes,
|
||||
currentActiveGroup: activeGroupName,
|
||||
})
|
||||
|
||||
const groupsToStart = config.value.rotateEnabled
|
||||
? visibleGroups.value.map(g => ({
|
||||
group: g,
|
||||
index: config.value.accountGroups.indexOf(g),
|
||||
isActive: g.name === activeGroupName
|
||||
}))
|
||||
: [{ group: activeGroup, index: activeGroupIndex, isActive: true }]
|
||||
|
||||
const startTasks = []
|
||||
for (const { group, index } of groupsToStart) {
|
||||
const startViewId = index * 3 + 1
|
||||
let currentViewId = startViewId
|
||||
for (const acc of group.accounts) {
|
||||
if (acc.email && acc.pwd && currentViewId < startViewId + 3 && currentViewId <= 9) {
|
||||
// Deep clone account object to remove Vue reactivity
|
||||
const cleanAccount = JSON.parse(JSON.stringify(acc))
|
||||
startTasks.push({
|
||||
viewId: currentViewId,
|
||||
account: {
|
||||
...cleanAccount,
|
||||
group: group.name,
|
||||
lang: config.value.lang || 'en' // 传递语言配置
|
||||
},
|
||||
delay: Math.random() * 2500 + 500
|
||||
})
|
||||
currentViewId++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
startTasks.map(async ({ viewId, account, delay }) => {
|
||||
await new Promise(r => setTimeout(r, delay))
|
||||
return window.electronAPI.startTikTokAutomation(viewId, account)
|
||||
})
|
||||
)
|
||||
|
||||
setIsRunning(true)
|
||||
currentGroupIndex.value = activeGroupIndex
|
||||
const status = await window.electronAPI.getRotationStatus()
|
||||
rotationStatus.value = status
|
||||
handleStatusChange(status)
|
||||
emit('goToBrowser')
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!isElectronEnv) return
|
||||
|
||||
await Promise.allSettled(
|
||||
Array.from({ length: 9 }, (_, i) => i + 1).map(viewId =>
|
||||
window.electronAPI.stopTikTokAutomation(viewId).catch(() => { })
|
||||
)
|
||||
)
|
||||
|
||||
await window.electronAPI.updateAutomationConfig({ rotationEnabled: false })
|
||||
if (window.electronAPI.clearAllCache) await window.electronAPI.clearAllCache()
|
||||
|
||||
setIsRunning(false)
|
||||
rotationStatus.value = null
|
||||
elapsedTime.value = '00:00'
|
||||
}
|
||||
|
||||
const handleGreetingConfirm = (data) => {
|
||||
const newPrologueList = {
|
||||
yolo: data.sentences,
|
||||
...data.translations
|
||||
}
|
||||
config.value.prologueList = newPrologueList
|
||||
config.value.needTranslate = data.needTranslate
|
||||
}
|
||||
|
||||
// Helpers for Template
|
||||
const getGroupName = (group, index) => {
|
||||
if (group.name.startsWith('第') && group.name.endsWith('组')) return ''
|
||||
return group.name
|
||||
}
|
||||
|
||||
const getGroupMode = (group) => {
|
||||
return rotationStatus.value?.instanceModes.find(i => i.group === group.name)?.mode
|
||||
}
|
||||
|
||||
const isGroupActive = (group) => {
|
||||
return rotationStatus.value?.enabled && rotationStatus.value?.currentActiveGroup === group.name
|
||||
}
|
||||
|
||||
const hasRunningAccounts = (group) => {
|
||||
return rotationStatus.value?.instanceModes.some(i => i.group === group.name)
|
||||
}
|
||||
|
||||
const getAccountViewId = (groupIndex, accountIndex) => {
|
||||
return groupIndex * 3 + accountIndex + 1
|
||||
}
|
||||
|
||||
const getAccountStatus = (groupIndex, accountIndex) => {
|
||||
const viewId = getAccountViewId(groupIndex, accountIndex)
|
||||
return rotationStatus.value?.instanceModes.find(i => i.viewId === viewId)
|
||||
}
|
||||
|
||||
function setIsRunning(val) {
|
||||
isRunning.value = val
|
||||
}
|
||||
|
||||
const togglePasswordVisibility = (gIndex, aIndex) => {
|
||||
const key = `${gIndex}-${aIndex}`
|
||||
showPasswordMap.value[key] = !showPasswordMap.value[key]
|
||||
}
|
||||
</script>
|
||||
@@ -1,269 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { isElectron, getAppVersion } from '../utils/electronBridge'
|
||||
import logo from '../assets/logo.png'
|
||||
import illustration from '../assets/illustration.png'
|
||||
|
||||
const STORAGE_KEY = 'login_credentials'
|
||||
const USER_KEY = 'user_data'
|
||||
|
||||
interface LoginCredentials {
|
||||
tenantName: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
interface LoginPageProps {
|
||||
onLoginSuccess: () => void
|
||||
}
|
||||
|
||||
function LoginPage({ onLoginSuccess }: LoginPageProps) {
|
||||
const [credentials, setCredentials] = useState<LoginCredentials>({
|
||||
tenantName: '',
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [version, setVersion] = useState('')
|
||||
|
||||
// 获取应用版本号
|
||||
useEffect(() => {
|
||||
const fetchVersion = async () => {
|
||||
const v = await getAppVersion()
|
||||
setVersion(v)
|
||||
}
|
||||
fetchVersion()
|
||||
}, [])
|
||||
|
||||
// 加载保存的凭据
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved)
|
||||
// 兼容旧数据格式:userId → username
|
||||
setCredentials({
|
||||
tenantName: data.tenantName || '',
|
||||
username: data.username || data.userId || '',
|
||||
password: data.password || '',
|
||||
})
|
||||
}
|
||||
} catch { }
|
||||
}, [])
|
||||
|
||||
const handleChange = (field: keyof LoginCredentials, value: string) => {
|
||||
setCredentials(prev => ({ ...prev, [field]: value }))
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
|
||||
if (!credentials.tenantName || !credentials.username || !credentials.password) {
|
||||
setError('请填写所有字段')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// 保存凭据
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(credentials))
|
||||
|
||||
console.log('[LoginPage] 开始登录...', credentials)
|
||||
|
||||
if (!isElectron()) {
|
||||
// 非 Electron 环境,模拟登录成功
|
||||
setError('非 Electron 环境,无法进行真实登录')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 调用登录 API
|
||||
const result = await window.electronAPI!.login(credentials)
|
||||
console.log('[LoginPage] 登录结果:', result)
|
||||
|
||||
if (result.success && result.user) {
|
||||
// 保存用户信息
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(result.user))
|
||||
onLoginSuccess()
|
||||
} else {
|
||||
setError(result.error || '登录失败')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F0F4F8] flex items-center justify-center font-sans antialiased relative overflow-hidden transition-colors duration-300">
|
||||
{/* Background Shapes */}
|
||||
<div
|
||||
className="absolute top-[-200px] right-[-200px] w-[800px] h-[800px] rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-pulse"
|
||||
style={{ background: 'radial-gradient(circle, rgba(79, 129, 230, 0.2) 0%, rgba(79, 129, 230, 0) 70%)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-[-100px] left-[-100px] w-[600px] h-[600px] rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-pulse"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(236, 72, 153, 0.15) 0%, rgba(236, 72, 153, 0) 70%)',
|
||||
animationDuration: '4s'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 z-10 relative flex justify-center items-center h-full">
|
||||
<div className="bg-white/70 backdrop-blur-xl w-full max-w-5xl rounded-[2rem] overflow-hidden flex flex-col md:flex-row shadow-2xl border border-white/20">
|
||||
|
||||
{/* Left Side: Form */}
|
||||
<div className="w-full md:w-1/2 p-8 md:p-12 lg:p-16 flex flex-col justify-center">
|
||||
{/* Header / Logo */}
|
||||
<div className="flex justify-center">
|
||||
<img src={logo} alt="Logo" className="w-[200px] h-auto" />
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">欢迎回来</h1>
|
||||
<p className="text-gray-500 text-sm">请输入您的账户信息以登录平台</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* 租户号 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">租户号</label>
|
||||
<div className="relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={credentials.tenantName}
|
||||
onChange={(e) => handleChange('tenantName', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="请输入租户号"
|
||||
className="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账号 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">账号</label>
|
||||
<div className="relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={credentials.username}
|
||||
onChange={(e) => handleChange('username', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="请输入账号"
|
||||
className="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<div className="relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
value={credentials.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="请输入密码"
|
||||
className="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-red-600 text-xs bg-red-50 p-2.5 rounded-lg">
|
||||
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-500 to-[#4F81E6] hover:from-blue-600 hover:to-[#3A6BC7] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4F81E6] transition-all transform hover:scale-[1.01] disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
登录中
|
||||
</span>
|
||||
) : '登 录'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<span className="text-gray-300 text-xs font-mono">v{version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Illustration */}
|
||||
<div className="hidden md:flex w-1/2 bg-blue-50/50 relative items-center justify-center p-12 overflow-hidden">
|
||||
{/* Decorative Circle matches login.html style */}
|
||||
<div className="absolute w-96 h-96 bg-blue-100 rounded-full blur-3xl opacity-30 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"></div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-sm">
|
||||
<img
|
||||
src={illustration}
|
||||
alt="Illustration"
|
||||
className="w-full h-auto drop-shadow-xl animate-float"
|
||||
style={{ animation: 'float 6s ease-in-out infinite' }}
|
||||
/>
|
||||
<div className="text-center mt-8">
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-2">连接全球创意</h3>
|
||||
<p className="text-gray-500 text-sm">高效管理您的TikTok矩阵,释放无限潜能</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Animation Keyframe Style */}
|
||||
<style>{`
|
||||
@keyframes float {
|
||||
0% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-15px); }
|
||||
100% { transform: translateY(0px); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
262
src/pages/LoginPage.vue
Normal file
262
src/pages/LoginPage.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
```
|
||||
<template>
|
||||
<div
|
||||
class="min-h-screen bg-[#F0F4F8] flex items-center justify-center font-sans antialiased relative overflow-hidden transition-colors duration-300">
|
||||
<div class="absolute top-8 right-8 flex gap-4 z-20">
|
||||
<!-- Network Settings (Placeholder/Mock) -->
|
||||
<!-- <div class="bg-white/95 border border-slate-200 rounded-2xl px-3 py-2 shadow-lg cursor-pointer hover:-translate-y-px transition-all flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-slate-700">{{ $t('login.network') }}</span>
|
||||
</div> -->
|
||||
|
||||
<!-- Language Selector -->
|
||||
<el-dropdown>
|
||||
<div
|
||||
class="bg-white/95 border border-slate-200 rounded-2xl px-4 py-2 shadow-lg cursor-pointer hover:-translate-y-px transition-all flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-slate-700">{{ locale === 'zh' ? '中文' : 'English' }}</span>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="switchLanguage('zh')">中文</el-dropdown-item>
|
||||
<el-dropdown-item @click="switchLanguage('en')">English</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- Background Shapes -->
|
||||
<div class="absolute top-[-200px] right-[-200px] w-[800px] h-[800px] rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-pulse"
|
||||
style="background: radial-gradient(circle, rgba(79, 129, 230, 0.2) 0%, rgba(79, 129, 230, 0) 70%)" />
|
||||
<div class="absolute bottom-[-100px] left-[-100px] w-[600px] h-[600px] rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-pulse"
|
||||
style="background: radial-gradient(circle, rgba(236, 72, 153, 0.15) 0%, rgba(236, 72, 153, 0) 70%); animation-duration: 4s" />
|
||||
|
||||
<div class="container mx-auto px-4 z-10 relative flex justify-center items-center h-full">
|
||||
<div
|
||||
class="bg-white/70 backdrop-blur-xl w-full max-w-5xl rounded-[2rem] overflow-hidden flex flex-col md:flex-row shadow-2xl border border-white/20">
|
||||
|
||||
<!-- Left Side: Form -->
|
||||
<div class="w-full md:w-1/2 p-8 md:p-12 lg:p-16 flex flex-col justify-center">
|
||||
<!-- Header / Logo -->
|
||||
<div class="flex justify-center">
|
||||
<img :src="logo" alt="Logo" class="w-[200px] h-auto" />
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">欢迎回来</h1>
|
||||
<p class="text-gray-500 text-sm">请输入您的账户信息以登录平台</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<!-- 租户号 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">租户号</label>
|
||||
<div class="relative rounded-md shadow-sm">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text" v-model="credentials.tenantName" placeholder="请输入租户号"
|
||||
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
|
||||
<div class="relative rounded-md shadow-sm">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text" v-model="credentials.username" placeholder="请输入账号"
|
||||
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<div class="relative rounded-md shadow-sm">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="password" v-model="credentials.password" placeholder="请输入密码"
|
||||
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error"
|
||||
class="flex items-center gap-2 text-red-600 text-xs bg-red-50 p-2.5 rounded-lg">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<div class="pt-2">
|
||||
<button type="submit" :disabled="isLoading"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-500 to-[#4F81E6] hover:from-blue-600 hover:to-[#3A6BC7] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4F81E6] transition-all transform hover:scale-[1.01] disabled:opacity-70 disabled:cursor-not-allowed">
|
||||
<template v-if="isLoading">
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
</svg>
|
||||
登录中
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
登 录
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<span class="text-gray-300 text-xs font-mono">v{{ version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Illustration -->
|
||||
<div
|
||||
class="hidden md:flex w-1/2 bg-blue-50/50 relative items-center justify-center p-12 overflow-hidden">
|
||||
<!-- Decorative Circle matches login.html style -->
|
||||
<div
|
||||
class="absolute w-96 h-96 bg-blue-100 rounded-full blur-3xl opacity-30 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 w-full max-w-sm">
|
||||
<img :src="illustration" alt="Illustration" class="w-full h-auto drop-shadow-xl animate-float"
|
||||
style="animation: float 6s ease-in-out infinite" />
|
||||
<div class="text-center mt-8">
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-2">连接全球创意</h3>
|
||||
<p class="text-gray-500 text-sm">高效管理您的TikTok矩阵,释放无限潜能</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { isElectron, getAppVersion } from '../utils/electronBridge'
|
||||
import { setUser, setToken, setUserPass, getUserPass } from '@/utils/storage'
|
||||
import logo from '../assets/logo.png'
|
||||
import illustration from '../assets/illustration.png'
|
||||
|
||||
const emit = defineEmits(['loginSuccess'])
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
// Language Switcher
|
||||
const switchLanguage = (lang) => {
|
||||
locale.value = lang
|
||||
localStorage.setItem('lang', lang)
|
||||
}
|
||||
|
||||
// const STORAGE_KEY = 'login_credentials' // Deprecated in favor of getUserPass
|
||||
// const USER_KEY = 'user_data' // Deprecated in favor of setUser
|
||||
|
||||
const credentials = ref({
|
||||
tenantName: '',
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
const isLoading = ref(false)
|
||||
const error = ref('')
|
||||
const version = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
// 获取应用版本
|
||||
getAppVersion().then(v => {
|
||||
version.value = v
|
||||
})
|
||||
|
||||
// 加载保存的凭据
|
||||
try {
|
||||
const saved = getUserPass()
|
||||
if (saved) {
|
||||
credentials.value = {
|
||||
tenantName: saved.tenantName || '',
|
||||
username: saved.username || saved.userId || '',
|
||||
password: saved.password || '',
|
||||
}
|
||||
}
|
||||
} catch { } // eslint-disable-line no-empty
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!credentials.value.tenantName || !credentials.value.username || !credentials.value.password) {
|
||||
error.value = '请填写所有字段'
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 保存凭据 (Using compatible storage helper)
|
||||
setUserPass(credentials.value)
|
||||
|
||||
console.log('[LoginPage] 开始登录...', credentials.value)
|
||||
|
||||
if (!isElectron()) {
|
||||
error.value = '非 Electron 环境,无法进行真实登录'
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.login({ ...credentials.value })
|
||||
console.log('[LoginPage] 登录结果:', result)
|
||||
|
||||
if (result.success && result.user) {
|
||||
// Save token and user info to localStorage using legacy keys to support ported views
|
||||
setToken(result.user.tokenValue);
|
||||
setUser(result.user);
|
||||
|
||||
emit('loginSuccess')
|
||||
} else {
|
||||
error.value = result.error || '登录失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || '登录失败'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,365 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useUpdate } from '../hooks/useUpdate'
|
||||
import { isElectron } from '../utils/electronBridge'
|
||||
|
||||
interface UpdateCheckerProps {
|
||||
onReady: () => void // 无更新或更新完成后调用
|
||||
}
|
||||
|
||||
const CHECK_TIMEOUT = 15000 // 15秒超时
|
||||
const MAX_RETRIES = 3 // 最大重试次数
|
||||
const AUTO_INSTALL_DELAY = 3 // 自动安装倒计时秒数
|
||||
|
||||
/**
|
||||
* 自动安装倒计时组件
|
||||
*/
|
||||
function AutoInstallCountdown({ installUpdate }: { installUpdate: () => void }) {
|
||||
const [countdown, setCountdown] = useState(AUTO_INSTALL_DELAY)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) {
|
||||
installUpdate()
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCountdown(prev => prev - 1)
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [countdown, installUpdate])
|
||||
|
||||
return (
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-green-50 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">下载完成</h2>
|
||||
<p className="text-green-600 text-sm mt-2">
|
||||
{countdown > 0 ? `${countdown} 秒后自动重启安装...` : '正在重启安装...'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={installUpdate}
|
||||
className="w-full py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-lg font-medium hover:from-green-600 hover:to-green-700 transition-all shadow-sm"
|
||||
>
|
||||
🚀 立即重启安装
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制更新检查页面
|
||||
* 程序启动时显示,必须完成更新才能进入主程序
|
||||
* 注意:仅在 Electron 环境中有效
|
||||
*/
|
||||
export default function UpdateChecker({ onReady }: UpdateCheckerProps) {
|
||||
const {
|
||||
status,
|
||||
updateInfo,
|
||||
progress,
|
||||
error,
|
||||
currentVersion,
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
installUpdate
|
||||
} = useUpdate()
|
||||
|
||||
const [checkComplete, setCheckComplete] = useState(false)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const [isTimeout, setIsTimeout] = useState(false)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const hasStartedRef = useRef(false)
|
||||
|
||||
// 非 Electron 环境直接跳过更新检查
|
||||
useEffect(() => {
|
||||
if (!isElectron()) {
|
||||
onReady()
|
||||
}
|
||||
}, [onReady])
|
||||
|
||||
// 启动检查更新(带超时)
|
||||
const startCheck = useCallback(() => {
|
||||
if (!isElectron()) return
|
||||
|
||||
setIsTimeout(false)
|
||||
checkForUpdates()
|
||||
|
||||
// 设置超时
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (status === 'checking') {
|
||||
setIsTimeout(true)
|
||||
// 超时后自动重试
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
setRetryCount(prev => prev + 1)
|
||||
}
|
||||
}
|
||||
}, CHECK_TIMEOUT)
|
||||
}, [checkForUpdates, status, retryCount])
|
||||
|
||||
// 清理超时定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 启动时自动检查更新(只执行一次)
|
||||
useEffect(() => {
|
||||
if (!hasStartedRef.current && isElectron()) {
|
||||
hasStartedRef.current = true
|
||||
startCheck()
|
||||
}
|
||||
}, [startCheck])
|
||||
|
||||
// 超时处理状态
|
||||
const [showTimeoutError, setShowTimeoutError] = useState(false)
|
||||
|
||||
// 超时后自动重试,重试次数用完后显示错误
|
||||
useEffect(() => {
|
||||
if (isTimeout) {
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
// 重试次数用完,显示超时错误
|
||||
console.log('[UpdateChecker] 更新检查超时,显示错误')
|
||||
setShowTimeoutError(true)
|
||||
} else if (retryCount > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
startCheck()
|
||||
}, 2000) // 2秒后重试
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [isTimeout, retryCount, startCheck])
|
||||
|
||||
// 监听状态变化
|
||||
useEffect(() => {
|
||||
// 状态不再是 checking,清除超时
|
||||
if (status !== 'checking' && timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
setIsTimeout(false)
|
||||
}
|
||||
|
||||
// 检查完成且无更新,直接进入程序
|
||||
if (status === 'idle' && checkComplete) {
|
||||
onReady()
|
||||
}
|
||||
}, [status, checkComplete, onReady])
|
||||
|
||||
// 标记检查已完成(从 checking 变为其他状态时)
|
||||
useEffect(() => {
|
||||
if (status !== 'checking' && status !== 'idle') {
|
||||
setCheckComplete(true)
|
||||
}
|
||||
// 如果检查后直接变成 idle(无更新),也标记完成
|
||||
if (status === 'idle') {
|
||||
const timer = setTimeout(() => {
|
||||
setCheckComplete(true)
|
||||
}, 500) // 等待0.5秒确认无更新
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [status])
|
||||
|
||||
// 自动开始下载(发现更新后)
|
||||
const handleDownload = useCallback(() => {
|
||||
downloadUpdate()
|
||||
}, [downloadUpdate])
|
||||
|
||||
// 非 Electron 环境不渲染
|
||||
if (!isElectron()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center p-6">
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 bg-blue-100 rounded-full blur-3xl opacity-50" />
|
||||
<div className="absolute -bottom-40 -left-40 w-96 h-96 bg-purple-100 rounded-full blur-3xl opacity-50" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-md">
|
||||
{/* Logo 和标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">应用更新检查</h1>
|
||||
<p className="text-gray-500 text-sm">当前版本: v{currentVersion || '...'}</p>
|
||||
</div>
|
||||
|
||||
{/* 更新卡片 */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-xl p-6">
|
||||
|
||||
{/* 检查中 */}
|
||||
{status === 'checking' && !showTimeoutError && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 mx-auto mb-4 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-gray-900 font-medium">正在检查更新...</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
{retryCount > 0 ? `第 ${retryCount}/${MAX_RETRIES} 次重试...` : '请稍候'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 超时错误 */}
|
||||
{showTimeoutError && (
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-orange-50 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">检查更新超时</h2>
|
||||
<p className="text-orange-600 text-sm mt-2">无法连接到更新服务器</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTimeoutError(false)
|
||||
setRetryCount(0)
|
||||
hasStartedRef.current = false
|
||||
startCheck()
|
||||
}}
|
||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm"
|
||||
>
|
||||
🔄 重新检查
|
||||
</button>
|
||||
|
||||
<p className="text-center text-gray-400 text-xs">
|
||||
请检查网络连接后重试
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 发现新版本 */}
|
||||
{status === 'available' && updateInfo && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-green-50 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">发现新版本</h2>
|
||||
<p className="text-green-600 font-mono mt-2">v{updateInfo.version}</p>
|
||||
</div>
|
||||
|
||||
{updateInfo.releaseNotes && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 max-h-32 overflow-y-auto">
|
||||
<p className="text-gray-600 text-sm whitespace-pre-wrap">{updateInfo.releaseNotes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm"
|
||||
>
|
||||
立即下载更新
|
||||
</button>
|
||||
|
||||
<p className="text-center text-gray-400 text-xs">
|
||||
必须更新后才能使用程序
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 下载中 */}
|
||||
{status === 'downloading' && progress && (
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-900 font-medium mb-1">正在下载更新</p>
|
||||
<p className="text-4xl font-bold text-blue-600">{progress.percent.toFixed(0)}%</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-300 rounded-full"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>{formatBytes(progress.transferred)} / {formatBytes(progress.total)}</span>
|
||||
<span>{formatBytes(progress.bytesPerSecond)}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-400 text-sm">
|
||||
请勿关闭程序...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 下载完成 - 自动重启安装 */}
|
||||
{status === 'downloaded' && (
|
||||
<AutoInstallCountdown installUpdate={installUpdate} />
|
||||
)}
|
||||
|
||||
{/* 错误 */}
|
||||
{status === 'error' && (
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-red-50 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-gray-900">检查更新失败</h2>
|
||||
<p className="text-red-500 text-sm mt-2">{error}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={startCheck}
|
||||
className="flex-1 py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm"
|
||||
>
|
||||
🔄 重试
|
||||
</button>
|
||||
<button
|
||||
onClick={onReady}
|
||||
className="flex-1 py-3 bg-gray-100 text-gray-600 rounded-lg font-medium hover:bg-gray-200 transition-all border border-gray-200"
|
||||
>
|
||||
跳过继续
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-400 text-xs">
|
||||
更新检查失败不影响正常使用
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部版权 */}
|
||||
<p className="text-center text-gray-400 text-xs mt-6">
|
||||
© 2025 Yolo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化字节数
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
311
src/pages/UpdateChecker.vue
Normal file
311
src/pages/UpdateChecker.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<!-- 非 Electron 环境不渲染 -->
|
||||
<div v-if="isElectronEnv"
|
||||
class="min-h-screen bg-gradient-to-br from-slate-100 to-slate-200 flex items-center justify-center p-6">
|
||||
<!-- 背景装饰 -->
|
||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div class="absolute -top-40 -right-40 w-96 h-96 bg-blue-100 rounded-full blur-3xl opacity-50" />
|
||||
<div class="absolute -bottom-40 -left-40 w-96 h-96 bg-purple-100 rounded-full blur-3xl opacity-50" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 w-full max-w-md">
|
||||
<!-- Logo 和标题 -->
|
||||
<div class="text-center mb-8">
|
||||
<div
|
||||
class="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">应用更新检查</h1>
|
||||
<p class="text-gray-500 text-sm">当前版本: v{{ currentVersion || '...' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 更新卡片 -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-xl p-6">
|
||||
|
||||
<!-- 检查中 -->
|
||||
<div v-if="status === 'checking' && !showTimeoutError" class="text-center py-8">
|
||||
<div
|
||||
class="w-12 h-12 mx-auto mb-4 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p class="text-gray-900 font-medium">正在检查更新...</p>
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
{{ retryCount > 0 ? `第 ${retryCount}/${MAX_RETRIES} 次重试...` : '请稍候' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 超时错误 -->
|
||||
<div v-if="showTimeoutError" class="space-y-6 py-4">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-16 h-16 mx-auto mb-4 bg-orange-50 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900">检查更新超时</h2>
|
||||
<p class="text-orange-600 text-sm mt-2">无法连接到更新服务器</p>
|
||||
</div>
|
||||
|
||||
<button @click="handleRetry"
|
||||
class="w-full py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm">
|
||||
🔄 重新检查
|
||||
</button>
|
||||
|
||||
<p class="text-center text-gray-400 text-xs">
|
||||
请检查网络连接后重试
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 发现新版本 -->
|
||||
<div v-if="status === 'available' && updateInfo" class="space-y-6">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 bg-green-50 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900">发现新版本</h2>
|
||||
<p class="text-green-600 font-mono mt-2">v{{ updateInfo.version }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="updateInfo.releaseNotes" class="bg-gray-50 rounded-lg p-3 max-h-32 overflow-y-auto">
|
||||
<p class="text-gray-600 text-sm whitespace-pre-wrap">{{ updateInfo.releaseNotes }}</p>
|
||||
</div>
|
||||
|
||||
<button @click="downloadUpdate"
|
||||
class="w-full py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm">
|
||||
立即下载更新
|
||||
</button>
|
||||
|
||||
<p class="text-center text-gray-400 text-xs">
|
||||
必须更新后才能使用程序
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 下载中 -->
|
||||
<div v-if="status === 'downloading' && progress" class="space-y-6 py-4">
|
||||
<div class="text-center">
|
||||
<p class="text-gray-900 font-medium mb-1">正在下载更新</p>
|
||||
<p class="text-4xl font-bold text-blue-600">{{ progress.percent.toFixed(0) }}%</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-300 rounded-full"
|
||||
:style="{ width: `${progress.percent}%` }" />
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-gray-500">
|
||||
<span>{{ formatBytes(progress.transferred) }} / {{ formatBytes(progress.total) }}</span>
|
||||
<span>{{ formatBytes(progress.bytesPerSecond) }}/s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-gray-400 text-sm">
|
||||
请勿关闭程序...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 下载完成 - 自动重启安装 -->
|
||||
<div v-if="status === 'downloaded'" class="space-y-6 py-4">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 bg-green-50 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900">下载完成</h2>
|
||||
<p class="text-green-600 text-sm mt-2">
|
||||
{{ countdown > 0 ? `${countdown} 秒后自动重启安装...` : '正在重启安装...' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button @click="installUpdate"
|
||||
class="w-full py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-lg font-medium hover:from-green-600 hover:to-green-700 transition-all shadow-sm">
|
||||
🚀 立即重启安装
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 错误 -->
|
||||
<div v-if="status === 'error'" class="space-y-6 py-4">
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 mx-auto mb-4 bg-red-50 rounded-full flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-gray-900">检查更新失败</h2>
|
||||
<p class="text-red-500 text-sm mt-2">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="startCheck"
|
||||
class="flex-1 py-3 bg-gradient-to-r from-blue-600 to-blue-500 text-white rounded-lg font-medium hover:from-blue-700 hover:to-blue-600 transition-all shadow-sm">
|
||||
🔄 重试
|
||||
</button>
|
||||
<button @click="emit('ready')"
|
||||
class="flex-1 py-3 bg-gray-100 text-gray-600 rounded-lg font-medium hover:bg-gray-200 transition-all border border-gray-200">
|
||||
跳过继续
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-gray-400 text-xs">
|
||||
更新检查失败不影响正常使用
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部版权 -->
|
||||
<p class="text-center text-gray-400 text-xs mt-6">
|
||||
© 2025 Yolo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useUpdate } from '../hooks/useUpdate'
|
||||
import { isElectron } from '../utils/electronBridge'
|
||||
|
||||
const emit = defineEmits(['ready'])
|
||||
|
||||
const CHECK_TIMEOUT = 15000 // 15秒超时
|
||||
const MAX_RETRIES = 3 // 最大重试次数
|
||||
const AUTO_INSTALL_DELAY = 3 // 自动安装倒计时秒数
|
||||
|
||||
const {
|
||||
status,
|
||||
updateInfo,
|
||||
progress,
|
||||
error,
|
||||
currentVersion,
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
installUpdate
|
||||
} = useUpdate()
|
||||
|
||||
const isElectronEnv = isElectron()
|
||||
|
||||
const checkComplete = ref(false)
|
||||
const retryCount = ref(0)
|
||||
const isTimeout = ref(false)
|
||||
const showTimeoutError = ref(false)
|
||||
const countdown = ref(AUTO_INSTALL_DELAY)
|
||||
|
||||
let timeoutTimer = null
|
||||
let hasStarted = false
|
||||
let countdownTimer = null
|
||||
|
||||
// 非 Electron 环境直接 ready
|
||||
onMounted(() => {
|
||||
if (!isElectronEnv) {
|
||||
emit('ready')
|
||||
return
|
||||
}
|
||||
// 启动检查
|
||||
if (!hasStarted) {
|
||||
hasStarted = true
|
||||
startCheck()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听状态
|
||||
watch(status, (newStatus) => {
|
||||
if (newStatus !== 'checking' && timeoutTimer) {
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = null
|
||||
isTimeout.value = false
|
||||
}
|
||||
|
||||
if (newStatus === 'idle' && checkComplete.value) {
|
||||
emit('ready')
|
||||
}
|
||||
|
||||
if (newStatus !== 'checking' && newStatus !== 'idle') {
|
||||
checkComplete.value = true
|
||||
}
|
||||
if (newStatus === 'idle') {
|
||||
setTimeout(() => {
|
||||
checkComplete.value = true
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// Auto install timer
|
||||
if (newStatus === 'downloaded') {
|
||||
startCountdown()
|
||||
}
|
||||
})
|
||||
|
||||
watch(isTimeout, (val) => {
|
||||
if (val) {
|
||||
if (retryCount.value >= MAX_RETRIES) {
|
||||
console.log('[UpdateChecker] 更新检查超时,显示错误')
|
||||
showTimeoutError.value = true
|
||||
} else if (retryCount.value > 0) {
|
||||
setTimeout(() => {
|
||||
startCheck()
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(checkComplete, (val) => {
|
||||
if (val && status.value === 'idle') {
|
||||
emit('ready')
|
||||
}
|
||||
})
|
||||
|
||||
function startCheck() {
|
||||
if (!isElectronEnv) return
|
||||
|
||||
isTimeout.value = false
|
||||
checkForUpdates()
|
||||
|
||||
timeoutTimer = setTimeout(() => {
|
||||
if (status.value === 'checking') {
|
||||
isTimeout.value = true
|
||||
if (retryCount.value < MAX_RETRIES) {
|
||||
retryCount.value++
|
||||
}
|
||||
}
|
||||
}, CHECK_TIMEOUT)
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
showTimeoutError.value = false
|
||||
retryCount.value = 0
|
||||
hasStarted = false
|
||||
startCheck()
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
countdown.value = AUTO_INSTALL_DELAY
|
||||
countdownTimer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(countdownTimer)
|
||||
installUpdate()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user