{% extends "base.html" %} {% block title %}仪表板 - AI答案采集平台{% endblock %} {% block styles %} .header { background: white; padding: 20px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; } .header-title { font-size: 24px; font-weight: 600; color: #333; } /* 操作步骤样式 */ .steps-container { background: white; padding: 24px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } .steps-title { font-size: 16px; font-weight: 600; color: #333; margin-bottom: 20px; } /* 警示信息样式 */ .warning-text { font-size: 13px; color: #ad6800; line-height: 1.6; } .warning-text ul { margin: 8px 0 0 0; padding-left: 20px; } .warning-text li { margin-bottom: 4px; } /* 帮助按钮 */ .help-btn { position: absolute; top: 16px; right: 16px; width: 24px; height: 24px; border-radius: 50%; border: 2px solid #d9d9d9; background: white; color: #8c8c8c; font-size: 13px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; line-height: 1; } .help-btn:hover { border-color: #fa8c16; color: #fa8c16; } .task-card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); transition: all 0.3s; } .task-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); } .task-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; } .task-name { font-size: 18px; font-weight: 600; color: #333; } .task-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; margin-bottom: 15px; } .info-item { display: flex; flex-direction: column; } .info-label { font-size: 12px; color: #999; margin-bottom: 5px; } .info-value { font-size: 14px; color: #333; } .task-actions { display: flex; gap: 10px; } .empty-state { text-align: center; padding: 60px 20px; color: #999; } .empty-state i { font-size: 64px; margin-bottom: 20px; color: #ddd; } .analysis-filter { background: #f0f2f5; padding: 15px; border-radius: 8px; margin-bottom: 20px; } {% endblock %} {% block content %}
Logo AI 答案采集分析 v[[ appInfo.version ]] 单机版
采集设置: [[ dailyLimit ]]条 [[ minInterval ]]~[[ maxInterval ]]秒 浏览器设置 智慧舆情设置 创建任务 退出登录
⚠️ 重要提示:数据采集限制
为避免触发AI平台的反爬虫机制和账号风险,请遵守以下规则:
  • 每日采集限制:建议单个平台每天采集不超过 [[ dailyLimit ]] 条数据
  • 采集间隔:建议每次采集间隔至少 30 秒
  • 账号安全:频繁采集可能导致账号被限制或封禁
  • 合理使用:仅用于品牌监控和数据分析,请勿用于商业爬虫
今日已采集: [[ todayCollectionCount ]] / [[ dailyLimit ]] 条
⚠️ 已达到设定上限,请调整上限或明天再采集
操作步骤
[[ currentStep === 0 ? '第一步:' : '' ]]登录账号验证 [[ currentStep === 1 ? '第二步:' : '' ]]创建任务 [[ currentStep === 2 ? '第三步:' : '' ]]开始采集
[[ task.name ]]
[[ getStatusText(task.status) ]]
品牌名称
[[ task.brand_name || '-' ]]
品牌曝光词
[[ task.brand_keywords.join(', ') ]]
监控问题
[[ task.questions.length ]] 个
AI平台
[[ task.platforms.length ]] 个
截图配置
全部启用 全部禁用 部分启用 ([[ getScreenshotEnabledCount(task) ]]/[[ task.platforms.length ]])
最后运行
[[ formatDate(task.last_run_at) ]]
已达上限 开始采集 [[ taskCommanding[String(task.id)] === 'pause' ? '暂停中……' : '暂停采集' ]] [[ taskCommanding[String(task.id)] === 'resume' ? '恢复中……' : '继续采集' ]] [[ taskCommanding[String(task.id)] === 'stop' ? '停止中……' : '结束采集' ]] 查看结果 编辑 [[ taskCommanding[String(task.id)] === 'reset' ? '重置中……' : '重置状态' ]] 删除
暂无监控任务
点击"创建任务"开始监控AI平台的品牌曝光
链接去重 (?) 分析
* 链接去重:在当前筛选范围内,相同的 URL 仅统计一次,反映引用的唯一来源分布。
顶级域名分布 导出数据
媒体引用排行
共 [[ analysisData.length ]] 个媒体 导出数据
暂无分析数据
调整筛选条件并点击"分析"按钮
顶级域名 完整域名 分析
刷新分析
添加GEO稿件 导出分析
* 默认展示今日最新一次的数据

舆情配置管理

添加配置
注意:请填写完整的API端点地址,如OpenAI类接口通常为 /v1/chat/completions
占位符: {text} → AI回答内容 {keyword} → 品牌曝光词 注意:输出必须为JSON格式
请确保以下AI平台已登录,未登录的平台将无法采集数据
提示:
  • 点击"去登录"会打开浏览器窗口,请在浏览器中完成登录
  • 登录完成后,关闭浏览器窗口(或等待自动关闭)
  • 然后点击"重新检测"验证登录状态
  • 登录状态会保存在浏览器中,下次无需重复登录
  • 需要退出某个平台账号时,点击"清除登录"即可删除当前用户的 Cookie
  • 浏览器窗口将在 5 分钟后自动关闭
[[ keyword ]] + 添加关键词 [[ brand ]] + 添加竞品品牌
多个竞品品牌用回车或点击空白处添加
每行一个问题,共 [[ taskForm.questions.length ]] 个问题
[[ platform.name ]]
设置同时执行的AI平台数量。设为1则串行执行(逐个完成),设为大于平台数则全部同时执行。
启用后会截取完整对话截图(拼接长图),禁用则只采集AI回答和引用参考数据
各平台截图设置:
[[ getPlatformName(platformId) ]]
手动执行 每日执行 每周执行
启用自动采集
每日采集次数:
每日1次 每日2次 每日3次 自定义
执行时间:
第一次:
第二次:
第[[i]]次:
自定义执行时间(每行一个):
提示:系统会在设定时间自动执行采集任务,请确保浏览器保持登录状态。
启用自动采集
执行星期:
周一 周二 周三 周四 周五 周六 周日
执行时间:
[[ config.name ]] 默认 AI
选择任务专属的舆情配置,不选择则使用全局默认配置
设定每日允许采集的最大条数。建议不要超过 100 以降低封号风险。
~
问题之间的等待时间范围(秒)。系统会在此范围内随机选择,防止被判定为恶意采集。建议最小值不低于 30 秒。
留空则使用系统自动检测模式。自定义路径将优先于自动检测。
[[ testResult.message ]]
常见浏览器路径参考:
Chrome: C:\Program Files\Google\Chrome\Application\chrome.exe
Edge: C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe
说明:建议使用 Chrome 或 Edge 稳定版,不建议使用开发版或便携版浏览器
支持选择多个任务
批量添加:一行一个URL,系统将为每个URL创建一条记录。
系统将在采集到的"引用参考"中查找包含此内容的链接。
{% endblock %} {% block scripts %} const app = createApp({ delimiters: ['[[', ']]'], data() { return { currentUser: null, desktopMode: false, appInfo: { name: 'GEO-SOP', version: '', build_number: '', build_date: '' }, dataDir: '', activeTab: 'tasks', tasks: [], loading: false, dialogVisible: false, dialogTitle: '创建任务', isEdit: false, editingTaskId: null, submitting: false, // 引用分析 analyzing: false, analysisFilter: { platform: '', task_id: null, dateRange: [], deduplicate: true }, analysisData: [], // 详细数据(完整域名) topAnalysisData: [], // 聚合数据(顶级域名) mediaChart: null, // 走势分析 trending: false, trendData: null, trendFilter: { platform: '', task_id: null, dateRange: [], top_n: 10, domains: [], level: 'top' }, trendChart: null, allDomains: [], // 完整域名列表 allTopDomains: [], // 顶级域名列表 // 登录验证相关 loginDialogVisible: false, checkingLogin: false, quickChecking: false, platformLoginStatus: [], // 从 API 获取 // 操作步骤 currentStep: 0, // 0: 登录账号, 1: 创建任务, 2: 开始采集 // 今日采集数量 todayCollectionCount: 0, dailyLimit: 50, collectionInterval: 30, minInterval: 30, maxInterval: 120, limitSettingsVisible: false, limitForm: { daily_limit: 50, min_interval: 30, max_interval: 120 }, taskForm: { name: '', brand_name: '', brand_keywords: [], competitor_brands: [], questions: [], platforms: [], max_parallel_platforms: 3, screenshot_config: {}, schedule_type: 'manual', sentiment_config_id: null }, screenshotGlobalEnabled: true, questionsText: '', keywordInput: '', keywordInputVisible: false, competitorInput: '', competitorInputVisible: false, formRules: { name: [ { required: true, message: '请输入任务名称', trigger: 'blur' } ], brand_keywords: [ { type: 'array', required: true, message: '请添加至少一个品牌曝光词', trigger: 'change' } ], questions: [ { type: 'array', required: true, message: '请输入监控问题', trigger: 'change' } ], platforms: [ { type: 'array', required: true, message: '请选择至少一个AI平台', trigger: 'change' } ] }, // 平台列表(从 API 动态获取) availablePlatforms: [], browserSettingsVisible: false, savingBrowserConfig: false, browserConfig: { current_browser_path: '', current_browser_type: '', configured_path: '' }, browserConfigForm: { browser_path: '' }, testResult: { success: false, message: '' }, // 调度配置 scheduleConfig: { enable_schedule: false, frequency: 'once', run_time_1: '09:00', run_time_2: '14:00', run_time_3: '20:00', run_time: '09:00', run_weekdays: [0], custom_times_text: '09:00\n14:00\n20:00' }, // GEO稿件分析 geoLoading: false, geoFilter: { task_id: null, platform: 'doubao', date: '' }, geoCoverageData: [], geoDialogVisible: false, geoDialogTitle: '添加GEO稿件', geoSubmitting: false, geoForm: { id: null, task_ids: [], title: '', url: '' }, geoDetailsVisible: false, currentGeoDetails: [], taskPollers: {}, taskCommanding: {}, // 舆情配置 sentimentConfigs: [], sentimentConfigDialogVisible: false, editingSentimentConfig: null, sentimentConfigForm: { id: null, name: '', positiveWordsInput: '', negativeWordsInput: '', enable_ai_sentiment: false, ai_platform: '', ai_api_url: '', ai_api_key: '', ai_model_name: '', ai_prompt: '', is_default: false } }; }, computed: { filteredDomains() { return this.trendFilter.level === 'top' ? this.allTopDomains : this.allDomains; } }, watch: { questionsText(val) { this.taskForm.questions = val.split('\n').filter(q => q.trim()); } }, async mounted() { await this.loadCurrentUser(); this.loadTasks(); this.loadDailyLimit(); this.loadTodayCollectionCount(); await this.loadPlatforms(); // 从 API 获取平台列表 await this.loadCachedLoginStatus(); // 加载当前用户缓存的登录状态 this.showWelcomeGuideIfNeeded(); this.loadBrowserConfig(); // 加载浏览器配置 this.initAnalysisFilter(); // 初始化引用参考源分析筛选(默认最近一月) this.initTrendFilter(); // 初始化走势图筛选(默认最近一月) this.initGeoFilter(); // 初始化GEO分析筛选 this.loadAnalysisData(); // 默认加载分析数据 this.loadSentimentConfigs(); // 加载舆情配置 this.loadTrendDomains(); // 初始化域名列表 this.loadTrendData(); // 默认加载走势数据 this.loadGeoCoverageData(); // 默认加载GEO分析数据 this.updateCurrentStep(); }, beforeUnmount() { Object.keys(this.taskPollers).forEach(taskId => { this.clearTaskPolling(taskId); }); }, methods: { async loadCurrentUser() { try { const response = await axios.get('/api/current-user'); if (response.data.success) { this.currentUser = response.data.user; this.desktopMode = !!response.data.desktop_mode; this.appInfo = response.data.app || this.appInfo; this.dataDir = response.data.data_dir || ''; } } catch (error) { console.error('获取当前用户失败:', error); } }, loginStatusStorageKey() { const userId = this.currentUser?.id || 'anonymous'; return `platformLoginStatus:${userId}`; }, normalizeLoginStatus(value) { if (value && typeof value === 'object') { return { isLoggedIn: !!value.is_logged_in, checkedAt: value.checked_at || '', statusMessage: value.error || '' }; } return { isLoggedIn: !!value, checkedAt: '', statusMessage: '' }; }, applyPlatformLoginStatus(platformId, value) { const platform = this.platformLoginStatus.find(p => p.id === platformId); if (!platform) return; const normalized = this.normalizeLoginStatus(value); platform.isLoggedIn = normalized.isLoggedIn; platform.checkedAt = normalized.checkedAt; platform.statusMessage = normalized.statusMessage; }, persistLoginStatus(platformId, payload) { const loginStatus = JSON.parse(localStorage.getItem(this.loginStatusStorageKey()) || '{}'); loginStatus[platformId] = payload; localStorage.setItem(this.loginStatusStorageKey(), JSON.stringify(loginStatus)); axios.post('/api/login-status-cache', { status: loginStatus }).catch(e => { console.error('同步登录状态缓存失败:', e); }); }, showWelcomeGuideIfNeeded() { const params = new URLSearchParams(window.location.search); const welcome = params.get('welcome') === '1'; const guideKey = `onboardingShown:${this.currentUser?.id || 'anonymous'}`; const hasCache = !!localStorage.getItem(this.loginStatusStorageKey()); if (welcome || (!hasCache && !localStorage.getItem(guideKey))) { localStorage.setItem(guideKey, '1'); setTimeout(() => { ElMessage.info('请先完成需要使用的 AI 平台账号登录,每个系统用户会保存独立登录状态。'); this.quickCheckAndShowLogin(); }, 500); } }, async loadPlatforms() { try { const response = await axios.get('/api/platforms'); if (response.data.success) { const platforms = response.data.platforms; // 更新平台列表 this.availablePlatforms = platforms; // 将 API 返回的平台列表转换为登录状态格式 this.platformLoginStatus = platforms.map(p => ({ id: p.id, name: p.name, url: p.url, isLoggedIn: false, checkedAt: '', statusMessage: '', checking: false })); console.log('平台列表已加载:', this.availablePlatforms); } } catch (error) { console.error('获取平台列表失败:', error); } }, handleTabClick(tab) { if (tab.name === 'analysis') { if (this.analysisData.length === 0) { this.loadAnalysisData(); } else { this.$nextTick(() => { this.renderMediaChart(); }); } } else if (tab.name === 'trends') { if (this.allDomains.length === 0) { this.loadAnalysisData(); } this.$nextTick(() => { this.renderTrendChart(); }); } else if (tab.name === 'geo_analysis') { this.loadGeoCoverageData(); } }, async loadGeoCoverageData() { this.geoLoading = true; try { const params = {}; if (this.geoFilter.task_id) params.task_id = this.geoFilter.task_id; if (this.geoFilter.platform) params.platform = this.geoFilter.platform; if (this.geoFilter.date) params.date = this.geoFilter.date; const response = await axios.get('/api/analysis/geo-coverage', { params }); if (response.data.success) { this.geoCoverageData = response.data.data; } } catch (error) { console.error('加载GEO分析数据失败:', error); ElMessage.error('加载GEO分析数据失败'); } finally { this.geoLoading = false; } }, async exportGeoAnalysis() { try { const params = new URLSearchParams(); if (this.geoFilter.task_id) params.append('task_id', this.geoFilter.task_id); if (this.geoFilter.platform) params.append('platform', this.geoFilter.platform); if (this.geoFilter.date) params.append('date', this.geoFilter.date); const url = `/api/analysis/geo-coverage/export?${params.toString()}`; const response = await axios.get(url, { responseType: 'blob' }); // 创建下载链接 const blob = new Blob([response.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = `GEO稿件被引用分析_${new Date().toISOString().split('T')[0]}.xlsx`; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); ElMessage.success('导出成功'); } catch (error) { console.error('导出失败:', error); ElMessage.error('导出失败'); } }, initGeoFilter() { // 默认展示今日 const today = new Date(); const formatDate = (date) => { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; }; this.geoFilter.date = formatDate(today); this.geoFilter.platform = 'doubao'; }, showAddGeoManuscript() { this.geoDialogTitle = '添加GEO稿件'; this.geoForm = { id: null, task_ids: this.geoFilter.task_id ? [this.geoFilter.task_id] : [], title: '', url: '' }; this.geoDialogVisible = true; }, editGeoManuscript(row) { this.geoDialogTitle = '编辑GEO稿件'; this.geoForm = { id: row.id, task_ids: row.task_ids || [], title: row.title, url: row.url }; this.geoDialogVisible = true; }, async submitGeoManuscript() { if (!this.geoForm.task_ids || this.geoForm.task_ids.length === 0 || !this.geoForm.title || !this.geoForm.url) { ElMessage.warning('请填写完整信息'); return; } // 解析多行URL const urls = this.geoForm.url.split('\n') .map(url => url.trim()) .filter(url => url.length > 0); if (urls.length === 0) { ElMessage.warning('请输入至少一个URL'); return; } this.geoSubmitting = true; try { // 如果是更新操作,只处理第一个URL if (this.geoForm.id) { const response = await axios.post('/api/geo-manuscripts', { ...this.geoForm, url: urls[0] }); if (response.data.success) { ElMessage.success('更新成功'); this.geoDialogVisible = false; this.loadGeoCoverageData(); } } else { // 批量添加 const response = await axios.post('/api/geo-manuscripts', { task_ids: this.geoForm.task_ids, title: this.geoForm.title, urls: urls }); if (response.data.success) { ElMessage.success(`成功添加 ${response.data.count} 条记录`); this.geoDialogVisible = false; this.loadGeoCoverageData(); } } } catch (error) { ElMessage.error('保存失败'); } finally { this.geoSubmitting = false; } }, async confirmDeleteGeo(row) { try { await ElMessageBox.confirm('确定删除该GEO稿件吗?分析数据将不再显示此条目。', '提示', { type: 'warning' }); const response = await axios.delete(`/api/geo-manuscripts/${row.id}`); if (response.data.success) { ElMessage.success('已删除'); this.loadGeoCoverageData(); } } catch (error) { if (error !== 'cancel') ElMessage.error('删除失败'); } }, viewGeoDetails(row) { this.currentGeoDetails = row.details; this.geoDetailsVisible = true; }, initTrendFilter() { // 默认最近一月 const end = new Date(); const start = new Date(); start.setMonth(start.getMonth() - 1); const formatDate = (date) => { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; }; this.trendFilter.dateRange = [formatDate(start), formatDate(end)]; }, initAnalysisFilter() { // 默认最近一月 const end = new Date(); const start = new Date(); start.setMonth(start.getMonth() - 1); const formatDate = (date) => { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; }; this.analysisFilter.dateRange = [formatDate(start), formatDate(end)]; }, onTrendLevelChange() { this.trendFilter.domains = []; this.loadTrendDomains(); this.loadTrendData(); }, async loadTrendDomains() { let params = { level: this.trendFilter.level }; if (this.trendFilter.platform) { params.platform = this.trendFilter.platform; } if (this.trendFilter.task_id) { params.task_id = this.trendFilter.task_id; } if (this.trendFilter.dateRange && this.trendFilter.dateRange.length === 2) { params.date_start = this.trendFilter.dateRange[0]; params.date_end = this.trendFilter.dateRange[1]; } try { const response = await axios.get('/api/analysis/domains', { params }); if (response.data.success) { if (this.trendFilter.level === 'top') { this.allTopDomains = response.data.domains; } else { this.allDomains = response.data.domains; } } } catch (error) { console.error('加载域名列表失败:', error); } }, onTrendFilterChange() { this.trendFilter.domains = []; this.loadTrendDomains(); }, async loadTrendData() { this.trending = true; let params = { top_n: this.trendFilter.top_n, level: this.trendFilter.level }; if (this.trendFilter.platform) { params.platform = this.trendFilter.platform; } if (this.trendFilter.task_id) { params.task_id = this.trendFilter.task_id; } // 处理日期范围:如果有完整的日期范围则传递,否则传递空字符串让后端使用默认值 if (this.trendFilter.dateRange && this.trendFilter.dateRange.length === 2 && this.trendFilter.dateRange[0] && this.trendFilter.dateRange[1]) { params.date_start = this.trendFilter.dateRange[0]; params.date_end = this.trendFilter.dateRange[1]; console.log('使用用户选择的日期范围:', params.date_start, '至', params.date_end); } else { // 用户未选择日期或日期不完整,传递空参数让后端使用默认值 params.date_start = ''; params.date_end = ''; console.log('使用默认日期范围(近一年)'); } if (this.trendFilter.domains && this.trendFilter.domains.length > 0) { params.domains = this.trendFilter.domains; } console.log('loadTrendData params:', params); try { const response = await axios.get('/api/analysis/reference-trends', { params }); console.log('loadTrendData response:', response.data); if (response.data.success) { this.trendData = response.data; // 存储完整响应 this.$nextTick(() => { this.renderTrendChart(); }); } else { // API调用成功但返回失败状态 console.error('loadTrendData: API returned failure:', response.data.message); ElMessage.error(response.data.message || '走势数据加载失败'); // 清空趋势数据,显示暂无数据 this.trendData = null; this.$nextTick(() => { this.renderTrendChart(); }); } } catch (error) { console.error('loadTrendData error:', error); ElMessage.error('走势数据加载失败'); // 清空趋势数据,显示暂无数据 this.trendData = null; this.$nextTick(() => { this.renderTrendChart(); }); } finally { this.trending = false; } }, renderTrendChart() { const chartDom = document.getElementById('trendChart'); if (!chartDom) { console.error('renderTrendChart: chartDom is null'); return; } // 确保容器有正确的尺寸 if (chartDom.offsetWidth === 0 || chartDom.offsetHeight === 0) { setTimeout(() => this.renderTrendChart(), 100); return; } if (!this.trendData) { // 如果有图表实例,先清空 if (this.trendChart) { try { this.trendChart.clear(); } catch (e) { console.warn('Failed to clear chart:', e); } } chartDom.innerHTML = '
暂无数据
'; return; } // 检查是否有数据 if (!this.trendData.dates || !Array.isArray(this.trendData.dates) || this.trendData.dates.length === 0 || !this.trendData.series || !Array.isArray(this.trendData.series) || this.trendData.series.length === 0) { // 如果有图表实例,先清空 if (this.trendChart) { try { this.trendChart.clear(); } catch (e) { console.warn('Failed to clear chart:', e); } } // 没有数据时显示提示 chartDom.innerHTML = '
暂无数据
'; return; } try { // 如果已有图表实例,使用clear方法而不是dispose if (this.trendChart) { try { this.trendChart.clear(); } catch (e) { // 如果clear失败,说明实例已失效,重新创建 console.warn('Failed to clear existing chart, will recreate:', e); this.trendChart = null; } } // 如果没有实例或实例已失效,创建新实例 if (!this.trendChart) { this.trendChart = echarts.init(chartDom); } const option = { title: { text: `顶级域名引用走势 (TOP ${this.trendFilter.top_n})`, left: 'center' }, tooltip: { trigger: 'axis' }, legend: { data: this.trendData.top_domains || [], bottom: 0, type: 'scroll' }, grid: { left: '3%', right: '4%', bottom: '10%', containLabel: true }, xAxis: { type: 'category', boundaryGap: false, data: this.trendData.dates }, yAxis: { type: 'value', name: '引用次数' }, series: this.trendData.series }; this.trendChart.setOption(option, true); // 添加resize监听(确保只添加一次) const resizeHandler = () => { if (this.trendChart) { try { this.trendChart.resize(); } catch (e) { console.warn('Failed to resize chart:', e); } } }; // 先移除旧的监听,避免重复添加 window.removeEventListener('resize', resizeHandler); window.addEventListener('resize', resizeHandler); } catch (error) { console.error('renderTrendChart error:', error); // 确保清空图表 if (this.trendChart) { try { this.trendChart.clear(); } catch (e) {} } chartDom.innerHTML = `
图表渲染失败: ${error.message}
`; } }, async loadAnalysisData() { this.analyzing = true; let params = { deduplicate: this.analysisFilter.deduplicate }; if (this.analysisFilter.platform) { params.platform = this.analysisFilter.platform; } if (this.analysisFilter.task_id) { params.task_id = this.analysisFilter.task_id; } if (this.analysisFilter.dateRange && this.analysisFilter.dateRange.length === 2) { params.date_start = this.analysisFilter.dateRange[0]; params.date_end = this.analysisFilter.dateRange[1]; } try { const response = await axios.get('/api/analysis/references', { params }); if (response.data.success) { this.analysisData = response.data.full_data; this.topAnalysisData = response.data.top_data; // 更新走势图可选域名列表 this.allDomains = this.analysisData.map(d => d.name); this.allTopDomains = this.topAnalysisData.map(d => d.name); this.$nextTick(() => { this.renderMediaChart(); }); } } catch (error) { ElMessage.error('分析数据加载失败'); } finally { this.analyzing = false; } }, renderMediaChart() { const chartDom = document.getElementById('mediaChart'); if (!chartDom) return; // 如果容器还没有宽度(可能还在隐藏状态),则延迟重试 if (chartDom.offsetWidth === 0) { setTimeout(() => this.renderMediaChart(), 100); return; } if (this.mediaChart) { this.mediaChart.dispose(); } this.mediaChart = echarts.init(chartDom); // 取前 15 个顶级域名展示在图表中 const topData = this.topAnalysisData.slice(0, 15).reverse(); const option = { tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'value', boundaryGap: [0, 0.01] }, yAxis: { type: 'category', data: topData.map(item => item.name) }, series: [ { name: '引用次数', type: 'bar', data: topData.map(item => item.count), itemStyle: { color: '#1890ff' }, label: { show: true, position: 'right' } } ] }; this.mediaChart.setOption(option); // 响应式调整 window.addEventListener('resize', () => { this.mediaChart && this.mediaChart.resize(); }); }, async loadCachedLoginStatus() { // 从localStorage加载缓存的登录状态(快速显示) const cachedStatus = localStorage.getItem(this.loginStatusStorageKey()); if (cachedStatus) { try { const loginStatus = JSON.parse(cachedStatus); this.platformLoginStatus.forEach(platform => { if (loginStatus[platform.id] !== undefined) { this.applyPlatformLoginStatus(platform.id, loginStatus[platform.id]); } }); axios.post('/api/login-status-cache', { status: loginStatus }).catch(e => { console.error('同步本地登录状态缓存失败:', e); }); this.updateCurrentStep(); } catch (e) { console.error('解析缓存登录状态失败:', e); } } try { const response = await axios.get('/api/login-status-cache'); if (response.data.success && response.data.status) { const serverStatus = response.data.status; const loginStatus = JSON.parse(localStorage.getItem(this.loginStatusStorageKey()) || '{}'); Object.keys(serverStatus).forEach(platformId => { loginStatus[platformId] = serverStatus[platformId]; }); this.platformLoginStatus.forEach(platform => { if (loginStatus[platform.id] !== undefined) { this.applyPlatformLoginStatus(platform.id, loginStatus[platform.id]); } }); if (Object.keys(loginStatus).length > 0) { localStorage.setItem(this.loginStatusStorageKey(), JSON.stringify(loginStatus)); } this.updateCurrentStep(); } } catch (e) { console.error('读取后端登录状态缓存失败:', e); } }, async loadTasks() { this.loading = true; try { const response = await axios.get('/api/tasks'); this.tasks = response.data.tasks; const activeTaskIds = new Set( this.tasks .filter(task => ['running', 'paused'].includes(task.status)) .map(task => String(task.id)) ); Object.keys(this.taskPollers).forEach(taskId => { if (!activeTaskIds.has(taskId)) { this.clearTaskPolling(taskId); } }); this.tasks.forEach(task => { if (['running', 'paused'].includes(task.status)) { this.startTaskPolling(task.id); // 检查是否已经达到目标状态,如果是则清除 commanding const cmd = this.taskCommanding[String(task.id)]; if ((cmd === 'pause' && task.status === 'paused') || (cmd === 'resume' && task.status === 'running') || (cmd === 'stop' && task.status === 'stopped')) { this.clearTaskCommanding(task.id); } } }); this.updateCurrentStep(); } catch (error) { ElMessage.error('加载任务失败'); } finally { this.loading = false; } }, loadDailyLimit() { const limit = localStorage.getItem('dailyLimit'); if (limit) { this.dailyLimit = parseInt(limit); this.limitForm.daily_limit = this.dailyLimit; } const minInterval = localStorage.getItem('minInterval'); if (minInterval) { this.minInterval = parseInt(minInterval); this.limitForm.min_interval = this.minInterval; } const maxInterval = localStorage.getItem('maxInterval'); if (maxInterval) { this.maxInterval = parseInt(maxInterval); this.limitForm.max_interval = this.maxInterval; } }, showLimitSettings() { this.limitForm.daily_limit = this.dailyLimit; this.limitForm.min_interval = this.minInterval; this.limitForm.max_interval = this.maxInterval; this.limitSettingsVisible = true; }, saveLimitSettings() { this.dailyLimit = this.limitForm.daily_limit; this.minInterval = this.limitForm.min_interval; this.maxInterval = this.limitForm.max_interval; localStorage.setItem('dailyLimit', this.dailyLimit); localStorage.setItem('minInterval', this.minInterval); localStorage.setItem('maxInterval', this.maxInterval); this.limitSettingsVisible = false; ElMessage.success('设置已更新'); }, async loadTodayCollectionCount() { // 从 localStorage 获取今日采集数量 const today = new Date().toDateString(); const storedData = localStorage.getItem('collectionCount'); if (storedData) { try { const data = JSON.parse(storedData); if (data.date === today) { this.todayCollectionCount = data.count || 0; } else { // 新的一天,重置计数 this.todayCollectionCount = 0; localStorage.setItem('collectionCount', JSON.stringify({ date: today, count: 0 })); } } catch (e) { this.todayCollectionCount = 0; } } else { this.todayCollectionCount = 0; localStorage.setItem('collectionCount', JSON.stringify({ date: today, count: 0 })); } }, incrementCollectionCount(count = 1) { const today = new Date().toDateString(); this.todayCollectionCount += count; localStorage.setItem('collectionCount', JSON.stringify({ date: today, count: this.todayCollectionCount })); }, updateCurrentStep() { // 根据任务数量和登录状态更新当前步骤 const hasLoggedIn = this.platformLoginStatus.some(p => p.isLoggedIn); if (!hasLoggedIn) { this.currentStep = 0; } else if (this.tasks.length === 0) { this.currentStep = 1; } else { this.currentStep = 2; } }, showLoginDialog() { this.loginDialogVisible = true; if (!this.desktopMode) { // 云端模式自动检测;单机版避免一次性打开多个本机 Chrome。 this.checkAllLoginStatus(); } }, async quickCheckAndShowLogin() { // 快速检测登录状态并显示对话框 this.quickChecking = true; try { // 快速检测(使用localStorage缓存) const cachedStatus = localStorage.getItem(this.loginStatusStorageKey()); if (cachedStatus) { try { const loginStatus = JSON.parse(cachedStatus); this.platformLoginStatus.forEach(platform => { if (loginStatus[platform.id] !== undefined) { this.applyPlatformLoginStatus(platform.id, loginStatus[platform.id]); } }); } catch (e) { console.error('解析缓存登录状态失败:', e); } } // 显示对话框 this.loginDialogVisible = true; // 云端模式后台异步检测真实状态;单机版由用户按平台手动检测。 if (!this.desktopMode) { this.checkAllLoginStatus(); } } finally { this.quickChecking = false; } }, getLoggedInCount() { return this.platformLoginStatus.filter(p => p.isLoggedIn).length; }, async checkAllLoginStatus() { if (this.desktopMode) { ElMessage.info('单机版请按平台逐个点击“重新检测”,避免一次性打开多个浏览器。'); await this.loadCachedLoginStatus(); return; } this.checkingLogin = true; try { const response = await axios.get('/api/check-login/all'); if (response.data.success) { // 更新登录状态 const platforms = response.data.platforms; platforms.forEach(platform => { this.applyPlatformLoginStatus(platform.platform, { is_logged_in: platform.is_logged_in, checked_at: platform.checked_at || '', error: platform.error || '' }); }); // 保存到 localStorage const loginStatus = {}; platforms.forEach(p => { loginStatus[p.platform] = { is_logged_in: p.is_logged_in, checked_at: p.checked_at || '', error: p.error || '' }; }); localStorage.setItem(this.loginStatusStorageKey(), JSON.stringify(loginStatus)); this.updateCurrentStep(); const loggedInCount = platforms.filter(p => p.is_logged_in).length; ElMessage.success(`检测完成,${loggedInCount} 个平台已登录`); } else { ElMessage.error(response.data.message || '检测失败'); } } catch (error) { console.error('检测登录状态失败:', error); ElMessage.error('检测失败: ' + (error.response?.data?.message || error.message)); } finally { this.checkingLogin = false; } }, async checkLoginStatus(platformId) { const platform = this.platformLoginStatus.find(p => p.id === platformId); if (!platform) return; ElMessage.info(`正在检测 ${platform.name} 登录状态...`); platform.checking = true; try { const response = await axios.get(`/api/check-login/${platformId}`); if (response.data.success) { const payload = { is_logged_in: response.data.is_logged_in, checked_at: response.data.checked_at || new Date().toLocaleString('zh-CN', { hour12: false }), error: response.data.error || '' }; this.applyPlatformLoginStatus(platformId, payload); this.persistLoginStatus(platformId, payload); this.updateCurrentStep(); if (platform.isLoggedIn) { ElMessage.success(`${platform.name} 已登录`); } else if (response.data.error) { ElMessage.warning(response.data.error); } else { ElMessage.warning(`${platform.name} 未登录,请先登录`); } } else { ElMessage.error(response.data.message || '检测失败'); } } catch (error) { console.error('检测登录状态失败:', error); ElMessage.error('检测失败: ' + (error.response?.data?.message || error.message)); } finally { platform.checking = false; } }, async clearPlatformLogin(platformId) { const platform = this.platformLoginStatus.find(p => p.id === platformId); if (!platform) return; try { await ElMessageBox.confirm( `确认清除 ${platform.name} 的登录状态吗?清除后需要重新登录该平台账号。`, '清除登录状态', { confirmButtonText: '清除', cancelButtonText: '取消', type: 'warning' } ); const response = await axios.delete(`/api/login/${platformId}`); if (response.data.success) { const payload = { is_logged_in: false, checked_at: new Date().toLocaleString('zh-CN', { hour12: false }), error: '' }; this.applyPlatformLoginStatus(platformId, payload); this.persistLoginStatus(platformId, payload); this.updateCurrentStep(); ElMessage.success(response.data.message || `${platform.name} 登录状态已清除`); } else { ElMessage.error(response.data.message || '清除失败'); } } catch (error) { if (error === 'cancel') return; console.error('清除登录状态失败:', error); ElMessage.error('清除失败: ' + (error.response?.data?.message || error.message || error)); } }, async exportTopDomainData() { if (this.topAnalysisData.length === 0) return; try { if (typeof XLSX === 'undefined') { const script = document.createElement('script'); script.src = "https://cdn.sheetjs.com/xlsx-0.20.1/package/dist/xlsx.full.min.js"; document.head.appendChild(script); await new Promise(resolve => script.onload = resolve); } const task = this.tasks.find(t => t.id === this.analysisFilter.task_id); const taskName = task ? task.name : '全部任务'; const platformName = this.availablePlatforms.find(p => p.id === this.analysisFilter.platform)?.name || '全部平台'; const domainData = this.topAnalysisData.map(item => ({ '顶级域名': item.name, '引用次数': item.count })); const wb = XLSX.utils.book_new(); const ws = XLSX.utils.json_to_sheet(domainData); XLSX.utils.book_append_sheet(wb, ws, "顶级域名分布"); XLSX.writeFile(wb, `顶级域名分布_${taskName}_${platformName}.xlsx`); ElMessage.success('导出成功'); } catch (error) { ElMessage.error('导出失败'); } }, async exportMediaRankingData() { if (this.analysisData.length === 0) return; try { if (typeof XLSX === 'undefined') { const script = document.createElement('script'); script.src = "https://cdn.sheetjs.com/xlsx-0.20.1/package/dist/xlsx.full.min.js"; document.head.appendChild(script); await new Promise(resolve => script.onload = resolve); } const task = this.tasks.find(t => t.id === this.analysisFilter.task_id); const taskName = task ? task.name : '全部任务'; const platformName = this.availablePlatforms.find(p => p.id === this.analysisFilter.platform)?.name || '全部平台'; const rankingData = this.analysisData.map(item => ({ '域名/媒体': item.name, '引用次数': item.count })); const wb = XLSX.utils.book_new(); const ws = XLSX.utils.json_to_sheet(rankingData); XLSX.utils.book_append_sheet(wb, ws, "媒体引用排行"); XLSX.writeFile(wb, `媒体引用排行_${taskName}_${platformName}.xlsx`); ElMessage.success('导出成功'); } catch (error) { ElMessage.error('导出失败'); } }, async openLoginPage(platformId) { const platform = this.platformLoginStatus.find(p => p.id === platformId); if (!platform) return; try { // 显示加载提示 const loadingMessage = ElMessage({ message: `正在打开 ${platform.name} 登录浏览器,请稍候...`, type: 'info', duration: 0, iconClass: 'el-icon-loading' }); // 调用后端API打开浏览器 const response = await axios.post(`/api/login/${platformId}`, { wait_time: 300 // 等待300秒 }); loadingMessage.close(); if (response.data.success) { if (this.desktopMode) { ElMessageBox.alert( `

本机浏览器窗口已打开。

请在弹出的浏览器中登录 ${platform.name} 账户。

登录完成后,请先关闭弹出的 ${platform.name} 浏览器窗口,再回到本窗口点击“重新检测”。

`, `${platform.name} 登录提示`, { confirmButtonText: '我知道了', type: 'info', dangerouslyUseHTMLString: true } ); return; } let seconds = 3; const vncUrl = `${window.location.origin}/vnc/vnc.html?path=vnc/websockify&autoconnect=true&resize=scale#password=watson`; const countdownHtml = () => `

云端浏览器窗口已打开。

${seconds} 秒后会自动打开云端桌面,请在云端桌面中登录 ${platform.name} 账户。

如果没有自动打开,请点击下方按钮手动进入云端桌面。

登录完成后,回到本页面点击“重新检测”验证登录状态。

`; const timer = setInterval(() => { seconds -= 1; const countdownEl = document.getElementById('login-vnc-countdown'); if (countdownEl) countdownEl.textContent = seconds; if (seconds <= 0) clearInterval(timer); }, 1000); setTimeout(() => { window.open(vncUrl, '_blank'); }, 3000); ElMessageBox.alert(countdownHtml(), `${platform.name} 登录提示`, { confirmButtonText: '打开云端桌面', type: 'info', dangerouslyUseHTMLString: true, beforeClose: (action, instance, done) => { clearInterval(timer); if (action === 'confirm') { window.open(vncUrl, '_blank'); } done(); } }); } else { ElMessage.error(response.data.message || '打开浏览器失败'); } } catch (error) { console.error('打开登录浏览器失败:', error); ElMessage.error('打开浏览器失败: ' + (error.response?.data?.message || error.message)); } }, showCreateDialog() { this.dialogTitle = '创建任务'; this.isEdit = false; this.dialogVisible = true; }, editTask(task) { this.dialogTitle = '编辑任务'; this.isEdit = true; this.editingTaskId = task.id; this.taskForm = { name: task.name, brand_name: task.brand_name, brand_keywords: [...task.brand_keywords], competitor_brands: task.competitor_brands ? [...task.competitor_brands] : [], questions: [...task.questions], platforms: [...task.platforms], max_parallel_platforms: task.max_parallel_platforms || 3, screenshot_config: task.screenshot_config ? {...task.screenshot_config} : {}, schedule_type: task.schedule_type, sentiment_config_id: task.sentiment_config_id || null }; // 初始化截图配置 this.initScreenshotConfig(); // 检查全局截图状态 this.updateGlobalScreenshotState(); // 加载调度配置 this.loadScheduleConfig(task); this.questionsText = task.questions.join('\n'); this.dialogVisible = true; }, loadScheduleConfig(task) { // 加载调度配置到表单 const scheduleConfig = task.schedule_config || {}; this.scheduleConfig = { enable_schedule: task.schedule_enabled || false, frequency: 'once', run_time_1: '09:00', run_time_2: '14:00', run_time_3: '20:00', run_time: '09:00', run_weekdays: [0], custom_times_text: '09:00\n14:00\n20:00' }; if (task.schedule_type === 'daily') { const run_times = scheduleConfig.run_times || ['09:00']; this.scheduleConfig.run_times = run_times; if (run_times.length === 1) { this.scheduleConfig.frequency = 'once'; this.scheduleConfig.run_time_1 = run_times[0]; } else if (run_times.length === 2) { this.scheduleConfig.frequency = 'twice'; this.scheduleConfig.run_time_1 = run_times[0]; this.scheduleConfig.run_time_2 = run_times[1]; } else if (run_times.length === 3) { this.scheduleConfig.frequency = 'three_times'; this.scheduleConfig.run_time_1 = run_times[0]; this.scheduleConfig.run_time_2 = run_times[1]; this.scheduleConfig.run_time_3 = run_times[2]; } else { this.scheduleConfig.frequency = 'custom'; this.scheduleConfig.custom_times_text = run_times.join('\n'); } } else if (task.schedule_type === 'weekly') { this.scheduleConfig.run_weekdays = scheduleConfig.run_weekdays || [0]; this.scheduleConfig.run_time = scheduleConfig.run_time || '09:00'; } }, async submitTask() { const valid = await this.$refs.taskFormRef.validate().catch(() => false); if (!valid) return; this.submitting = true; try { const url = this.isEdit ? `/api/tasks/${this.editingTaskId}` : '/api/tasks'; const method = this.isEdit ? 'put' : 'post'; // 构建提交数据 const submitData = { ...this.taskForm, schedule_enabled: this.scheduleConfig.enable_schedule, schedule_config: this.buildScheduleConfig() }; const response = await axios[method](url, submitData); if (response.data.success) { ElMessage.success(this.isEdit ? '任务更新成功' : '任务创建成功'); this.dialogVisible = false; this.loadTasks(); this.loadGeoCoverageData(); } } catch (error) { ElMessage.error(error.response?.data?.message || '操作失败'); } finally { this.submitting = false; } }, buildScheduleConfig() { // 根据调度类型构建配置 if (this.taskForm.schedule_type === 'daily') { const run_times = []; if (this.scheduleConfig.frequency === 'once') { if (this.scheduleConfig.run_time_1) run_times.push(this.scheduleConfig.run_time_1); } else if (this.scheduleConfig.frequency === 'twice') { if (this.scheduleConfig.run_time_1) run_times.push(this.scheduleConfig.run_time_1); if (this.scheduleConfig.run_time_2) run_times.push(this.scheduleConfig.run_time_2); } else if (this.scheduleConfig.frequency === 'three_times') { if (this.scheduleConfig.run_time_1) run_times.push(this.scheduleConfig.run_time_1); if (this.scheduleConfig.run_time_2) run_times.push(this.scheduleConfig.run_time_2); if (this.scheduleConfig.run_time_3) run_times.push(this.scheduleConfig.run_time_3); } else if (this.scheduleConfig.frequency === 'custom') { // 解析自定义时间 const lines = (this.scheduleConfig.custom_times_text || '').split('\n'); lines.forEach(line => { const time = line.trim(); if (time && /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/.test(time)) { run_times.push(time); } }); } return { run_times }; } else if (this.taskForm.schedule_type === 'weekly') { return { run_weekdays: this.scheduleConfig.run_weekdays, run_time: this.scheduleConfig.run_time || '09:00' }; } return {}; }, async runTask(taskId) { // 检查今日采集数量 if (this.todayCollectionCount >= this.dailyLimit) { ElMessageBox.alert( `今日采集数量已达到建议上限(${this.dailyLimit}条),为避免触发AI平台反爬虫机制,建议明天再继续采集。`, '采集限制提醒', { confirmButtonText: '我知道了', type: 'warning' } ); return; } try { // 计算本次将采集的数据量 const task = this.tasks.find(t => t.id === taskId); const estimatedCount = task.questions.length * task.platforms.length; const remainingQuota = this.dailyLimit - this.todayCollectionCount; let confirmMessage = `确定要开始采集数据吗?\n\n`; confirmMessage += `本次预计采集:${estimatedCount} 条数据\n`; confirmMessage += `今日已采集:${this.todayCollectionCount} 条\n`; confirmMessage += `今日剩余额度:${remainingQuota} 条\n\n`; if (estimatedCount > remainingQuota) { confirmMessage += `⚠️ 警告:本次采集将超出今日建议额度!\n`; confirmMessage += `建议减少问题数量或平台数量。`; } await ElMessageBox.confirm(confirmMessage, '确认采集', { confirmButtonText: '确定', cancelButtonText: '取消', type: estimatedCount > remainingQuota ? 'warning' : 'info' }); // 立即更新本地状态为"执行中" if (task) { task.status = 'running'; } ElMessage.info('任务已开始执行,请稍候...'); // 发送采集请求 const response = await axios.post(`/api/tasks/${taskId}/run`, { min_interval: this.minInterval, max_interval: this.maxInterval }); if (response.data.success) { // 增加今日采集计数 this.incrementCollectionCount(estimatedCount); // 开始轮询任务状态 this.startTaskPolling(taskId); } } catch (error) { if (error !== 'cancel') { ElMessage.error(error.response?.data?.message || '执行失败'); // 重新加载任务列表 this.loadTasks(); } } }, async controlTask(taskId, command) { try { if (command === 'stop') { await ElMessageBox.confirm( '确定要结束采集吗?结束后本次采集将停止。', '结束采集确认', { confirmButtonText: '确定结束', cancelButtonText: '取消', type: 'warning' } ); } // 设置命令中状态 this.taskCommanding = { ...this.taskCommanding, [taskId]: command }; const response = await axios.post(`/api/tasks/${taskId}/control`, { command }); if (response.data.success) { ElMessage.success(response.data.message || '操作成功'); this.startTaskPolling(taskId); // 只有暂停命令需要立即刷新以显示“暂停中”,其他命令由轮询更新 this.loadTasks(); } else { // 失败时清除状态 this.clearTaskCommanding(taskId); } } catch (error) { // 清除状态 this.clearTaskCommanding(taskId); if (error !== 'cancel') { ElMessage.error(error.response?.data?.message || '操作失败'); } } }, clearTaskCommanding(taskId) { const key = String(taskId); if (this.taskCommanding[key]) { const newCommanding = { ...this.taskCommanding }; delete newCommanding[key]; this.taskCommanding = newCommanding; } }, clearTaskPolling(taskId) { const key = String(taskId); if (this.taskPollers[key]) { clearInterval(this.taskPollers[key]); delete this.taskPollers[key]; } }, startTaskPolling(taskId) { const key = String(taskId); if (this.taskPollers[key]) { return; } this.taskPollers[key] = setInterval(async () => { try { const response = await axios.get(`/api/tasks/${taskId}`); const taskData = response.data.task; const task = this.tasks.find(t => t.id === taskId); if (task) { // 如果状态发生了变化,清除对应的 commanding 状态 if (task.status !== taskData.status) { this.clearTaskCommanding(taskId); } task.status = taskData.status; task.last_run_at = taskData.last_run_at; } if (taskData.status === 'completed') { this.clearTaskPolling(taskId); ElMessage.success('采集完成!'); this.loadTasks(); } else if (taskData.status === 'failed') { this.clearTaskPolling(taskId); ElMessage.error('采集失败,请查看日志'); this.loadTasks(); } else if (taskData.status === 'stopped') { this.clearTaskPolling(taskId); ElMessage.info('采集已结束'); this.loadTasks(); } } catch (error) { console.error('轮询任务状态失败:', error); this.clearTaskPolling(taskId); this.loadTasks(); } }, 2000); }, viewResults(taskId) { window.location.href = `/task/${taskId}/results`; }, async deleteTask(taskId) { try { await ElMessageBox.confirm('确定要删除这个任务吗?', '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }); const response = await axios.delete(`/api/tasks/${taskId}`); if (response.data.success) { ElMessage.success('任务已删除'); this.loadTasks(); this.loadGeoCoverageData(); } } catch (error) { if (error !== 'cancel') { ElMessage.error('删除失败'); } } }, async resetTaskStatus(taskId) { try { await ElMessageBox.confirm('确定要重置任务状态吗?这将允许您重新执行任务。', '确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }); // 设置命令中状态 this.taskCommanding = { ...this.taskCommanding, [taskId]: 'reset' }; const response = await axios.post(`/api/tasks/${taskId}/reset-status`); if (response.data.success) { ElMessage.success('状态已重置'); this.loadTasks(); } } catch (error) { if (error !== 'cancel') { ElMessage.error('重置失败'); } } finally { this.clearTaskCommanding(taskId); } }, showKeywordInput() { this.keywordInputVisible = true; this.$nextTick(() => { this.$refs.keywordInputRef.focus(); }); }, addKeyword() { const keyword = this.keywordInput.trim(); if (keyword && !this.taskForm.brand_keywords.includes(keyword)) { this.taskForm.brand_keywords.push(keyword); } this.keywordInput = ''; this.keywordInputVisible = false; }, removeKeyword(keyword) { const index = this.taskForm.brand_keywords.indexOf(keyword); if (index > -1) { this.taskForm.brand_keywords.splice(index, 1); } }, showCompetitorInput() { this.competitorInputVisible = true; this.$nextTick(() => { this.$refs.competitorInputRef?.focus(); }); }, addCompetitorBrand() { const brand = this.competitorInput.trim(); if (brand && !this.taskForm.competitor_brands.includes(brand)) { this.taskForm.competitor_brands.push(brand); } this.competitorInput = ''; this.competitorInputVisible = false; }, removeCompetitorBrand(brand) { const index = this.taskForm.competitor_brands.indexOf(brand); if (index > -1) { this.taskForm.competitor_brands.splice(index, 1); } }, resetForm() { this.taskForm = { name: '', brand_name: '', brand_keywords: [], competitor_brands: [], questions: [], platforms: [], screenshot_config: {}, schedule_type: 'manual', sentiment_config_id: null }; this.screenshotGlobalEnabled = true; this.questionsText = ''; this.$refs.taskFormRef?.resetFields(); // 重置调度配置 this.scheduleConfig = { enable_schedule: false, frequency: 'once', run_time_1: '09:00', run_time_2: '14:00', run_time_3: '20:00', run_time: '09:00', run_weekdays: [0], custom_times_text: '09:00\n14:00\n20:00' }; }, handlePlatformChange(platforms) { // 当平台选择变化时,初始化截图配置 this.initScreenshotConfig(); }, handleScheduleEnableChange(enabled) { // 调度启用状态变化 if (!enabled) { // 如果禁用,清空时间选择 this.scheduleConfig.run_time_1 = '09:00'; this.scheduleConfig.run_time_2 = '14:00'; this.scheduleConfig.run_time_3 = '20:00'; this.scheduleConfig.run_time = '09:00'; } }, initScreenshotConfig() { // 为新选择的平台初始化截图配置(默认启用) this.taskForm.platforms.forEach(platformId => { if (!(platformId in this.taskForm.screenshot_config)) { this.taskForm.screenshot_config[platformId] = true; } }); // 移除未选择平台的配置 Object.keys(this.taskForm.screenshot_config).forEach(platformId => { if (!this.taskForm.platforms.includes(platformId)) { delete this.taskForm.screenshot_config[platformId]; } }); this.updateGlobalScreenshotState(); }, handleGlobalScreenshotChange(enabled) { // 全局开关:设置所有平台的截图状态 this.taskForm.platforms.forEach(platformId => { this.taskForm.screenshot_config[platformId] = enabled; }); }, updateGlobalScreenshotState() { // 更新全局开关状态:如果所有平台都启用,则全局开关为启用 if (this.taskForm.platforms.length === 0) { this.screenshotGlobalEnabled = true; return; } const allEnabled = this.taskForm.platforms.every( platformId => this.taskForm.screenshot_config[platformId] === true ); this.screenshotGlobalEnabled = allEnabled; }, getPlatformName(platformId) { const platform = this.availablePlatforms.find(p => p.id === platformId); return platform ? platform.name : platformId; }, getStatusType(status) { const types = { pending: '', running: 'warning', paused: 'info', completed: 'success', failed: 'danger', stopped: 'info' }; return types[status] || ''; }, getStatusText(status) { const texts = { pending: '待执行', running: '执行中', paused: '已暂停', completed: '已完成', failed: '失败', stopped: '已结束' }; return texts[status] || status; }, formatDate(dateStr) { if (!dateStr) return '-'; const date = new Date(dateStr); return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); }, handleLogout() { window.location.href = '/logout'; }, getScreenshotEnabledCount(task) { // 计算启用截图的平台数量 if (!task.screenshot_config || Object.keys(task.screenshot_config).length === 0) { return task.platforms.length; // 默认全部启用 } return task.platforms.filter(p => task.screenshot_config[p] !== false).length; }, async showBrowserSettings() { this.browserSettingsVisible = true; this.testResult = { success: false, message: '' }; await this.loadBrowserConfig(); }, async loadBrowserConfig() { try { const response = await axios.get('/api/browser/config'); if (response.data.success) { this.browserConfig = response.data.config; this.browserConfigForm.browser_path = response.data.config.configured_path || ''; } } catch (error) { console.error('加载浏览器配置失败:', error); } }, testBrowserPath() { const path = this.browserConfigForm.browser_path.trim(); if (!path) { this.testResult = { success: false, message: '请输入浏览器路径' }; return; } // 简单的本地路径验证(仅做基本格式检查) if (!path.endsWith('.exe')) { this.testResult = { success: false, message: '路径必须指向 .exe 可执行文件' }; return; } // 检查是否包含 chrome 或 edge const isChrome = path.toLowerCase().includes('chrome'); const isEdge = path.toLowerCase().includes('edge'); if (!isChrome && !isEdge) { this.testResult = { success: false, message: '警告: 仅支持 Chrome 和 Edge 浏览器' }; return; } this.testResult = { success: true, message: `路径格式正确 (${isChrome ? 'Chrome' : 'Edge'}),点击保存生效` }; }, async saveBrowserSettings() { this.savingBrowserConfig = true; try { const response = await axios.post('/api/browser/config', { browser_path: this.browserConfigForm.browser_path.trim() }); if (response.data.success) { ElMessage.success('浏览器配置已保存'); this.browserSettingsVisible = false; await this.loadBrowserConfig(); } else { ElMessage.error(response.data.message || '保存失败'); } } catch (error) { ElMessage.error('保存配置失败'); } finally { this.savingBrowserConfig = false; } }, // ==================== 舆情配置相关方法 ==================== async loadSentimentConfigs() { try { const response = await axios.get('/api/sentiment/configs'); if (response.data.success) { this.sentimentConfigs = response.data.configs; } } catch (error) { ElMessage.error('加载舆情配置失败'); } }, showSentimentConfigDialog() { this.editingSentimentConfig = null; this.sentimentConfigForm = { id: null, name: '', positiveWordsInput: '', negativeWordsInput: '', enable_ai_sentiment: false, ai_platform: '', ai_api_url: '', ai_api_key: '', ai_model_name: '', ai_prompt: '请分析以下文本中关于"内容"的舆情倾向:\n\n文本:{text}\n\n判断规则:\n1. 如果文本中出现具体的价格信息(如"10万"、"15.5万"等),则判定为负面舆情\n2. 如果文本中没有价格信息,根据整体语气判断是正面、负面还是中性\n\n请判断是正面、负面还是中性,并给出简短理由。\n输出格式:{"sentiment": "positive|negative|neutral", "score": -1到1之间的数值, "label": "正面|负面|中性", "reason": "分析理由"}', is_default: false }; this.sentimentConfigDialogVisible = true; }, editSentimentConfig(config) { this.editingSentimentConfig = config; this.sentimentConfigForm = { id: config.id, name: config.name, positiveWordsInput: config.positive_words.join(','), negativeWordsInput: config.negative_words.join(','), enable_ai_sentiment: config.enable_ai_sentiment || false, ai_platform: config.ai_platform || '', ai_api_url: config.ai_api_url || '', ai_api_key: config.ai_api_key || '', ai_model_name: config.ai_model_name || '', ai_prompt: config.ai_prompt || '', is_default: config.is_default || false }; this.sentimentConfigDialogVisible = true; }, toggleAiSentiment() { if (!this.sentimentConfigForm.enable_ai_sentiment) { this.sentimentConfigForm.ai_platform = ''; this.sentimentConfigForm.ai_api_url = ''; this.sentimentConfigForm.ai_api_key = ''; this.sentimentConfigForm.ai_model_name = ''; this.sentimentConfigForm.ai_prompt = ''; } }, onPlatformChange() { // 根据选择的平台自动填充默认配置 const platformConfig = { 'openai': { api_url: 'https://api.openai.com/v1/chat/completions', model_name: 'gpt-4o-mini' }, 'siliconflow': { api_url: 'https://api.siliconflow.cn/v1/chat/completions', model_name: 'Qwen/Qwen2-7B-Instruct' }, 'doubao': { api_url: 'https://api.doubao.com/v1/chat/completions', model_name: 'doubao-3.5' }, 'custom': { api_url: '', model_name: '' } }; const config = platformConfig[this.sentimentConfigForm.ai_platform]; if (config) { this.sentimentConfigForm.ai_api_url = config.api_url; this.sentimentConfigForm.ai_model_name = config.model_name; } }, async saveSentimentConfig() { if (!this.sentimentConfigForm.name.trim()) { ElMessage.error('请输入配置名称'); return; } const data = { name: this.sentimentConfigForm.name.trim(), positive_words: this.sentimentConfigForm.positiveWordsInput.split(',').map(w => w.trim()).filter(w => w), negative_words: this.sentimentConfigForm.negativeWordsInput.split(',').map(w => w.trim()).filter(w => w), enable_ai_sentiment: this.sentimentConfigForm.enable_ai_sentiment, ai_platform: this.sentimentConfigForm.ai_platform, ai_api_url: this.sentimentConfigForm.ai_api_url.trim(), ai_api_key: this.sentimentConfigForm.ai_api_key.trim(), ai_model_name: this.sentimentConfigForm.ai_model_name.trim(), ai_prompt: this.sentimentConfigForm.ai_prompt.trim(), is_default: this.sentimentConfigForm.is_default }; try { let response; if (this.editingSentimentConfig) { response = await axios.put(`/api/sentiment/configs/${this.editingSentimentConfig.id}`, data); } else { response = await axios.post('/api/sentiment/configs', data); } if (response.data.success) { ElMessage.success(this.editingSentimentConfig ? '配置已更新' : '配置已添加'); this.sentimentConfigDialogVisible = false; await this.loadSentimentConfigs(); } else { ElMessage.error(response.data.message || '保存失败'); } } catch (error) { ElMessage.error('保存配置失败'); } }, async confirmDeleteSentimentConfig(config) { try { await ElMessageBox.confirm('确定要删除这个舆情配置吗?', '警告', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }); const response = await axios.delete(`/api/sentiment/configs/${config.id}`); if (response.data.success) { ElMessage.success('配置已删除'); await this.loadSentimentConfigs(); } } catch (error) { if (error !== 'cancel') { ElMessage.error('删除失败'); } } }, async setDefaultSentimentConfig(config) { try { const response = await axios.put(`/api/sentiment/configs/${config.id}`, { is_default: true }); if (response.data.success) { ElMessage.success('已设为默认配置'); await this.loadSentimentConfigs(); } } catch (error) { ElMessage.error('操作失败'); } } } }); app.use(ElementPlus); // 全局注册所有 Element Plus 图标 for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component); } app.mount('#app'); {% endblock %}