Files
yu-zhi-ran/platform/frontend/tasks.html
T
Yuzhiran Dev 1855f190f5 配置全面迁移数据库:PromptConfig、TaskConfig动态调度、敏感词/清洗规则/趋势映射/平台标签/痛点模板全部可编辑
- 新增 PromptConfig 模型 + API,支持提示词在线编辑(16条默认)
- 调度器动态读取 TaskConfig.schedule,admin 可调执行时间
- 新增 KeywordDomainMap、SensitiveWord、ContentCleanRule、TrendFieldMapping 表
- DOMAINS、TREND_DOMAIN_MAP、PLATFORM_TAGS、china_pains、RSS关键词、priority_weights 全部迁移到 DB
- tasks.html 重构:卡片网格+配置/产出/历史/提示词四个Tab,折叠显示
- 清理冗余代码:DEFAULT_PROMPTS死代码、collector.py unreachable代码、compliance_checker bug
- strip_thinking_html 改用 DB 规则优先
2026-05-22 11:18:23 +08:00

917 lines
71 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 任务管理</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
.toolbar { gap: 12px; }
.task-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 16px; margin-bottom: 12px; transition: all 0.3s; background: #fff; }
.task-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.12); transform: translateY(-1px); }
.task-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; }
.task-id { font-size: 13px; color: #909399; font-family: monospace; }
.task-stage { display: inline-block; padding: 4px 12px; border-radius: 16px; font-size: 12px; font-weight: 500; }
.stage-creator { background: #f0f9eb; color: #67c23a; }
.stage-optimize { background: #fdf6ec; color: #e6a23c; }
.stage-review { background: #fef0f0; color: #f56c6c; }
.stage-publish { background: #ecf5ff; color: #409eff; }
.task-body { margin-bottom: 12px; }
.task-message { font-size: 14px; color: #606266; margin-bottom: 8px; }
.task-meta { display: flex; gap: 16px; font-size: 13px; color: #909399; flex-wrap: wrap; }
.status-badge { display: inline-flex; align-items: center; gap: 4px; padding: 4px 12px; border-radius: 16px; font-size: 13px; }
.status-pending { background: #f4f4f5; color: #909399; }
.status-running { background: #f0f9eb; color: #67c23a; }
.status-completed { background: #ecf5ff; color: #409eff; }
.status-failed { background: #fef0f0; color: #f56c6c; }
.status-cancelled { background: #f4f4f5; color: #c0c4cc; }
.error-box { margin-top: 8px; padding: 8px 12px; background: #fef0f0; border-radius: 6px; font-size: 13px; color: #f56c6c; border-left: 3px solid #f56c6c; }
.task-card-list-mobile { display: none; }
.schedule-row { display: flex; align-items: center; gap: 16px; padding: 14px 16px; border-radius: 10px; background: #f8faff; border: 1px solid #e8edf5; margin-bottom: 8px; transition: all 0.2s; }
.schedule-row:hover { background: #f0f4ff; }
.schedule-icon { font-size: 22px; width: 36px; text-align: center; flex-shrink: 0; }
.schedule-info { flex: 1; min-width: 0; }
.schedule-name { font-size: 14px; font-weight: 600; color: #303133; }
.schedule-time { font-size: 12px; color: #909399; margin-top: 2px; }
.schedule-next { font-size: 12px; color: #409eff; white-space: nowrap; }
.schedule-badge { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.schedule-badge.active { background: #67c23a; }
.schedule-badge.inactive { background: #c0c4cc; }
.detail-row { display: flex; padding: 10px 0; border-bottom: 1px solid #f0f2f5; }
.detail-row:last-child { border-bottom: none; }
.detail-label { width: 80px; flex-shrink: 0; font-size: 13px; color: #909399; }
.detail-value { flex: 1; font-size: 13px; color: #303133; word-break: break-all; }
@media (max-width: 768px) {
.task-table { display: none; }
.task-card-list-mobile { display: block; }
.schedule-row { padding: 10px 12px; gap: 10px; flex-wrap: wrap; }
.schedule-next { width: 100%; margin-left: 46px; }
}
.module-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.module-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 16px; transition: all 0.3s; background: #fff; }
.module-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.12); transform: translateY(-2px); }
.module-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.module-title { font-weight: 600; font-size: 15px; color: #303133; }
.module-status { font-size: 12px; padding: 2px 10px; border-radius: 12px; background: #f4f4f5; color: #909399; }
.module-status.running { background: #f0f9eb; color: #67c23a; }
.module-content > div { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px; }
.module-content > div > span:first-child { color: #909399; }
.module-content > div > span:last-child { color: #303133; font-weight: 500; }
.task-drawer .el-drawer__body { padding: 16px 24px; overflow-y: auto; display: flex; flex-direction: column; }
.data-table { display: block; }
@media (max-width: 768px) { .data-table { display: none; } }
</style>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<uni-nav title="任务管理" :username="currentUser.username" :is-admin="isAdmin" current-page="tasks" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 任务管理</h2>
<div class="filter-bar">
<el-button size="default" :type="activeTab === 'scheduler' ? 'primary' : ''" @click="switchTab('scheduler')">定时任务</el-button>
<el-button size="default" :type="activeTab === 'cases' ? 'primary' : ''" @click="switchTab('cases')">案例管理</el-button>
<el-button size="default" :type="activeTab === 'categories' ? 'primary' : ''" @click="switchTab('categories')">采集类别</el-button>
<el-button size="default" :type="activeTab === 'sources' ? 'primary' : ''" @click="switchTab('sources')">信息源</el-button>
<el-button size="default" :type="activeTab === 'tasks' ? 'primary' : ''" @click="switchTab('tasks')">创作任务</el-button>
</div>
</div>
<!-- 定时任务 -->
<div v-if="activeTab === 'scheduler'">
<div class="toolbar">
<el-tag v-if="schedulerRunning" type="success" size="small">调度器运行中</el-tag>
<el-tag v-else type="danger" size="small">调度器未启动</el-tag>
<el-button @click="loadModules"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
</div>
<div v-if="moduleLoading" style="text-align: center; padding: 20px; color: #909399;">加载中...</div>
<div v-else-if="modules.length === 0" style="text-align: center; padding: 30px 20px; color: #909399; font-size: 14px;">暂无定时任务</div>
<div v-else class="module-grid">
<div v-for="mod in modules" :key="mod.module_id" class="module-card" @click="openModuleDetail(mod)" style="cursor:pointer;">
<div class="module-header">
<span class="module-title">{{ mod.title }}</span>
<el-tag v-if="mod.running > 0" type="success" size="small">运行中</el-tag>
<el-tag v-else-if="!mod.enabled" type="info" size="small">已禁用</el-tag>
<span v-else :class="['module-status', mod.last_status === 'success' ? 'completed' : mod.last_status === 'failed' ? 'failed' : '']">{{ mod.last_status === 'success' ? '正常' : mod.last_status === 'failed' ? '失败' : '空闲' }}</span>
</div>
<div class="module-content">
<div><span>最后运行</span><span>{{ mod.last_run || '从未' }}</span></div>
<div><span>下次运行</span><span>{{ mod.next_run || '—' }}</span></div>
<div><span>累计运行</span><span>{{ mod.total_runs }} 次 <span style="color:#67c23a;">{{ mod.success_runs }} 成功</span> <span style="color:#f56c6c;">{{ mod.failed_runs }} 失败</span></span></div>
<div style="margin-top:10px; border-bottom:none;">
<el-button size="small" type="primary" @click.stop="triggerModule(mod.module_id)" :loading="runningModule === mod.module_id" :disabled="!mod.enabled">立即运行</el-button>
<el-button size="small" @click.stop="openModuleDetail(mod)">查看详情</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 案例管理 -->
<div v-if="activeTab === 'cases'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showCaseDialog()">新增案例</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ cases.length }} 个</span>
</div>
<div v-if="casesLoading" class="card-loading">加载中...</div>
<template v-else-if="cases.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon>
<div class="empty-text">暂无案例数据</div>
<el-button type="primary" size="small" @click="showCaseDialog()">新增第一个案例</el-button>
</div>
</template>
<template v-else>
<div class="stat-summary">
<div class="stat-item"><span class="num">{{ cases.length }}</span><span class="label">总案例</span></div>
</div>
<el-table :data="paginatedCases" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="title" label="标题" min-width="80" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="field" label="领域" width="140"></el-table-column>
<el-table-column prop="summary" label="概述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="source" label="来源" width="140"></el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCase(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="cases.length > casePageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="cases.length" :page-size="casePageSize" :current-page="casePage" @current-change="casePage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile">
<div v-for="item in paginatedCases" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ item.title }}</span></div>
<div class="card-row"><span class="card-label">领域</span><span class="card-value">{{ item.field }}</span></div>
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
</div>
</div>
</div>
</div>
<!-- 采集类别 -->
<div v-if="activeTab === 'categories'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
<el-button size="small" @click="loadCategories">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ categories.length }} 个</span>
</div>
<div v-if="categoriesLoading" class="card-loading">加载中...</div>
<template v-else-if="categories.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconPicture /></el-icon>
<div class="empty-text">暂无采集类别</div>
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
</div>
</template>
<template v-else>
<el-table :data="paginatedCategories" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
<el-table-column prop="search_query" label="搜索词" min-width="200"></el-table-column>
<el-table-column prop="source_count" label="信息源数" width="100"></el-table-column>
<el-table-column prop="is_active" label="激活" width="70">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showCategoryDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="categories.length > categoryPageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="categories.length" :page-size="categoryPageSize" :current-page="categoryPage" @current-change="categoryPage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile" v-if="categories.length > 0">
<div v-for="item in paginatedCategories" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">搜索词</span><span class="card-value">{{ item.search_query }}</span></div>
<div class="card-row"><span class="card-label">源数</span><span class="card-value">{{ item.source_count }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showCategoryDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(item.id)">删除</el-button>
</div>
</div>
</div>
</div>
<!-- 信息源 -->
<div v-if="activeTab === 'sources'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
<el-button size="small" @click="loadSources">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ sources.length }} 个</span>
</div>
<div v-if="sourcesLoading" class="card-loading">加载中...</div>
<template v-else-if="sources.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
<div class="empty-text">暂无信息源</div>
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
</div>
</template>
<template v-else>
<el-table :data="paginatedSources" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="50"></el-table-column>
<el-table-column prop="name" label="名称" min-width="130"></el-table-column>
<el-table-column prop="source_type" label="类型" width="90">
<template #default="scope">{{ {rss:'RSS',web_search:'搜索',local:'本地'}[scope.row.source_type] || scope.row.source_type }}</template>
</el-table-column>
<el-table-column prop="query" label="查询词/URL" min-width="250" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="focus" label="聚焦" min-width="120"></el-table-column>
<el-table-column prop="is_active" label="激活" width="60">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button size="small" @click="showSourceDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSource(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="sources.length > sourcePageSize" style="display:flex;justify-content:center;margin:12px 0;">
<el-pagination background layout="prev, pager, next" :total="sources.length" :page-size="sourcePageSize" :current-page="sourcePage" @current-change="sourcePage = $event"></el-pagination>
</div>
</template>
<div class="card-list-mobile" v-if="sources.length > 0">
<div v-for="item in paginatedSources" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">类型</span><span class="card-value">{{ item.source_type }}</span></div>
<div class="card-row"><span class="card-label">查询</span><span class="card-value">{{ item.query || item.url }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showSourceDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSource(item.id)">删除</el-button>
</div>
</div>
</div>
</div>
<!-- 创作任务 -->
<div v-if="activeTab === 'tasks'">
<div class="toolbar">
<el-button-group>
<el-button :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; loadTasks()">全部</el-button>
<el-button :type="filterStatus === 'running' ? 'primary' : ''" @click="filterStatus = 'running'; loadTasks()">进行中</el-button>
<el-button :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'; loadTasks()">等待中</el-button>
<el-button :type="filterStatus === 'completed' ? 'primary' : ''" @click="filterStatus = 'completed'; loadTasks()">已完成</el-button>
<el-button :type="filterStatus === 'failed' ? 'primary' : ''" @click="filterStatus = 'failed'; loadTasks()">失败</el-button>
</el-button-group>
<el-button @click="loadTasks"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
</div>
<div v-if="loading" style="text-align: center; padding: 40px; color: #909399;">加载中...</div>
<div v-else-if="allTasks.length === 0" style="text-align: center; padding: 60px 20px; color: #909399;">
<el-icon style="font-size:56px;margin-bottom:16px;color:#c0c4cc;"><IconTopic /></el-icon>
<div style="font-size: 16px;">暂无任务</div>
<div style="margin-top: 12px; font-size: 14px;">前往 <a href="/topics.html" style="color: #409eff;">选题管理</a> 创建新任务</div>
</div>
<div v-else>
<div v-for="task in paginatedTasks" :key="task.task_id" class="task-card">
<div class="task-header">
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<span class="task-id" title="任务ID">{{ task.task_id.slice(0,16) }}</span>
<span style="font-weight:600;color:#303133;font-size:14px;">{{ task.topic_title || task.topic_id || '通用任务' }}</span>
<span class="status-badge" :class="'status-' + task.status">{{ getStatusLabel(task.status) }}</span>
<span class="task-stage" :class="'stage-' + task.stage">{{ getStageLabel(task.stage) }}</span>
</div>
<div style="display: flex; gap: 8px;">
<el-button v-if="task.status === 'running'" size="small" type="danger" @click="cancelTask(task.task_id)">取消</el-button>
<el-button v-if="task.status === 'failed'" size="small" type="primary" @click="retryTask(task)">重试</el-button>
<el-button size="small" @click="viewTaskDetail(task)">详情</el-button>
</div>
</div>
<div class="task-body">
<div class="task-message">{{ task.message || '无消息' }}</div>
<div v-if="task.status === 'running'" style="margin-top: 8px;">
<el-progress :percentage="task.progress || 0" :stroke-width="12" :show-text="true"></el-progress>
</div>
</div>
<div class="task-meta">
<span>ID: {{ task.task_id }}</span>
<span>创建: {{ formatDate(task.created_at) }}</span>
<span v-if="task.started_at">开始: {{ formatDate(task.started_at) }}</span>
<span v-if="task.finished_at">完成: {{ formatDate(task.finished_at) }}</span>
<span v-if="task.duration">耗时: {{ task.duration }}秒</span>
<span>创建人: {{ task.created_by }}</span>
</div>
<div v-if="task.error_msg" class="error-box">错误: {{ task.error_msg }}</div>
</div>
<div v-if="paginatedTasks.length > 0 && allTasks.length > pageSize" style="display:flex;justify-content:center;align-items:center;margin-top:16px;gap:16px;">
<span style="font-size:13px;color:#909399;">共 {{ allTasks.length }} 条</span>
<el-pagination background layout="prev, pager, next" :total="allTasks.length" :page-size="pageSize" :current-page="currentPage" @current-change="p => { currentPage = p; }"></el-pagination>
</div>
</div>
</div>
</div>
<!-- 模块详情抽屉 -->
<el-drawer v-model="showDrawer" :title="drawerTitle" size="50%" direction="rtl" class="task-drawer">
<div v-if="drawerLoading" style="text-align:center;padding:40px;color:#909399;">加载中...</div>
<div v-else-if="drawerError" style="text-align:center;padding:40px;color:#f56c6c;">{{ drawerError }}</div>
<div v-else-if="drawerData" style="height:100%;display:flex;flex-direction:column;">
<div style="padding-bottom:16px;border-bottom:1px solid #ebeef5;margin-bottom:16px;flex-shrink:0;">
<el-tag v-if="drawerData.module_id" size="small" type="info" style="margin-bottom:8px;">{{ drawerData.title || drawerData.module_id }}</el-tag>
<el-tag v-if="drawerData.module_id && drawerData.title" size="small" type="" style="margin-bottom:8px;margin-left:6px;">{{ drawerData.module_id }}</el-tag>
<p style="color:#606266;font-size:14px;margin:0;">{{ drawerData.description }}</p>
</div>
<el-tabs v-model="drawerTab" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
<el-tab-pane label="📥 配置" name="inputs" style="overflow:auto;flex:1;">
<div style="margin-bottom:16px;">
<div style="font-size:13px;font-weight:600;margin-bottom:12px;">基础配置</div>
<div style="display:flex;gap:16px;align-items:center;margin-bottom:12px;">
<span style="font-size:13px;color:#606266;width:60px;">启用</span>
<el-switch v-model="drawerData.enabled" @change="saveModuleConfig" :disabled="savingConfig"></el-switch>
<span style="font-size:12px;color:#909399;">{{ drawerData.enabled ? '定时执行' : '已禁用' }}</span>
</div>
<div style="display:flex;gap:16px;align-items:center;margin-bottom:12px;">
<span style="font-size:13px;color:#606266;width:60px;">定时</span>
<el-input v-model="drawerData.schedule" size="small" style="width:120px;" placeholder="HH:MM" :disabled="savingConfig"></el-input>
<span style="font-size:12px;color:#909399;">每日执行时间(HH:MM</span>
</div>
</div>
<div v-if="Object.keys(drawerData.params || {}).length > 0">
<div style="font-size:13px;font-weight:600;margin-bottom:12px;">参数配置</div>
<div v-for="(val, key) in drawerData.params" :key="key" style="margin-bottom:12px;display:flex;align-items:center;gap:12px;">
<span style="font-size:13px;color:#606266;width:120px;">{{ key }}</span>
<el-input v-if="typeof val === 'string'" v-model="drawerData.params[key]" size="small" style="flex:1;" @change="saveModuleConfig" :disabled="savingConfig"></el-input>
<el-input-number v-else-if="typeof val === 'number'" v-model="drawerData.params[key]" size="small" :disabled="savingConfig" @change="saveModuleConfig"></el-input-number>
<el-switch v-else-if="typeof val === 'boolean'" v-model="drawerData.params[key]" size="small" :disabled="savingConfig" @change="saveModuleConfig"></el-switch>
<span v-else style="font-size:13px;color:#909399;">{{ JSON.stringify(val) }}</span>
</div>
</div>
<div v-else style="color:#909399;font-size:13px;padding:8px 0;">无自定义参数</div>
<div style="margin-top:16px;padding-top:12px;border-top:1px solid #ebeef5;">
<el-button type="primary" size="small" @click="saveModuleConfig" :loading="savingConfig">保存配置</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="📤 产出结果" name="outputs" style="overflow:auto;flex:1;">
<div v-if="drawerData.last_result && Object.keys(drawerData.last_result).length > 0">
<div style="font-size:13px;font-weight:600;margin-bottom:10px;color:#303133;">最近运行结果</div>
<div v-for="(val, key) in drawerData.last_result" :key="key" style="margin-bottom:6px;font-size:13px;">
<span style="color:#909399;">{{ key }}:</span>
<span :style="{color: ['topics_count','articles_synced','processed','passed','topics_found'].includes(key) ? '#409eff' : '#606266', fontWeight: ['topics_count','articles_synced'].includes(key) ? '600' : '400'}">{{ val }}</span>
</div>
<el-divider v-if="Object.keys(drawerData.outputs || {}).length > 0" />
</div>
<div v-if="Object.keys(drawerData.outputs || {}).length === 0" style="color:#909399;padding:20px;text-align:center;">暂无产出数据</div>
<div v-else>
<div v-for="(val, key) in drawerData.outputs" :key="key" style="margin-bottom:16px;border-bottom:1px solid #f0f2f5;padding-bottom:12px;">
<div style="font-size:13px;font-weight:600;color:#303133;margin-bottom:6px;">{{ key }}</div>
<div v-if="key === '热点列表' && Array.isArray(val)">
<el-table :data="val" size="small" max-height="400" style="width:100%;">
<el-table-column prop="topic" label="话题" min-width="180"></el-table-column>
<el-table-column prop="domain" label="领域" width="100"></el-table-column>
<el-table-column prop="platform" label="平台" width="100"></el-table-column>
<el-table-column prop="source" label="来源" width="80"></el-table-column>
</el-table>
</div>
<div v-else-if="key === '最新选题' && Array.isArray(val)">
<el-table :data="val" size="small" max-height="300" style="width:100%;">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="title" label="标题" min-width="150"></el-table-column>
<el-table-column prop="field" label="领域" width="80"></el-table-column>
<el-table-column prop="status" label="状态" width="70"></el-table-column>
<el-table-column prop="created" label="创建时间" min-width="150"></el-table-column>
</el-table>
</div>
<div v-else-if="(key === '最新文章' || key === '最新指标') && Array.isArray(val)">
<el-table :data="val" size="small" max-height="300" style="width:100%;">
<el-table-column v-for="col in Object.keys(val[0]||{})" :key="col" :prop="col" :label="col" min-width="120"></el-table-column>
</el-table>
</div>
<div v-else-if="key === '各分类结果' && Array.isArray(val)">
<div v-for="item in val" :key="item.query" style="margin-bottom:8px;padding:8px;background:#f8faff;border-radius:6px;">
<div style="font-size:13px;font-weight:500;">{{ item.query }} <el-tag size="mini">{{ item.count }}条</el-tag></div>
<div style="font-size:12px;color:#909399;margin-top:4px;" v-for="s in item.samples" :key="s">● {{ s }}</div>
</div>
</div>
<div v-else-if="key === '文章评分' && Array.isArray(val)">
<div v-for="item in val" :key="item.title" style="margin-bottom:6px;display:flex;align-items:center;gap:8px;">
<span style="font-size:13px;">{{ item.title }}</span>
<el-rate :model-value="parseScore(item.score)" disabled show-score score-template="{value}分" size="small"></el-rate>
</div>
</div>
<div v-else-if="key === 'AI建议摘要'" style="background:#f0f9eb;padding:10px 14px;border-radius:8px;font-size:13px;color:#303133;">{{ val }}</div>
<div v-else-if="key === '建议新增类别' && Array.isArray(val)">
<div v-for="item in val" :key="item.name" style="padding:8px;background:#f0f9eb;border-radius:6px;margin-bottom:6px;">
<div style="font-weight:500;">{{ item.name }}</div>
<div style="font-size:12px;color:#606266;">{{ item.reason }}</div>
</div>
</div>
<div v-else-if="key === '类别评估' && Array.isArray(val)">
<div v-for="item in val" :key="item.name" style="margin-bottom:4px;font-size:13px;">
<el-tag :type="item.status==='保留'?'success':'warning'" size="mini" style="margin-right:6px;">{{ item.status }}</el-tag>
{{ item.name }} — {{ item.reason }}
</div>
</div>
<div v-else-if="key === '来源分布' && typeof val === 'object'">
<div v-for="(cnt, src) in val" :key="src" style="margin-bottom:4px;font-size:13px;">
{{ src }}: <el-tag size="mini">{{ cnt }}条</el-tag>
</div>
</div>
<div v-else-if="key === '高互动领域' && Array.isArray(val)">
<div v-for="item in val" :key="item[0]" style="margin-bottom:4px;font-size:13px;">
{{ item[0] }}: <el-tag size="mini" type="success">{{ item[1] }}分</el-tag>
</div>
</div>
<div v-else style="font-size:13px;color:#606266;">{{ val }}</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="📋 运行记录" name="history" style="overflow:auto;flex:1;">
<div v-if="drawerHistory && drawerHistory.length > 0">
<el-timeline>
<el-timeline-item v-for="(h, i) in drawerHistory" :key="h.id || i"
:timestamp="formatDate(h.started_at)"
:color="h.status === 'success' ? '#67c23a' : h.status === 'running' ? '#e6a23c' : '#f56c6c'"
:hollow="h.status === 'running'">
<div style="font-size:13px;">
<el-tag :type="h.status === 'success' ? 'success' : h.status === 'running' ? 'warning' : 'danger'" size="small" style="margin-right:6px;">{{ h.status }}</el-tag>
<span v-if="h.message">{{ h.message }}</span>
<span v-else style="color:#909399;"></span>
</div>
<div v-if="h.error_trace" style="margin-top:4px;padding:6px 8px;background:#fef0f0;border-radius:4px;font-size:12px;color:#f56c6c;max-height:80px;overflow:auto;">{{ h.error_trace }}</div>
<div style="font-size:12px;color:#c0c4cc;margin-top:2px;">
耗时 {{ h.duration ? h.duration + 's' : '—' }} · 触发 {{ h.triggered_by }}
</div>
</el-timeline-item>
</el-timeline>
</div>
<div v-else style="color:#909399;padding:20px;text-align:center;">暂无运行记录</div>
</el-tab-pane>
<el-tab-pane label="🤖 提示词" name="prompts" style="overflow:auto;flex:1;">
<div v-if="!drawerPrompts || drawerPrompts.length === 0" style="color:#909399;padding:20px;text-align:center;">此模块暂无提示词配置</div>
<div v-else style="display:flex;flex-direction:column;gap:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
<span style="font-size:12px;color:#909399;">{{ drawerPrompts.length }} 个提示词 · 点击展开编辑</span>
<el-button size="small" @click="reloadPrompts">刷新</el-button>
</div>
<el-collapse v-if="drawerPrompts.length > 3" style="flex-shrink:0;">
<el-collapse-item v-for="p in drawerPrompts" :key="p.id" :title="p.key + (p.description ? '' + p.description + '' : '')" :name="p.id">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<div style="font-size:12px;color:#909399;">{{ p.description }}</div>
<el-switch v-model="p.enabled" @change="savePrompt(p)" :disabled="promptSaving === p.id" size="small"></el-switch>
</div>
<div style="display:flex;gap:12px;margin-bottom:8px;font-size:12px;color:#606266;">
temp: <span style="color:#409eff;">{{ p.temperature ?? '-' }}</span>
max_tokens: <span style="color:#409eff;">{{ p.max_tokens ?? '-' }}</span>
<span v-for="v in (p.variables || [])" :key="v.name" style="background:#f4f4f5;padding:1px 6px;border-radius:4px;">{{ v.name }}</span>
</div>
<el-input type="textarea" v-model="p.content" :rows="6" style="font-family:monospace;font-size:12px;" @blur="savePrompt(p)"></el-input>
</el-collapse-item>
</el-collapse>
<div v-else>
<div v-for="p in drawerPrompts" :key="p.id" style="border:1px solid #ebeef5;border-radius:10px;padding:12px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
<div>
<el-tag size="small" type="info" style="margin-right:6px;">{{ p.category }}</el-tag>
<span style="font-weight:600;font-size:14px;color:#303133;">{{ p.key }}</span>
<div style="font-size:12px;color:#909399;margin-top:2px;">{{ p.description }}</div>
</div>
<el-switch v-model="p.enabled" @change="savePrompt(p)" :disabled="promptSaving === p.id" size="small"></el-switch>
</div>
<div style="display:flex;gap:10px;margin-bottom:6px;font-size:12px;color:#606266;flex-wrap:wrap;">
<span>temp: <span style="color:#409eff;">{{ p.temperature ?? '-' }}</span></span>
<span>max: <span style="color:#409eff;">{{ p.max_tokens ?? '-' }}</span></span>
<span v-for="v in (p.variables || [])" :key="v.name" style="background:#f4f4f5;padding:1px 6px;border-radius:4px;">{{ v.name }}</span>
</div>
<el-input type="textarea" v-model="p.content" :rows="4" style="font-family:monospace;font-size:12px;" @blur="savePrompt(p)"></el-input>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<div style="padding-top:16px;border-top:1px solid #ebeef5;flex-shrink:0;text-align:center;">
<el-button type="primary" @click="triggerModule(drawerData.module_id)" :loading="runningModule === drawerData.module_id" size="medium">立即运行此任务</el-button>
</div>
</div>
</el-drawer>
</main>
</div>
<!-- 案例对话框 -->
<el-dialog v-model="caseDialogVisible" :title="caseDialogTitle" width="700px" :close-on-click-modal="false">
<el-form :model="caseForm" label-width="80px">
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="标题"><el-input v-model="caseForm.title"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="领域"><el-input v-model="caseForm.field"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="概述"><el-input type="textarea" v-model="caseForm.summary" :rows="3"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="关键指标"><el-input v-model="caseForm.key_metrics"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="日期"><el-input v-model="caseForm.date"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="来源"><el-input v-model="caseForm.source"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="来源URL"><el-input v-model="caseForm.source_url"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="可信度"><el-input v-model="caseForm.credibility_rating"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="国内适用性"><el-input v-model="caseForm.china_applicability"/></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="caseDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCase">确定</el-button>
</template>
</el-dialog>
<!-- 类别对话框 -->
<el-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="500px" :close-on-click-modal="false">
<el-form :model="categoryForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="categoryForm.name" placeholder="如:循环消费"/></el-form-item>
<el-form-item label="搜索词"><el-input v-model="categoryForm.search_query" placeholder="如:以旧换新 二手交易 闲置 2026"/></el-form-item>
<el-form-item label="描述"><el-input type="textarea" v-model="categoryForm.description" :rows="3"/></el-form-item>
<el-form-item label="排序"><el-input-number v-model="categoryForm.sort_order" :min="0"/></el-form-item>
<el-form-item label="激活"><el-switch v-model="categoryForm.is_active"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="categoryDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCategory">确定</el-button>
</template>
</el-dialog>
<!-- 信息源对话框 -->
<el-dialog v-model="sourceDialogVisible" :title="sourceDialogTitle" width="550px" :close-on-click-modal="false">
<el-form :model="sourceForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="sourceForm.name" placeholder="如:循环消费搜索"/></el-form-item>
<el-form-item label="类型">
<el-select v-model="sourceForm.source_type" style="width:100%">
<el-option label="RSS订阅" value="rss"></el-option>
<el-option label="搜索引擎" value="web_search"></el-option>
<el-option label="本地文件" value="local"></el-option>
</el-select>
</el-form-item>
<el-form-item label="查询词/URL"><el-input v-model="sourceForm.query" :placeholder="sourceForm.source_type==='web_search'?'搜索关键词':'RSS URL或本地路径'"/></el-form-item>
<el-form-item label="聚焦领域"><el-input v-model="sourceForm.focus"/></el-form-item>
<el-form-item label="可信度">
<el-select v-model="sourceForm.credibility" style="width:100%">
<el-option label="高" value="high"></el-option>
<el-option label="中" value="medium"></el-option>
<el-option label="低" value="low"></el-option>
</el-select>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="sourceForm.sort_order" :min="0"/></el-form-item>
<el-form-item label="激活"><el-switch v-model="sourceForm.is_active"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="sourceDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveSource">确定</el-button>
</template>
</el-dialog>
<!-- 任务详情对话框 -->
<el-dialog v-model="showDetailDialog" title="任务详情" width="600px" class="task-detail-dialog">
<div v-if="detailTask">
<div class="detail-row"><span class="detail-label">任务ID</span><span class="detail-value">{{ detailTask.task_id }}</span></div>
<div class="detail-row"><span class="detail-label">状态</span><span class="detail-value"><span class="status-badge" :class="'status-' + detailTask.status">{{ getStatusLabel(detailTask.status) }}</span></span></div>
<div class="detail-row"><span class="detail-label">阶段</span><span class="detail-value"><span class="task-stage" :class="'stage-' + detailTask.stage">{{ getStageLabel(detailTask.stage) }}</span></span></div>
<div class="detail-row"><span class="detail-label">进度</span><span class="detail-value">{{ detailTask.progress }}%</span></div>
<div class="detail-row"><span class="detail-label">选题ID</span><span class="detail-value">{{ detailTask.topic_id || '-' }}</span></div>
<div class="detail-row"><span class="detail-label">创建时间</span><span class="detail-value">{{ formatDate(detailTask.created_at) }}</span></div>
<div class="detail-row"><span class="detail-label">开始时间</span><span class="detail-value">{{ formatDate(detailTask.started_at) }}</span></div>
<div class="detail-row"><span class="detail-label">完成时间</span><span class="detail-value">{{ formatDate(detailTask.finished_at) }}</span></div>
<div class="detail-row"><span class="detail-label">耗时</span><span class="detail-value">{{ detailTask.duration ? detailTask.duration + '秒' : '-' }}</span></div>
<div class="detail-row"><span class="detail-label">创建人</span><span class="detail-value">{{ detailTask.created_by }}</span></div>
<div class="detail-row"><span class="detail-label">消息</span><span class="detail-value">{{ detailTask.message || '-' }}</span></div>
<div v-if="detailTask.error_msg" class="detail-row"><span class="detail-label">错误信息</span><span class="detail-value" style="color: #f56c6c;">{{ detailTask.error_msg }}</span></div>
<div v-if="detailTask.result_data" style="margin-top: 16px;">
<div style="font-weight: 600; margin-bottom: 8px; font-size: 13px;">结果数据:</div>
<pre style="background: #f5f7fa; padding: 12px; border-radius: 8px; overflow: auto; max-height: 200px; font-size: 12px;">{{ JSON.stringify(detailTask.result_data, null, 2) }}</pre>
</div>
</div>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const { createApp, ref, reactive, computed, onMounted } = Vue;
const { ElMessage, ElMessageBox } = ElementPlus;
const TasksApp = {
data() {
const SCHEDULER_JOBS = {
'scheduled_collect': { icon: 'IconRefresh', name: '内容采集', defaultTime: '01:30' },
'scheduled_generate': { icon: 'IconDocument', name: '内容创作', defaultTime: '03:30' },
'scheduled_optimize': { icon: 'IconSearch', name: '合规审查', defaultTime: '04:30' },
'scheduled_optimize_sources': { icon: 'IconSetting', name: '信息源优化', defaultTime: '05:00' },
'scheduled_metrics_sync': { icon: 'IconDashboard', name: '指标同步', defaultTime: '06:00' },
'scheduled_refresh_search_cache': { icon: 'IconRefresh', name: '搜索缓存', defaultTime: '01:00' },
'scheduled_fetch_trends': { icon: 'IconRefresh', name: '热点趋势', defaultTime: '01:10' },
};
const MODULE_TRIGGER_ENDPOINTS = {
scheduled_collect: '/api/system/collect/run',
scheduled_refresh_search_cache: '/api/system/refresh-search-cache/run',
scheduled_fetch_trends: '/api/system/trends/run',
scheduled_generate: '/api/system/generate/run',
scheduled_optimize: '/api/system/review/run',
scheduled_optimize_sources: '/api/system/optimize-sources/run',
scheduled_metrics_sync: '/api/system/metrics-sync/run',
};
return {
currentUser: { username: '' }, isAdmin: false, isLoggedIn: false,
activeTab: 'scheduler',
// 模块 & 调度
modules: [], moduleLoading: false, schedulerRunning: false, runningModule: null,
showDrawer: false, drawerTitle: '', drawerData: null, drawerLoading: false, drawerError: '', drawerTab: 'inputs', drawerHistory: [], drawerPrompts: [], savingConfig: false, promptSaving: null, promptEditOrig: {},
// 创作任务
allTasks: [], loading: false, filterStatus: '',
showDetailDialog: false, detailTask: null,
currentPage: 1, pageSize: 10,
pollTimer: null,
// 案例
cases: [], casesLoading: false,
caseDialogVisible: false, caseDialogTitle: '新增案例', editingCaseId: null,
caseForm: { id: null, title: '', field: '', summary: '', key_metrics: '', date: '', source: '', source_url: '', credibility_rating: '', china_applicability: '' },
casePage: 1, casePageSize: 10,
// 类别
categories: [], categoriesLoading: false,
categoryDialogVisible: false, categoryDialogTitle: '新增类别', editingCategoryId: null,
categoryForm: { name: '', search_query: '', description: '', sort_order: 0, is_active: true },
categoryPage: 1, categoryPageSize: 10,
// 信息源
sources: [], sourcesLoading: false,
sourceDialogVisible: false, sourceDialogTitle: '新增信息源', editingSourceId: null,
sourceForm: { name: '', source_type: 'web_search', query: '', credibility: 'medium', focus: '', sort_order: 0, is_active: true },
sourcePage: 1, sourcePageSize: 10,
SCHEDULER_JOBS, MODULE_TRIGGER_ENDPOINTS,
}
},
computed: {
paginatedTasks() {
const start = (this.currentPage - 1) * this.pageSize;
return this.allTasks.slice(start, start + this.pageSize);
},
paginatedCases() {
const s = (this.casePage - 1) * this.casePageSize;
return this.cases.slice(s, s + this.casePageSize);
},
paginatedCategories() {
const s = (this.categoryPage - 1) * this.categoryPageSize;
return this.categories.slice(s, s + this.categoryPageSize);
},
paginatedSources() {
const s = (this.sourcePage - 1) * this.sourcePageSize;
return this.sources.slice(s, s + this.sourcePageSize);
},
},
methods: {
// === 通用 ===
getToken() { return localStorage.getItem('authToken'); },
async api(url, opts = {}) {
const token = this.getToken();
if (!token) { ElMessage.error('请先登录'); return null; }
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers }, ...opts });
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
return res.json();
},
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
checkAuth() {
const token = this.getToken();
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.switchTab('scheduler'); })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
switchTab(name) {
this.activeTab = name;
this.showDrawer = false;
if (name === 'scheduler') { this.loadModules(); }
else if (name === 'cases') { this.loadCases(); }
else if (name === 'categories') { this.loadCategories(); }
else if (name === 'sources') { this.loadSources(); }
else if (name === 'tasks') { this.loadTasks(); }
},
// === 模块 ===
async loadModules() {
this.moduleLoading = true;
try {
const data = await this.api('/api/system/modules/status');
if (!data) return;
this.modules = data.modules || [];
this.schedulerRunning = data.scheduler && data.scheduler.running === true;
} catch (e) { console.error(e); }
finally { this.moduleLoading = false; }
},
async openModuleDetail(mod) {
this.showDrawer = true;
this.drawerTitle = mod.title + ' 详情';
this.drawerData = { ...mod };
this.drawerError = '';
this.drawerLoading = true;
this.drawerTab = 'inputs';
this.drawerHistory = [];
this.drawerPrompts = [];
try {
const [detail, history, prompts] = await Promise.all([
this.api('/api/admin/task-configs/' + mod.module_id),
this.api('/api/admin/task-configs/history/' + mod.module_id + '?limit=20'),
this.api('/api/admin/prompt-configs?module_id=' + mod.module_id),
]);
this.drawerData = { ...mod, ...detail };
this.drawerHistory = history || [];
this.drawerPrompts = prompts || [];
} catch (e) { this.drawerError = e.message; }
finally { this.drawerLoading = false; }
},
async savePrompt(p) {
this.promptSaving = p.id;
try {
await this.api('/api/admin/prompt-configs/' + p.id, {
method: 'PUT',
body: JSON.stringify({
content: p.content,
enabled: p.enabled,
description: p.description,
temperature: p.temperature,
max_tokens: p.max_tokens,
}),
});
ElMessage.success('已保存');
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
finally { this.promptSaving = null; }
},
async reloadPrompts() {
if (!this.drawerData || !this.drawerData.module_id) return;
try {
this.drawerPrompts = await this.api('/api/admin/prompt-configs?module_id=' + this.drawerData.module_id);
} catch (e) { ElMessage.error('刷新失败: ' + e.message); }
},
async triggerModule(modId) {
const endpoint = this.MODULE_TRIGGER_ENDPOINTS[modId];
if (!endpoint) { ElMessage.error('未知模块'); return; }
this.runningModule = modId;
try {
await this.api(endpoint, { method: 'POST' });
ElMessage.success('任务已启动');
setTimeout(() => this.loadModules(), 2000);
} catch (e) { ElMessage.error('启动失败: ' + e.message); }
finally { this.runningModule = null; }
},
async saveModuleConfig() {
if (!this.drawerData || !this.drawerData.module_id) return;
this.savingConfig = true;
try {
await this.api('/api/admin/task-configs/' + this.drawerData.module_id, {
method: 'PUT',
body: JSON.stringify({ enabled: this.drawerData.enabled, params: this.drawerData.params, schedule: this.drawerData.schedule, last_modified_by: this.currentUser.username }),
});
ElMessage.success('配置已保存');
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
finally { this.savingConfig = false; }
},
// === 创作任务 ===
parseScore(v) { const n = parseFloat(v); return isNaN(n) ? 0 : n; },
getStatusLabel(status) { return { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' }[status] || status; },
getStageLabel(stage) { return { 'creator': '创作', 'optimize': '审查', 'review': '审查', 'publish': '发布' }[stage] || stage; },
formatDate(dateStr) {
if (!dateStr) return '-';
try { return new Date(dateStr).toLocaleString('zh-CN'); } catch (e) { return dateStr; }
},
startPolling() {
this.stopPolling();
this.pollTimer = setInterval(() => this.loadTasks(false), 5000);
},
stopPolling() {
if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
},
async loadTasks(resetPage = true) {
this.loading = true;
try {
let url = '/api/tasks?limit=200';
if (this.filterStatus) url += '&status=' + this.filterStatus;
const data = await this.api(url) || [];
this.allTasks = data;
if (resetPage) this.currentPage = 1;
const hasRunning = data.some(t => t.status === 'running');
if (hasRunning) { this.startPolling(); } else { this.stopPolling(); }
} catch (e) { console.error(e); ElMessage.error('加载任务列表失败: ' + e.message); }
finally { this.loading = false; }
},
viewTaskDetail(task) { this.detailTask = task; this.showDetailDialog = true; },
async cancelTask(taskId) {
try {
await ElMessageBox.confirm('确定取消该任务?', '提示', { type: 'warning' });
await this.api('/api/tasks/' + taskId, { method: 'DELETE' });
ElMessage.success('任务已取消');
this.loadTasks();
} catch (e) { if (e !== 'cancel') ElMessage.error(e.message || '操作失败'); }
},
async retryTask(task) {
if (!task.topic_id) { ElMessage.warning('该任务无关联选题,无法重试'); return; }
try {
await this.api('/api/tasks/run-creator?topic_id=' + task.topic_id, { method: 'POST' });
ElMessage.success('已重新提交创作任务');
this.loadTasks();
} catch (e) { ElMessage.error('重试失败: ' + e.message); }
},
// === 案例 ===
async loadCases() {
this.casesLoading = true;
try { this.cases = await this.api('/api/admin/cases'); } catch (e) { ElMessage.error('加载案例失败: ' + e.message); }
finally { this.casesLoading = false; }
},
showCaseDialog(row = null) {
if (row) { this.caseDialogTitle = '编辑案例'; this.editingCaseId = row.id; Object.assign(this.caseForm, row); }
else { this.caseDialogTitle = '新增案例'; this.editingCaseId = null; Object.keys(this.caseForm).forEach(k => { if (k === 'id') this.caseForm.id = null; else this.caseForm[k] = ''; }); }
this.caseDialogVisible = true;
},
async saveCase() {
try {
if (this.editingCaseId) { await this.api('/api/admin/cases/' + this.editingCaseId, { method: 'PUT', body: JSON.stringify(this.caseForm) }); ElMessage.success('更新成功'); }
else { await this.api('/api/admin/cases', { method: 'POST', body: JSON.stringify(this.caseForm) }); ElMessage.success('创建成功'); }
this.caseDialogVisible = false; await this.loadCases();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
},
async deleteCase(id) {
try { await ElMessageBox.confirm('确定删除该案例吗?', '提示', { type: 'warning' }); await this.api('/api/admin/cases/' + id, { method: 'DELETE' }); ElMessage.success('删除成功'); await this.loadCases(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
},
// === 类别 ===
async loadCategories() {
this.categoriesLoading = true;
try { this.categories = await this.api('/api/admin/collector/categories'); } catch (e) { ElMessage.error('加载类别失败: ' + e.message); }
finally { this.categoriesLoading = false; }
},
showCategoryDialog(row = null) {
if (row) { this.categoryDialogTitle = '编辑类别'; this.editingCategoryId = row.id; Object.assign(this.categoryForm, { name: row.name, search_query: row.search_query || '', description: row.description || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
else { this.categoryDialogTitle = '新增类别'; this.editingCategoryId = null; this.categoryForm.name = ''; this.categoryForm.search_query = ''; this.categoryForm.description = ''; this.categoryForm.sort_order = 0; this.categoryForm.is_active = true; }
this.categoryDialogVisible = true;
},
async saveCategory() {
try {
if (this.editingCategoryId) { await this.api('/api/admin/collector/categories/' + this.editingCategoryId, { method: 'PUT', body: JSON.stringify(this.categoryForm) }); ElMessage.success('更新成功'); }
else { await this.api('/api/admin/collector/categories', { method: 'POST', body: JSON.stringify(this.categoryForm) }); ElMessage.success('创建成功'); }
this.categoryDialogVisible = false; await this.loadCategories();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
},
async deleteCategory(id) {
try { await ElMessageBox.confirm('确定删除该类别及其关联的信息源吗?', '提示', { type: 'warning' }); await this.api('/api/admin/collector/categories/' + id, { method: 'DELETE' }); ElMessage.success('删除成功'); await this.loadCategories(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
},
// === 信息源 ===
async loadSources() {
this.sourcesLoading = true;
try { this.sources = await this.api('/api/admin/collector/sources'); } catch (e) { ElMessage.error('加载信息源失败: ' + e.message); }
finally { this.sourcesLoading = false; }
},
showSourceDialog(row = null) {
if (row) { this.sourceDialogTitle = '编辑信息源'; this.editingSourceId = row.id; Object.assign(this.sourceForm, { name: row.name, source_type: row.source_type, query: row.query || '', credibility: row.credibility || 'medium', focus: row.focus || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
else { this.sourceDialogTitle = '新增信息源'; this.editingSourceId = null; this.sourceForm.name = ''; this.sourceForm.source_type = 'web_search'; this.sourceForm.query = ''; this.sourceForm.credibility = 'medium'; this.sourceForm.focus = ''; this.sourceForm.sort_order = 0; this.sourceForm.is_active = true; }
this.sourceDialogVisible = true;
},
async saveSource() {
try {
if (this.editingSourceId) { await this.api('/api/admin/collector/sources/' + this.editingSourceId, { method: 'PUT', body: JSON.stringify(this.sourceForm) }); ElMessage.success('更新成功'); }
else { await this.api('/api/admin/collector/sources', { method: 'POST', body: JSON.stringify(this.sourceForm) }); ElMessage.success('创建成功'); }
this.sourceDialogVisible = false; await this.loadSources();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
},
async deleteSource(id) {
try { await ElMessageBox.confirm('确定删除该信息源吗?', '提示', { type: 'warning' }); await this.api('/api/admin/collector/sources/' + id, { method: 'DELETE' }); ElMessage.success('删除成功'); await this.loadSources(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
},
},
mounted() {
this.checkAuth();
// 从 URL hash 定位 tab
const hash = window.location.hash.replace('#tab=', '');
if (['scheduler','cases','categories','sources','tasks'].includes(hash)) {
this.activeTab = hash;
}
},
beforeUnmount() { this.stopPolling(); }
};
const app = Vue.createApp(TasksApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
<script src="ai-assistant.js"></script>
</body>
</html>