Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67d1dde6d5 | |||
| 8d103a91ab | |||
| 0dd02a13f6 |
232
CLAUDE.md
Normal file
232
CLAUDE.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 Vue 3 的 AI 助手 Web 前端应用(Yolo),可在浏览器和 Electron 环境中运行。项目主要用于自动化私信和内容管理,支持多账号管理、轮换策略和 AI 自动回复功能。
|
||||
|
||||
## 核心技术栈
|
||||
|
||||
- **前端框架**: Vue 3(使用 Composition API)
|
||||
- **构建工具**: Vite
|
||||
- **样式**: Tailwind CSS
|
||||
- **HTTP 请求**: Axios
|
||||
- **运行环境**: 支持浏览器和 Electron 双环境
|
||||
|
||||
## 常用开发命令
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 本地开发(默认端口 5173)
|
||||
npm run dev
|
||||
|
||||
# 生产构建
|
||||
npm run build
|
||||
|
||||
# 预览构建产物
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 项目架构
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API 接口定义
|
||||
├── assets/ # 静态资源(图片等)
|
||||
├── components/ # Vue 组件
|
||||
├── hooks/ # 可复用的组合式函数
|
||||
├── layout/ # 布局组件
|
||||
├── locales/ # 国际化配置
|
||||
├── pages/ # 页面级组件
|
||||
├── router/ # 路由配置
|
||||
├── styles/ # 全局样式
|
||||
├── types/ # TypeScript 类型定义
|
||||
├── utils/ # 工具函数
|
||||
└── views/ # 视图组件
|
||||
```
|
||||
|
||||
### 关键架构模式
|
||||
|
||||
#### 双环境支持设计
|
||||
|
||||
项目通过 `electronBridge.js` 实现 Web 和 Electron 环境的兼容:
|
||||
|
||||
- 使用 `isElectron()` 检测运行环境
|
||||
- 通过 `window.electronAPI` 访问 Electron 功能
|
||||
- 非 Electron 环境中 API 调用会安全降级
|
||||
|
||||
#### 页面状态管理
|
||||
|
||||
应用使用三个主要页面状态:
|
||||
1. `login` - 登录页面
|
||||
2. `config` - 配置页面(账号、AI 设置、任务配置)
|
||||
3. `browser` - 浏览器视图(9 个视图分为 A/B/C 三组)
|
||||
|
||||
状态存储于 `localStorage`:
|
||||
- `user_data` - 用户认证信息
|
||||
- `autoDm_runConfig` - 自动化运行配置
|
||||
|
||||
#### 视图分组系统
|
||||
|
||||
- 9 个视图被分为 3 组(A/B/C),每组 3 个视图
|
||||
- 视图 ID 映射:Tab A (1-3), Tab B (4-5), Tab C (7-9)
|
||||
- 支持账号轮换和自动切换策略
|
||||
|
||||
### API 请求架构
|
||||
|
||||
#### 请求拦截器配置
|
||||
|
||||
- **开发环境**: `http://192.168.2.22:8101`
|
||||
- **生产环境**: `https://crawlclient.api.yolozs.com`
|
||||
- **认证**: 通过 `vvtoken` header(登录和租户查询接口除外)
|
||||
- **超时时间**: 60 秒
|
||||
- **错误码 40400**: 强制退出登录并停止脚本
|
||||
|
||||
#### 关键 API 端点
|
||||
|
||||
```javascript
|
||||
// 认证相关
|
||||
/api/tenant/get-id-by-name - 根据租户名获取 ID
|
||||
/api/user/doLogin - 用户登录
|
||||
/api/tenant/get-expired-time - 获取账号到期时间
|
||||
|
||||
// 数据相关
|
||||
/api/save_data/hosts_info - 保存主播信息
|
||||
/api/save_data/live_host_detail - 查看直播间详情
|
||||
/api/common/country_info - 获取国家信息
|
||||
|
||||
// 账号管理
|
||||
/api/account/list - 查询员工列表
|
||||
/api/account/managerhosts - 分配主播
|
||||
```
|
||||
|
||||
### Electron 集成要点
|
||||
|
||||
#### 主要 IPC 通道
|
||||
|
||||
```javascript
|
||||
// 视图控制
|
||||
electronAPI.showViews() - 显示浏览器视图
|
||||
electronAPI.hideViews() - 隐藏浏览器视图
|
||||
electronAPI.switchTab(tabId) - 切换标签组
|
||||
electronAPI.switchToView(viewId) - 切换到指定视图
|
||||
|
||||
// 自动化控制
|
||||
electronAPI.stopTikTokAutomation(viewId) - 停止自动化任务
|
||||
electronAPI.updateAutomationConfig(config) - 更新自动化配置
|
||||
electronAPI.clearAllCache() - 清空所有缓存
|
||||
|
||||
// 状态监听
|
||||
electronAPI.onRotationStatusChanged(callback) - 监听轮换状态变化
|
||||
electronAPI.onGreetingStatsChanged(callback) - 监听问候统计变化
|
||||
electronAPI.onAutomationLog(callback) - 监听自动化日志
|
||||
|
||||
// 系统功能
|
||||
electronAPI.getAppVersion() - 获取应用版本
|
||||
electronAPI.checkHealth() - 健康检查
|
||||
```
|
||||
|
||||
#### 健康检查机制
|
||||
|
||||
每 5 秒执行一次健康检查:
|
||||
- 检测到错误码 40400 时自动退出登录
|
||||
- 仅在 Electron 环境且已登录状态下运行
|
||||
|
||||
## 样式和 UI 规范
|
||||
|
||||
### Tailwind 自定义配置
|
||||
|
||||
项目扩展了以下 Tailwind 主题:
|
||||
|
||||
**主色调**:
|
||||
- `primary-500` (#3b82f6) 用于主要操作按钮和强调元素
|
||||
- 提供完整的 50-900 梯度色阶
|
||||
|
||||
**自定义动画**:
|
||||
- `fadeIn` - 淡入效果(0.3s)
|
||||
- `slideIn` - 滑入效果(0.3s,带 Y 轴位移)
|
||||
|
||||
### 设计原则
|
||||
|
||||
- 使用渐变背景增强视觉层次:`from-slate-100 to-slate-200`
|
||||
- 卡片设计使用圆角和阴影:`rounded-2xl shadow-xl`
|
||||
- 状态指示器使用脉动动画:`animate-pulse`
|
||||
- 过渡效果统一使用 `transition-all`
|
||||
|
||||
## Vite 配置要点
|
||||
|
||||
### 路径别名
|
||||
|
||||
```javascript
|
||||
'@' -> src/
|
||||
```
|
||||
|
||||
### 开发服务器
|
||||
|
||||
- 端口:5173
|
||||
- 启用网络访问:`host: true`
|
||||
|
||||
### 构建配置
|
||||
|
||||
- 输出目录:`dist/`
|
||||
- 禁用 sourcemap(生产环境)
|
||||
|
||||
## 重要约定
|
||||
|
||||
### 状态管理
|
||||
|
||||
不使用 Vuex/Pinia,直接使用:
|
||||
- Composition API 的 `ref`/`reactive` 管理组件状态
|
||||
- `localStorage` 持久化关键配置
|
||||
- 事件监听器同步跨组件状态
|
||||
|
||||
### 组件通信
|
||||
|
||||
- 父子组件:通过 `props` 和 `emit` 事件
|
||||
- 跨页面:通过 `storage` 事件监听 `localStorage` 变化
|
||||
- Electron 通信:通过 `window.electronAPI` 的 IPC 通道
|
||||
|
||||
### 错误处理
|
||||
|
||||
- API 错误通过 Axios 拦截器统一处理
|
||||
- 40400 错误码触发全局登出
|
||||
- 使用 `try-catch` 保护可能失败的操作(带空 catch 块的是故意忽略错误)
|
||||
|
||||
### 代码风格
|
||||
|
||||
- 使用 ESLint 的 `eslint-disable-line no-empty` 标记故意为空的 catch 块
|
||||
- 组件使用 `<script setup>` 语法
|
||||
- 优先使用 `const` 和箭头函数
|
||||
- 模板中使用 `:class` 动态绑定样式数组
|
||||
|
||||
## 调试和测试
|
||||
|
||||
### 环境检测
|
||||
|
||||
- 开发环境:`window.location.port === '5173'`
|
||||
- Electron 环境:检查 `window.electronAPI` 是否存在
|
||||
|
||||
### 控制台日志规范
|
||||
|
||||
使用 `[组件名]` 前缀标识日志来源:
|
||||
```javascript
|
||||
console.log('[App] 收到轮换状态变化:', status)
|
||||
console.error('[App] 健康检查失败:', error)
|
||||
```
|
||||
|
||||
## 特殊注意事项
|
||||
|
||||
1. **更新检查**:应用启动时在 Electron 环境下会强制执行更新检查(`UpdateChecker` 组件)
|
||||
|
||||
2. **自动登出**:窗口关闭前(`beforeunload`)会自动清除 `localStorage` 中的用户数据
|
||||
|
||||
3. **并行停止任务**:停止所有任务时使用 `Promise.allSettled` 并行处理 9 个视图,避免串行等待
|
||||
|
||||
4. **配置持久化**:账号分组配置变化时通过 `storage` 事件跨组件同步
|
||||
|
||||
5. **视图映射**:视图 ID 与账号的映射关系通过 `viewAccountMap` 计算(每组 3 个账号)
|
||||
1518
package-lock.json
generated
1518
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -14,16 +14,8 @@
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.13.4",
|
||||
"element-plus": "^2.13.2",
|
||||
"less": "^4.5.1",
|
||||
"less-loader": "^12.3.0",
|
||||
"pinia": "^3.0.4",
|
||||
"postcss": "^8.4.49",
|
||||
"qwebchannel": "^6.2.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^5.4.11",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^5.0.2"
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
|
||||
104
src/App.vue
104
src/App.vue
@@ -5,7 +5,7 @@
|
||||
|
||||
<template v-else>
|
||||
<!-- 登录页面 -->
|
||||
<LoginPage v-if="currentPage === 'login'" @login-success="currentPage = 'browser'" class="animate-fadeIn" />
|
||||
<LoginPage v-if="currentPage === 'login'" @login-success="currentPage = 'config'" class="animate-fadeIn" />
|
||||
|
||||
<template v-else>
|
||||
<!-- 配置页面 - 使用 v-show 保持状态 -->
|
||||
@@ -15,16 +15,57 @@
|
||||
</div>
|
||||
|
||||
<!-- 浏览器页面 -->
|
||||
<div class="h-full w-full animate-fadeIn" v-show="currentPage === 'browser'">
|
||||
<WorkbenchLayout
|
||||
:account-groups="accountGroups"
|
||||
:rotation-status="rotationStatus"
|
||||
:greeting-stats="greetingStats"
|
||||
:automation-logs="automationLogs"
|
||||
@go-back="handleGoToConfig"
|
||||
@stop-all="handleStopAll"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
<div class="flex h-full w-full bg-gradient-to-br from-gray-50 to-gray-100 animate-fadeIn"
|
||||
v-show="currentPage === 'browser'">
|
||||
<!-- 侧边栏 -->
|
||||
<Sidebar :tabs="tabs" :current-tab="currentTab" @tab-switch="handleTabSwitch"
|
||||
@go-back="handleGoToConfig" @stop-all="handleStopAll" :is-loading="isLoading"
|
||||
:account-groups="accountGroups" :rotation-status="rotationStatus" :greeting-stats="greetingStats"
|
||||
:automation-logs="automationLogs" />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<main class="flex-1 flex flex-col relative">
|
||||
<!-- 顶部视图切换栏 -->
|
||||
<div class="h-12 bg-white border-b border-gray-200 flex items-center px-4 gap-2 shadow-sm">
|
||||
<span class="text-gray-500 text-sm mr-2">视图:</span>
|
||||
<button v-for="viewId in currentTabConfig.viewIds" :key="viewId" @click="handleViewSwitch(viewId)"
|
||||
:class="[
|
||||
'px-3 py-1.5 rounded-lg text-sm font-medium transition-all',
|
||||
(selectedViewId || currentTabConfig.viewIds[0]) === viewId
|
||||
? 'bg-blue-500 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-gray-200'
|
||||
]">
|
||||
视图 {{ viewId }}
|
||||
<span v-if="viewAccountMap[viewId]" class="ml-1.5 text-xs opacity-70">
|
||||
({{ viewAccountMap[viewId].email.split('@')[0] }})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- 状态指示 -->
|
||||
<span v-if="automationStatus[selectedViewId || currentTabConfig.viewIds[0]]"
|
||||
class="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded border border-gray-200">
|
||||
{{ automationStatus[selectedViewId || currentTabConfig.viewIds[0]] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 单个视图显示区域 -->
|
||||
<div class="flex-1 relative">
|
||||
<ViewPlaceholder class="absolute inset-0" />
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading"
|
||||
class="absolute inset-0 bg-slate-900/80 flex items-center justify-center z-50">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="w-10 h-10 border-3 border-t-primary-400 border-slate-600 rounded-full animate-spin" />
|
||||
<span class="text-slate-400 text-sm">切换中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 更新通知 -->
|
||||
<UpdateNotification />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -36,7 +77,8 @@ import { isElectron, getAppVersion } from './utils/electronBridge'
|
||||
import LoginPage from './pages/LoginPage.vue'
|
||||
import ConfigPage from './pages/ConfigPage.vue'
|
||||
import UpdateChecker from './pages/UpdateChecker.vue'
|
||||
import WorkbenchLayout from './layout/WorkbenchLayout.vue'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import ViewPlaceholder from './components/ViewPlaceholder.vue'
|
||||
import UpdateNotification from './components/UpdateNotification.vue'
|
||||
|
||||
// Constants
|
||||
@@ -46,8 +88,10 @@ const CONFIG_KEY = 'autoDm_runConfig'
|
||||
// State
|
||||
const updateReady = ref(false)
|
||||
const currentPage = ref('login')
|
||||
const currentTab = ref('A')
|
||||
const isLoading = ref(false)
|
||||
const automationStatus = ref({})
|
||||
const selectedViewId = ref(null)
|
||||
const accountGroups = ref([])
|
||||
const viewAccountMap = ref({})
|
||||
const rotationStatus = ref(null)
|
||||
@@ -57,6 +101,15 @@ const automationLogs = ref([])
|
||||
const isElectronEnv = isElectron()
|
||||
const isDev = window.location.port === '5173'
|
||||
|
||||
// Computed
|
||||
const tabs = computed(() => [
|
||||
{ id: 'A', label: accountGroups.value[0]?.name || 'Tab A', viewIds: [1, 2, 3] },
|
||||
{ id: 'B', label: accountGroups.value[1]?.name || 'Tab B', viewIds: [4, 5, 6] },
|
||||
{ id: 'C', label: accountGroups.value[2]?.name || 'Tab C', viewIds: [7, 8, 9] }
|
||||
])
|
||||
|
||||
const currentTabConfig = computed(() => tabs.value.find(t => t.id === currentTab.value) || tabs.value[0])
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Set Title
|
||||
@@ -72,7 +125,7 @@ onMounted(() => {
|
||||
if (userData) {
|
||||
const user = JSON.parse(userData)
|
||||
if (user && user.tokenValue) {
|
||||
currentPage.value = 'browser'
|
||||
currentPage.value = 'config'
|
||||
}
|
||||
}
|
||||
} catch { } // eslint-disable-line no-empty
|
||||
@@ -198,6 +251,31 @@ watch(currentPage, (newVal) => {
|
||||
})
|
||||
|
||||
// Actions
|
||||
const handleTabSwitch = async (tab) => {
|
||||
if (tab === currentTab.value) return
|
||||
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const result = await window.electronAPI.switchTab(tab)
|
||||
if (result.success) {
|
||||
currentTab.value = tab
|
||||
selectedViewId.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换标签失败:', error)
|
||||
}
|
||||
} else {
|
||||
currentTab.value = tab
|
||||
selectedViewId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewSwitch = async (viewId) => {
|
||||
selectedViewId.value = viewId
|
||||
if (isElectron()) {
|
||||
await window.electronAPI.switchToView(viewId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoToBrowser = async () => {
|
||||
if (isElectron()) {
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
class="px-3 py-1.5 text-sm bg-red-100 text-red-600 hover:bg-red-200 rounded disabled:opacity-50">
|
||||
删除选中
|
||||
</button>
|
||||
<button v-if="user.tenantId == 13259" @click="showBatchImport = 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>
|
||||
</div>
|
||||
|
||||
<!-- 筛选 -->
|
||||
@@ -68,10 +72,8 @@
|
||||
<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"
|
||||
<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 }} 级
|
||||
@@ -165,6 +167,66 @@
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 批量导入弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showBatchImport" 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 flex flex-col 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="closeBatchImport" class="text-gray-700 hover:text-gray-900 text-xl">✕</button>
|
||||
</div>
|
||||
<div class="p-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||
主播ID(每行一个)
|
||||
</label>
|
||||
<textarea v-model="batchInput" rows="8" placeholder="123456 789012 345678"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:border-blue-500 focus:outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">国家:</span>
|
||||
<select v-model="batchCountry"
|
||||
class="px-2 py-1 border border-gray-300 rounded text-sm focus:border-blue-500 focus:outline-none">
|
||||
<option v-for="c in COUNTRY_OPTIONS" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">等级:</span>
|
||||
<select v-model="batchLevel"
|
||||
class="px-2 py-1 border border-gray-300 rounded text-sm focus:border-blue-500 focus:outline-none">
|
||||
<option v-for="l in ALL_LEVELS" :key="l" :value="l">{{ l }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">票种:</span>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" v-model.number="batchInvitationType" :value="1" class="w-4 h-4" />
|
||||
<span class="text-sm">普票</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" v-model.number="batchInvitationType" :value="2" class="w-4 h-4" />
|
||||
<span class="text-sm text-yellow-600">金票</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="batchInput.trim()" class="text-sm p-3 bg-gray-50 rounded-lg">
|
||||
<span class="text-green-600">可导入:{{ batchParsed.valid.length }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 border-t border-gray-200 flex justify-end gap-3">
|
||||
<button @click="closeBatchImport"
|
||||
class="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">取消</button>
|
||||
<button @click="doBatchImport" :disabled="!batchParsed.valid.length || batchImporting"
|
||||
class="px-4 py-2 text-sm bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:opacity-50">
|
||||
{{ batchImporting ? '导入中...' : '导入' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -231,6 +293,22 @@ const maxCount = ref(100)
|
||||
const selectedLevels = ref(new Set())
|
||||
const showLevelDropdown = ref(false)
|
||||
|
||||
// 批量导入
|
||||
const showBatchImport = ref(false)
|
||||
const batchInput = ref('')
|
||||
const batchCountry = ref('美国')
|
||||
const batchLevel = ref('B3')
|
||||
const batchInvitationType = ref(1)
|
||||
const batchImporting = ref(false)
|
||||
|
||||
const COUNTRY_OPTIONS = ['美国', '英国', '加拿大', '澳大利亚', '德国', '法国', '日本', '韩国', '巴西', '印度尼西亚', '墨西哥', '菲律宾', '越南', '泰国', '马来西亚', '沙特阿拉伯', '西班牙', '意大利', '土耳其', '埃及', '尼日利亚', '哥伦比亚', '阿根廷', '智利', '秘鲁', '以色列', '伊拉克', '约旦']
|
||||
const ALL_LEVELS = [
|
||||
'A1', 'A2', 'A3',
|
||||
'B1', 'B2', 'B3', 'B4', 'B5',
|
||||
'C1', 'C2', 'C3', 'C4', 'C5',
|
||||
'D1', 'D2', 'D3', 'D4', 'D5',
|
||||
]
|
||||
|
||||
// Lifecycle
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
@@ -241,13 +319,22 @@ watch(() => props.visible, (newVal) => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
let user = ref()
|
||||
onMounted(() => {
|
||||
if (props.visible) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
loadHosts()
|
||||
loadConfig()
|
||||
}
|
||||
|
||||
// Load User Data
|
||||
try {
|
||||
const userData = localStorage.getItem('user_data')
|
||||
if (userData) {
|
||||
user.value = JSON.parse(userData)
|
||||
console.log('user.value', user.value)
|
||||
}
|
||||
} catch { }
|
||||
})
|
||||
|
||||
// 监听过滤器变化,同步到后端配置
|
||||
@@ -424,6 +511,46 @@ const deleteSelected = () => {
|
||||
|
||||
const onClose = () => emit('close')
|
||||
|
||||
// 批量导入 - 解析ID列表
|
||||
const batchParsed = computed(() => {
|
||||
const lines = batchInput.value.split('\n').map(l => l.trim()).filter(Boolean)
|
||||
const valid = lines.map(id => ({ anchorId: id, country: batchCountry.value, hostsLevel: batchLevel.value }))
|
||||
return { valid }
|
||||
})
|
||||
|
||||
const closeBatchImport = () => {
|
||||
showBatchImport.value = false
|
||||
batchInput.value = ''
|
||||
batchCountry.value = '美国'
|
||||
batchLevel.value = 'B3'
|
||||
batchInvitationType.value = 1
|
||||
}
|
||||
|
||||
const doBatchImport = async () => {
|
||||
const { valid } = batchParsed.value
|
||||
if (!valid.length) return
|
||||
if (!isElectron()) return
|
||||
batchImporting.value = true
|
||||
try {
|
||||
const result = await window.electronAPI.addAnchorData({
|
||||
anchors: valid,
|
||||
invitationType: batchInvitationType.value
|
||||
})
|
||||
if (result.success) {
|
||||
alert(`导入完成:新增 ${result.added} 个,跳过 ${result.skipped} 个重复`)
|
||||
closeBatchImport()
|
||||
await loadHosts()
|
||||
} else {
|
||||
alert(`导入失败:${result.error}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('批量导入失败:', e)
|
||||
alert('批量导入失败')
|
||||
} finally {
|
||||
batchImporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isElectron()) {
|
||||
await window.electronAPI.saveAnchorData(JSON.parse(JSON.stringify(hosts.value)))
|
||||
|
||||
14
src/main.js
14
src/main.js
@@ -1,17 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './styles/index.css'
|
||||
import App from './App.vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import i18n from './locales'
|
||||
import router from './router'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#root')
|
||||
createApp(App).mount('#root')
|
||||
|
||||
@@ -59,8 +59,7 @@
|
||||
<!-- 卡片头部 -->
|
||||
<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="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>
|
||||
|
||||
@@ -86,8 +85,7 @@
|
||||
@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">
|
||||
<span class="text-xs text-gray-400 group-hover/item:text-blue-400">
|
||||
{{ config.accountGroups.indexOf(group) + 1 }}
|
||||
</span>
|
||||
</button>
|
||||
@@ -152,15 +150,16 @@
|
||||
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="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"
|
||||
<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>
|
||||
@@ -183,16 +182,16 @@
|
||||
<!-- 轮换设置 -->
|
||||
<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>
|
||||
<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">
|
||||
@@ -219,7 +218,7 @@
|
||||
</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"
|
||||
@@ -324,7 +323,37 @@
|
||||
<HostListDialog :visible="showHostDialog" @close="showHostDialog = false" @save="() => { }" />
|
||||
|
||||
<!-- 打招呼内容弹窗 -->
|
||||
<GreetingDialog :visible="showGreetingDialog" @close="showGreetingDialog = false" @confirm="handleGreetingConfirm" />
|
||||
<GreetingDialog :visible="showGreetingDialog" @close="showGreetingDialog = false"
|
||||
@confirm="handleGreetingConfirm" />
|
||||
|
||||
|
||||
<!-- 预热 Loading 遮罩 -->
|
||||
<transition name="fade">
|
||||
<div v-if="warmingUp"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/30 backdrop-blur-sm">
|
||||
<div
|
||||
class="bg-white/90 rounded-2xl shadow-2xl border border-white/60 px-6 py-5 flex items-center gap-4">
|
||||
<!-- spinner -->
|
||||
<div class="w-10 h-10 rounded-full border-4 border-gray-200 border-t-blue-500 animate-spin"></div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold text-gray-900">正在预热视图</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
这会提升后台视图渲染稳定性,请稍候…
|
||||
</div>
|
||||
|
||||
<!-- 可选:进度小点点动画 -->
|
||||
<div class="flex gap-1 pt-1">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce [animation-delay:-0.2s]"></span>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce [animation-delay:-0.1s]"></span>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400 animate-bounce"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -489,18 +518,24 @@ const loadConfig = async () => {
|
||||
if (isElectronEnv) {
|
||||
const saved = await window.electronAPI.loadRunConfig()
|
||||
if (saved) {
|
||||
config.value = { ...config.value, ...saved }
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(saved))
|
||||
// 排除 filters 字段,因为过滤器配置由 HostListDialog 独立管理
|
||||
const { filters, ...runConfig } = saved
|
||||
config.value = { ...config.value, ...runConfig }
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(runConfig))
|
||||
} else {
|
||||
const localSaved = localStorage.getItem(CONFIG_KEY)
|
||||
if (localSaved) {
|
||||
config.value = { ...config.value, ...JSON.parse(localSaved) }
|
||||
const parsed = JSON.parse(localSaved)
|
||||
const { filters, ...runConfig } = parsed
|
||||
config.value = { ...config.value, ...runConfig }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const localSaved = localStorage.getItem(CONFIG_KEY)
|
||||
if (localSaved) {
|
||||
config.value = { ...config.value, ...JSON.parse(localSaved) }
|
||||
const parsed = JSON.parse(localSaved)
|
||||
const { filters, ...runConfig } = parsed
|
||||
config.value = { ...config.value, ...runConfig }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -517,7 +552,11 @@ const saveToLocalStorage = () => {
|
||||
const saveToFile = async () => {
|
||||
if (!isElectronEnv) return
|
||||
try {
|
||||
await window.electronAPI.saveRunConfig(JSON.parse(JSON.stringify(config.value)))
|
||||
// 只保存运行配置,不包含过滤器相关配置(过滤器由 HostListDialog 独立管理)
|
||||
const configToSave = JSON.parse(JSON.stringify(config.value))
|
||||
// 确保不覆盖 filters 配置
|
||||
delete configToSave.filters
|
||||
await window.electronAPI.saveRunConfig(configToSave)
|
||||
} catch (e) {
|
||||
console.error('保存配置失败:', e)
|
||||
}
|
||||
@@ -631,6 +670,7 @@ const handleSleepTimeInput = (val) => {
|
||||
config.value.sleepTime = parseInt(val) || 0
|
||||
}
|
||||
}
|
||||
const warmingUp = ref(false)
|
||||
|
||||
// Start/Stop
|
||||
const handleStart = async (specificGroupIndex) => {
|
||||
@@ -674,6 +714,7 @@ const handleStart = async (specificGroupIndex) => {
|
||||
rotationEnabled: config.value.rotateEnabled,
|
||||
rotationIntervalMinutes: config.value.switchMinutes,
|
||||
currentActiveGroup: activeGroupName,
|
||||
// 这里只是保存配置到 configStore,不会直接传给 automation
|
||||
})
|
||||
|
||||
const groupsToStart = config.value.rotateEnabled
|
||||
@@ -694,8 +735,8 @@ const handleStart = async (specificGroupIndex) => {
|
||||
const cleanAccount = JSON.parse(JSON.stringify(acc))
|
||||
startTasks.push({
|
||||
viewId: currentViewId,
|
||||
account: {
|
||||
...cleanAccount,
|
||||
account: {
|
||||
...cleanAccount,
|
||||
group: group.name,
|
||||
lang: config.value.lang || 'en' // 传递语言配置
|
||||
},
|
||||
@@ -706,6 +747,18 @@ const handleStart = async (specificGroupIndex) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 预热所有视图,确保后台视图完成渲染,解决自动化执行失败问题
|
||||
warmingUp.value = true
|
||||
try {
|
||||
console.log('[ConfigPage] 预热所有视图...')
|
||||
await window.electronAPI.warmUpViews()
|
||||
console.log('[ConfigPage] 视图预热完成')
|
||||
} catch (e) {
|
||||
console.warn('[ConfigPage] 视图预热失败,继续启动:', e)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
startTasks.map(async ({ viewId, account, delay }) => {
|
||||
await new Promise(r => setTimeout(r, delay))
|
||||
@@ -745,6 +798,7 @@ const handleStart = async (specificGroupIndex) => {
|
||||
const status = await window.electronAPI.getRotationStatus()
|
||||
rotationStatus.value = status
|
||||
handleStatusChange(status)
|
||||
warmingUp.value = false //关闭遮罩
|
||||
emit('goToBrowser')
|
||||
}
|
||||
|
||||
@@ -810,3 +864,14 @@ const togglePasswordVisibility = (gIndex, aIndex) => {
|
||||
showPasswordMap.value[key] = !showPasswordMap.value[key]
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,28 +1,6 @@
|
||||
```
|
||||
<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%)" />
|
||||
@@ -157,24 +135,14 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { isElectron, getAppVersion } from '../utils/electronBridge'
|
||||
import { setUser, setToken, setUserPass, getUserPass, setPermissions } 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 STORAGE_KEY = 'login_credentials'
|
||||
const USER_KEY = 'user_data'
|
||||
|
||||
const credentials = ref({
|
||||
tenantName: '',
|
||||
@@ -193,12 +161,13 @@ onMounted(() => {
|
||||
|
||||
// 加载保存的凭据
|
||||
try {
|
||||
const saved = getUserPass()
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved)
|
||||
credentials.value = {
|
||||
tenantName: saved.tenantName || '',
|
||||
username: saved.username || saved.userId || '',
|
||||
password: saved.password || '',
|
||||
tenantName: data.tenantName || '',
|
||||
username: data.username || data.userId || '',
|
||||
password: data.password || '',
|
||||
}
|
||||
}
|
||||
} catch { } // eslint-disable-line no-empty
|
||||
@@ -214,8 +183,8 @@ const handleSubmit = async () => {
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 保存凭据 (Using compatible storage helper)
|
||||
setUserPass(credentials.value)
|
||||
// 保存凭据
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(credentials.value))
|
||||
|
||||
console.log('[LoginPage] 开始登录...', credentials.value)
|
||||
|
||||
@@ -229,17 +198,7 @@ const handleSubmit = async () => {
|
||||
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);
|
||||
|
||||
// 保存权限信息
|
||||
setPermissions({
|
||||
bigBrother: result.user.bigBrother,
|
||||
crawl: result.user.crawl,
|
||||
webAi: result.user.webAi,
|
||||
});
|
||||
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(result.user))
|
||||
emit('loginSuccess')
|
||||
} else {
|
||||
error.value = result.error || '登录失败'
|
||||
|
||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -111,6 +111,7 @@ export interface ElectronAPI {
|
||||
loadAIConfig: () => Promise<Record<string, unknown>>
|
||||
loadAnchorData: () => Promise<unknown[]>
|
||||
saveAnchorData: (data: unknown[]) => Promise<{ success: boolean }>
|
||||
addAnchorData: (data: { anchors: { anchorId: string; country: string; hostsLevel: string }[]; invitationType: number }) => Promise<{ success: boolean; added?: number; skipped?: number; error?: string }>
|
||||
saveRunConfig: (config: Record<string, unknown>) => Promise<{ success: boolean }>
|
||||
loadRunConfig: () => Promise<Record<string, unknown> | null>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user