feat: 任务进度实时展示、接口测试、暗色主题重构及多项 bug 修复
后端 - 新增 app/task_progress.py 线程安全进度注册表 - 任务改为后台线程异步执行(_run_task_background),手动触发立即返回 task_key - 6 个任务函数(summarizer/tagger/scorer/deduplicator/brief/taxonomy)循环内上报进度 - scheduler 定时任务同步上报进度(trigger=scheduled) - 新增 GET /api/tasks/progress 与 POST /api/tasks/progress/reset 接口 - 新增 POST /api/test-connection 接口连通性测试(独立短超时客户端) - 修复 ai_client/rss_client 配置在 import 时固化的 bug(改为 property 运行时读取 settings), 导致实际任务用 .env 假 key 调 LLM 401 - 修复 ai_client 对 reasoning 模型(MiniMax-M3 等)输出 <think> 块的 JSON 解析失败 - 修复 taxonomy bootstrap:LLM 超时(改用 300s 专用 client)、MiniMax 输出审查 (精简样本仅标题 + 约束生成中性类目名)、失败误报 success(改抛异常如实标记) - 修复 models.py 双外键关系映射启动崩溃(显式 foreign_keys) - 修复 main.py SPA 路由 404、ArticleOut.published_at 序列化 500 - 移除 lifespan 同步 bootstrap 阻塞启动,改由 scheduler 后台异步执行 前端 - Deep Ink 高对比度暗色主题重构,修复 Element Plus 暗色模式对比度问题 - Tasks 页面任务进度实时展示(进度条/阶段/计数/状态/触发来源)+ 1.5s 轮询 - 接口测试面板(rssKeeper / LLM 连通性 + 延迟) - 修复 nextJobs jobId 映射 bug 部署与文档 - Dockerfile 优化(BuildKit 缓存挂载、预编译 wheel、去 gcc、阿里云镜像源) - 新增 API.md 接口文档 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -1,10 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dataClean - RSS 数据清洗</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,400;0,500;0,600;0,700;0,800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+36
-18
@@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<el-aside width="220px">
|
||||
<el-aside width="230px">
|
||||
<div class="logo">
|
||||
<el-icon size="28"><DataLine /></el-icon>
|
||||
<span>dataClean</span>
|
||||
<div class="logo-icon">
|
||||
<el-icon size="22"><DataLine /></el-icon>
|
||||
</div>
|
||||
<span class="logo-text">dataClean</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
router
|
||||
background-color="transparent"
|
||||
text-color="#a0a0a0"
|
||||
active-text-color="#409eff"
|
||||
text-color="#9ba4b8"
|
||||
active-text-color="#2dd4bf"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
@@ -40,17 +42,17 @@
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="top-header" height="60px">
|
||||
<el-header class="top-header" height="56px">
|
||||
<div class="header-right">
|
||||
<el-input
|
||||
v-model="apiTokenInput"
|
||||
placeholder="API Token(未设置可留空)"
|
||||
size="small"
|
||||
size="default"
|
||||
show-password
|
||||
style="width: 260px;"
|
||||
style="width: 280px;"
|
||||
@keyup.enter="saveToken"
|
||||
/>
|
||||
<el-button size="small" type="primary" @click="saveToken">
|
||||
<el-button size="default" type="primary" @click="saveToken">
|
||||
{{ hasToken ? '更新 Token' : '设置 Token' }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -89,15 +91,30 @@ const saveToken = () => {
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
border-bottom: 1px solid var(--dc-border);
|
||||
padding: 0 16px;
|
||||
}
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: var(--dc-primary-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dc-primary);
|
||||
}
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--dc-text);
|
||||
letter-spacing: -0.3px;
|
||||
font-family: var(--dc-font);
|
||||
}
|
||||
|
||||
.top-header {
|
||||
@@ -105,20 +122,21 @@ const saveToken = () => {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
border-bottom: 1px solid var(--dc-border);
|
||||
background-color: var(--dc-card-bg);
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.el-menu-item {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
margin: 2px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.el-menu-item .el-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@@ -65,10 +65,15 @@ export const datacleanApi = {
|
||||
summarize: () => api.post('/tasks/summarize'),
|
||||
tagScoreDedup: () => api.post('/tasks/tag-score-dedup'),
|
||||
generateBrief: () => api.post('/tasks/brief'),
|
||||
getTaskProgress: () => api.get('/tasks/progress'),
|
||||
resetTaskProgress: (taskKey) => api.post('/tasks/progress/reset', null, { params: { task_key: taskKey } }),
|
||||
|
||||
// 配置
|
||||
getSettings: () => api.get('/settings'),
|
||||
updateSetting: (key, value) => api.put(`/settings/${key}`, { value }),
|
||||
updateSettingsBatch: (settings) => api.put('/settings', { settings }),
|
||||
resetSettings: () => api.post('/settings/reset'),
|
||||
|
||||
// 连通性测试
|
||||
testConnection: () => api.post('/test-connection'),
|
||||
}
|
||||
|
||||
+394
-68
@@ -1,100 +1,159 @@
|
||||
/* ============================================
|
||||
dataClean — Deep Ink Theme
|
||||
高对比度深色主题,阅读优先
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
--dc-bg: #0f0f23;
|
||||
--dc-card-bg: #1a1a2e;
|
||||
--dc-border: #2d2d44;
|
||||
--dc-text: #e0e0e0;
|
||||
--dc-text-secondary: #a0a0a0;
|
||||
--dc-primary: #409eff;
|
||||
--dc-success: #67c23a;
|
||||
--dc-warning: #e6a23c;
|
||||
--dc-danger: #f56c6c;
|
||||
/* —— 背景层次 —— */
|
||||
--dc-bg: #0b0d12;
|
||||
--dc-surface: #12151e;
|
||||
--dc-surface-raised: #191d2a;
|
||||
--dc-surface-hover: #1f2435;
|
||||
--dc-overlay: #252b3d;
|
||||
|
||||
/* —— 边框 —— */
|
||||
--dc-border: #272e3f;
|
||||
--dc-border-light: #333c52;
|
||||
|
||||
/* —— 文字(严格高对比度) —— */
|
||||
--dc-text: #e8ecf4;
|
||||
--dc-text-secondary: #9ba4b8;
|
||||
--dc-text-muted: #5f6a80;
|
||||
|
||||
/* —— 主色调:Teal Mint —— */
|
||||
--dc-primary: #2dd4bf;
|
||||
--dc-primary-hover: #5eead4;
|
||||
--dc-primary-dim: rgba(45, 212, 191, 0.12);
|
||||
--dc-primary-bg: rgba(45, 212, 191, 0.08);
|
||||
|
||||
/* —— 辅助强调 —— */
|
||||
--dc-accent-warm: #f59e42;
|
||||
--dc-accent-rose: #fb7185;
|
||||
|
||||
/* —— 语义色 —— */
|
||||
--dc-success: #4ade80;
|
||||
--dc-warning: #facc15;
|
||||
--dc-danger: #f87171;
|
||||
--dc-info: #60a5fa;
|
||||
|
||||
/* —— 阴影 —— */
|
||||
--dc-shadow: 0 1px 4px rgba(0,0,0,0.35);
|
||||
--dc-shadow-lg: 0 8px 24px rgba(0,0,0,0.4);
|
||||
|
||||
/* —— 圆角 —— */
|
||||
--dc-radius: 10px;
|
||||
--dc-radius-sm: 6px;
|
||||
|
||||
/* —— 字体 —— */
|
||||
--dc-font: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
/* ===== 全局重置 ===== */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-family: var(--dc-font);
|
||||
background-color: var(--dc-bg);
|
||||
color: var(--dc-text);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: var(--dc-text);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--dc-card-bg);
|
||||
border: 1px solid var(--dc-border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--dc-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--dc-text-secondary);
|
||||
margin-top: 8px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--dc-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
a:hover {
|
||||
color: var(--dc-primary-hover);
|
||||
}
|
||||
|
||||
/* ===== 页面标题 ===== */
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 24px;
|
||||
color: var(--dc-text);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
/* ===== 统计卡片 ===== */
|
||||
.stat-card {
|
||||
background: var(--dc-surface);
|
||||
border: 1px solid var(--dc-border);
|
||||
border-radius: var(--dc-radius);
|
||||
padding: 20px 22px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.stat-card:hover {
|
||||
border-color: var(--dc-border-light);
|
||||
box-shadow: var(--dc-shadow);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
color: var(--dc-primary);
|
||||
letter-spacing: -0.5px;
|
||||
font-family: var(--dc-font);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--dc-text-secondary);
|
||||
margin-top: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== 通用卡片(替代 dark-card) ===== */
|
||||
.dark-card {
|
||||
background: var(--dc-card-bg) !important;
|
||||
background: var(--dc-surface) !important;
|
||||
border: 1px solid var(--dc-border) !important;
|
||||
border-radius: var(--dc-radius) !important;
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
|
||||
.dark-card .el-card__header {
|
||||
border-bottom: 1px solid var(--dc-border) !important;
|
||||
color: var(--dc-text) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 柱状图 ===== */
|
||||
.daily-bar-wrap {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
height: 120px;
|
||||
height: 140px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.daily-bar {
|
||||
flex: 1;
|
||||
background: linear-gradient(to top, var(--dc-primary), #66b1ff);
|
||||
background: linear-gradient(to top, var(--dc-primary), #5eead4);
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-width: 20px;
|
||||
position: relative;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.daily-bar:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.daily-bar-label {
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
bottom: -22px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--dc-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.daily-bar-value {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
@@ -102,63 +161,330 @@ body {
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
color: var(--dc-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 评分进度条 ===== */
|
||||
.score-progress {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.score-progress .el-progress-bar__outer {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
background-color: rgba(255, 255, 255, 0.06) !important;
|
||||
}
|
||||
|
||||
/* ===== 链接 & 标签 ===== */
|
||||
.article-link {
|
||||
color: var(--dc-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.article-link:hover {
|
||||
color: var(--dc-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
margin-right: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Element Plus 暗色覆盖 */
|
||||
/* ============================================
|
||||
Element Plus 暗色深度覆盖
|
||||
确保 EP 组件在深色背景上完全可读
|
||||
============================================ */
|
||||
|
||||
/* —— 侧栏 —— */
|
||||
.el-aside {
|
||||
background-color: var(--dc-surface) !important;
|
||||
border-right: 1px solid var(--dc-border) !important;
|
||||
}
|
||||
.el-menu {
|
||||
border-right: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.el-aside {
|
||||
background-color: var(--dc-card-bg) !important;
|
||||
border-right: 1px solid var(--dc-border) !important;
|
||||
.el-menu-item {
|
||||
color: var(--dc-text-secondary) !important;
|
||||
border-radius: var(--dc-radius-sm);
|
||||
margin: 2px 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.el-menu-item:hover {
|
||||
background-color: var(--dc-surface-hover) !important;
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
.el-menu-item.is-active {
|
||||
background-color: var(--dc-primary-dim) !important;
|
||||
color: var(--dc-primary) !important;
|
||||
}
|
||||
|
||||
/* —— 主容器 —— */
|
||||
.el-container {
|
||||
background-color: var(--dc-bg) !important;
|
||||
}
|
||||
|
||||
.el-main {
|
||||
background-color: var(--dc-bg) !important;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
background-color: transparent !important;
|
||||
.el-header {
|
||||
background-color: var(--dc-surface) !important;
|
||||
}
|
||||
|
||||
/* —— 卡片 —— */
|
||||
.el-card {
|
||||
background-color: var(--dc-surface) !important;
|
||||
border-color: var(--dc-border) !important;
|
||||
border-radius: var(--dc-radius) !important;
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
.el-card__header {
|
||||
border-bottom-color: var(--dc-border) !important;
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
|
||||
/* —— 表格 —— */
|
||||
.el-table {
|
||||
--el-table-bg-color: transparent;
|
||||
--el-table-tr-bg-color: transparent;
|
||||
--el-table-header-bg-color: var(--dc-surface-raised);
|
||||
--el-table-row-hover-bg-color: var(--dc-primary-dim);
|
||||
--el-table-border-color: var(--dc-border);
|
||||
--el-table-text-color: var(--dc-text);
|
||||
--el-table-header-text-color: var(--dc-text-secondary);
|
||||
background-color: transparent !important;
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
.el-table th,
|
||||
.el-table tr {
|
||||
background-color: transparent !important;
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
.el-table th.el-table__cell {
|
||||
background-color: var(--dc-surface-raised) !important;
|
||||
color: var(--dc-text-secondary) !important;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.el-table--enable-row-hover .el-table__body tr:hover > td {
|
||||
background-color: rgba(64, 158, 255, 0.1) !important;
|
||||
background-color: var(--dc-primary-dim) !important;
|
||||
}
|
||||
.el-table td.el-table__cell,
|
||||
.el-table th.el-table__cell {
|
||||
border-bottom-color: var(--dc-border) !important;
|
||||
}
|
||||
.el-table__empty-text {
|
||||
color: var(--dc-text-muted) !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner {
|
||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
||||
/* —— 输入框 —— */
|
||||
.el-input__wrapper {
|
||||
background-color: var(--dc-surface-raised) !important;
|
||||
box-shadow: 0 0 0 1px var(--dc-border) inset !important;
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
.el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px var(--dc-border-light) inset !important;
|
||||
}
|
||||
.el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--dc-primary) inset !important;
|
||||
}
|
||||
.el-input__inner {
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
.el-input__inner::placeholder {
|
||||
color: var(--dc-text-muted) !important;
|
||||
}
|
||||
.el-textarea__inner {
|
||||
background-color: var(--dc-surface-raised) !important;
|
||||
border-color: var(--dc-border) !important;
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
.el-textarea__inner::placeholder {
|
||||
color: var(--dc-text-muted) !important;
|
||||
}
|
||||
|
||||
/* —— 按钮 —— */
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--dc-primary);
|
||||
--el-button-border-color: var(--dc-primary);
|
||||
--el-button-hover-bg-color: var(--dc-primary-hover);
|
||||
--el-button-hover-border-color: var(--dc-primary-hover);
|
||||
--el-button-text-color: #0b0d12;
|
||||
--el-button-hover-text-color: #0b0d12;
|
||||
font-weight: 600;
|
||||
}
|
||||
.el-button--danger {
|
||||
--el-button-bg-color: var(--dc-danger);
|
||||
--el-button-border-color: var(--dc-danger);
|
||||
--el-button-text-color: #0b0d12;
|
||||
--el-button-hover-text-color: #0b0d12;
|
||||
}
|
||||
|
||||
/* —— 标签 —— */
|
||||
.el-tag {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.el-tag--info {
|
||||
--el-tag-bg-color: rgba(96, 165, 250, 0.12);
|
||||
--el-tag-border-color: rgba(96, 165, 250, 0.2);
|
||||
--el-tag-text-color: #93bbfd;
|
||||
}
|
||||
.el-tag--success {
|
||||
--el-tag-bg-color: rgba(74, 222, 128, 0.12);
|
||||
--el-tag-border-color: rgba(74, 222, 128, 0.2);
|
||||
--el-tag-text-color: #86efac;
|
||||
}
|
||||
.el-tag--warning {
|
||||
--el-tag-bg-color: rgba(250, 204, 21, 0.12);
|
||||
--el-tag-border-color: rgba(250, 204, 21, 0.2);
|
||||
--el-tag-text-color: #fde047;
|
||||
}
|
||||
.el-tag--danger {
|
||||
--el-tag-bg-color: rgba(248, 113, 113, 0.12);
|
||||
--el-tag-border-color: rgba(248, 113, 113, 0.2);
|
||||
--el-tag-text-color: #fca5a5;
|
||||
}
|
||||
.el-tag--primary {
|
||||
--el-tag-bg-color: var(--dc-primary-dim);
|
||||
--el-tag-border-color: rgba(45, 212, 191, 0.25);
|
||||
--el-tag-text-color: var(--dc-primary);
|
||||
}
|
||||
|
||||
/* —— 时间线 —— */
|
||||
.el-timeline-item__tail {
|
||||
border-left-color: var(--dc-border-light) !important;
|
||||
}
|
||||
.el-timeline-item__node {
|
||||
background-color: var(--dc-primary) !important;
|
||||
}
|
||||
.el-timeline-item__timestamp {
|
||||
color: var(--dc-text-muted) !important;
|
||||
}
|
||||
.el-timeline-item__content {
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
|
||||
/* —— 折叠面板 —— */
|
||||
.el-collapse {
|
||||
--el-collapse-header-bg-color: transparent;
|
||||
--el-collapse-content-bg-color: transparent;
|
||||
--el-collapse-border-color: var(--dc-border);
|
||||
border-color: var(--dc-border) !important;
|
||||
}
|
||||
.el-collapse-item__header {
|
||||
color: var(--dc-text) !important;
|
||||
background-color: transparent !important;
|
||||
border-bottom-color: var(--dc-border) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.el-collapse-item__wrap {
|
||||
background-color: transparent !important;
|
||||
border-bottom-color: var(--dc-border) !important;
|
||||
}
|
||||
.el-collapse-item__content {
|
||||
color: var(--dc-text-secondary) !important;
|
||||
}
|
||||
|
||||
/* —— Tabs —— */
|
||||
.el-tabs__item {
|
||||
color: var(--dc-text-secondary) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
.el-tabs__item.is-active {
|
||||
color: var(--dc-primary) !important;
|
||||
}
|
||||
.el-tabs__item:hover {
|
||||
color: var(--dc-primary-hover) !important;
|
||||
}
|
||||
.el-tabs__active-bar {
|
||||
background-color: var(--dc-primary) !important;
|
||||
}
|
||||
.el-tabs__nav-wrap::after {
|
||||
background-color: var(--dc-border) !important;
|
||||
}
|
||||
|
||||
/* —— 分页 —— */
|
||||
.el-pagination {
|
||||
--el-pagination-bg-color: transparent;
|
||||
--el-pagination-text-color: var(--dc-text-secondary);
|
||||
--el-pagination-button-bg-color: var(--dc-surface-raised);
|
||||
--el-pagination-hover-color: var(--dc-primary);
|
||||
}
|
||||
.el-pager li {
|
||||
background: var(--dc-surface-raised) !important;
|
||||
color: var(--dc-text-secondary) !important;
|
||||
}
|
||||
.el-pager li.is-active {
|
||||
background: var(--dc-primary) !important;
|
||||
color: #0b0d12 !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* —— Alert —— */
|
||||
.el-alert--info {
|
||||
--el-alert-bg-color: rgba(96, 165, 250, 0.08);
|
||||
border-color: rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
.el-alert--warning {
|
||||
--el-alert-bg-color: rgba(250, 204, 21, 0.08);
|
||||
border-color: rgba(250, 204, 21, 0.2);
|
||||
}
|
||||
.el-alert__title {
|
||||
color: var(--dc-text) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* —— Empty —— */
|
||||
.el-empty__description p {
|
||||
color: var(--dc-text-muted) !important;
|
||||
}
|
||||
|
||||
/* —— Page Header —— */
|
||||
.el-page-header__left {
|
||||
color: var(--dc-text-secondary) !important;
|
||||
}
|
||||
.el-page-header__content {
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
|
||||
/* —— 日期选择器弹出层 —— */
|
||||
.el-date-editor {
|
||||
--el-fill-color-blank: var(--dc-surface-raised);
|
||||
}
|
||||
|
||||
/* —— Checkbox —— */
|
||||
.el-checkbox__label {
|
||||
color: var(--dc-text-secondary) !important;
|
||||
}
|
||||
|
||||
/* —— 进度条文本 —— */
|
||||
.el-progress__text {
|
||||
color: var(--dc-text) !important;
|
||||
}
|
||||
|
||||
/* —— Loading —— */
|
||||
.el-loading-mask {
|
||||
background-color: rgba(11, 13, 18, 0.7) !important;
|
||||
}
|
||||
|
||||
/* —— Message Box —— */
|
||||
.el-message-box {
|
||||
--el-messagebox-title-color: var(--dc-text);
|
||||
--el-messagebox-content-color: var(--dc-text-secondary);
|
||||
background-color: var(--dc-surface) !important;
|
||||
border-color: var(--dc-border) !important;
|
||||
}
|
||||
|
||||
/* —— 滚动条美化 —— */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--dc-border-light);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--dc-text-muted);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<el-page-header @back="$router.push('/articles')" title="文章详情" />
|
||||
<el-page-header @back="$router.push('/articles')" title="返回列表">
|
||||
<template #content>
|
||||
<span style="color: var(--dc-text); font-weight: 600;">文章详情</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card v-if="article" class="dark-card" style="margin-top: 20px;">
|
||||
<template #header>
|
||||
@@ -10,7 +14,7 @@
|
||||
<span><el-icon><OfficeBuilding /></el-icon> {{ article.feed_title }}</span>
|
||||
<span v-if="article.author"><el-icon><User /></el-icon> {{ article.author }}</span>
|
||||
<span><el-icon><Timer /></el-icon> {{ article.published_at }}</span>
|
||||
<el-tag v-if="article.is_representative" type="success">重复组代表</el-tag>
|
||||
<el-tag v-if="article.is_representative" type="success" size="small">重复组代表</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -35,15 +39,13 @@
|
||||
|
||||
<div class="article-section">
|
||||
<h3>评分</h3>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6" v-for="score in scoreList" :key="score.label">
|
||||
<div class="score-item">
|
||||
<div class="score-label">{{ score.label }}</div>
|
||||
<div class="score-value">{{ score.value.toFixed(1) }}</div>
|
||||
<el-progress :percentage="Math.round(score.value)" :color="score.color" class="score-progress" />
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="scores-grid">
|
||||
<div class="score-item" v-for="score in scoreList" :key="score.label">
|
||||
<div class="score-label">{{ score.label }}</div>
|
||||
<div class="score-value" :style="{ color: score.color }">{{ score.value.toFixed(1) }}</div>
|
||||
<el-progress :percentage="Math.round(score.value)" :color="score.color" class="score-progress" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-section" v-if="article.link">
|
||||
@@ -73,10 +75,10 @@ const article = ref(null)
|
||||
const scoreList = computed(() => {
|
||||
if (!article.value) return []
|
||||
return [
|
||||
{ label: '热度', value: article.value.heat_score, color: '#f56c6c' },
|
||||
{ label: '重要性', value: article.value.importance_score, color: '#e6a23c' },
|
||||
{ label: '重复度', value: article.value.duplication_score, color: '#67c23a' },
|
||||
{ label: '综合分', value: article.value.composite_score, color: '#409eff' },
|
||||
{ label: '热度', value: article.value.heat_score, color: '#f87171' },
|
||||
{ label: '重要性', value: article.value.importance_score, color: '#facc15' },
|
||||
{ label: '重复度', value: article.value.duplication_score, color: '#4ade80' },
|
||||
{ label: '综合分', value: article.value.composite_score, color: '#2dd4bf' },
|
||||
]
|
||||
})
|
||||
|
||||
@@ -98,66 +100,79 @@ onMounted(loadArticle)
|
||||
.article-header h2 {
|
||||
margin-bottom: 12px;
|
||||
color: var(--dc-text);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
color: var(--dc-text-secondary);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.article-meta .el-icon {
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.article-section {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.article-section h3 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 15px;
|
||||
margin-bottom: 14px;
|
||||
color: var(--dc-text);
|
||||
border-left: 4px solid var(--dc-primary);
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--dc-primary);
|
||||
padding-left: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-summary {
|
||||
line-height: 1.8;
|
||||
color: var(--dc-text);
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--dc-primary-bg);
|
||||
padding: 18px;
|
||||
border-radius: var(--dc-radius);
|
||||
border: 1px solid rgba(45, 212, 191, 0.15);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
color: var(--dc-text-secondary);
|
||||
color: var(--dc-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
color: var(--dc-text-secondary);
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scores-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
.score-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--dc-surface-raised);
|
||||
padding: 18px;
|
||||
border-radius: var(--dc-radius);
|
||||
text-align: center;
|
||||
border: 1px solid var(--dc-border);
|
||||
}
|
||||
|
||||
.score-label {
|
||||
color: var(--dc-text-secondary);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 8px 0;
|
||||
color: var(--dc-text);
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin: 6px 0 10px;
|
||||
letter-spacing: -0.5px;
|
||||
font-family: var(--dc-font);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<el-page-header @back="$router.push('/briefs')" title="简报详情" />
|
||||
<el-page-header @back="$router.push('/briefs')" title="返回列表">
|
||||
<template #content>
|
||||
<span style="color: var(--dc-text); font-weight: 600;">{{ brief?.brief_date }} 每日简报</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card v-if="brief" class="dark-card" style="margin-top: 20px;">
|
||||
<template #header>
|
||||
<div class="brief-header">
|
||||
<h2>{{ brief.brief_date }} 每日简报</h2>
|
||||
<div class="brief-meta">
|
||||
<el-tag type="info">原始文章:{{ brief.total_articles }}</el-tag>
|
||||
<el-tag type="success">去重后:{{ brief.unique_articles }}</el-tag>
|
||||
<el-tag type="info" effect="plain">原始文章:{{ brief.total_articles }}</el-tag>
|
||||
<el-tag type="success" effect="plain">去重后:{{ brief.unique_articles }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,9 +20,11 @@
|
||||
<el-collapse-item
|
||||
v-for="(articles, category) in brief.by_category"
|
||||
:key="category"
|
||||
:title="`${category} (${articles.length})`"
|
||||
:name="category"
|
||||
>
|
||||
<template #title>
|
||||
<span class="collapse-title">{{ category }}({{ articles.length }} 篇)</span>
|
||||
</template>
|
||||
<div
|
||||
v-for="article in articles"
|
||||
:key="article.id"
|
||||
@@ -30,7 +35,7 @@
|
||||
<span class="brief-article-feed">{{ article.feed_title }}</span>
|
||||
</div>
|
||||
<div class="brief-article-tags">
|
||||
<el-tag v-for="tag in article.tags" :key="tag" size="small" class="tag-item">{{ tag }}</el-tag>
|
||||
<el-tag v-for="tag in article.tags" :key="tag" size="small" class="tag-item" type="info">{{ tag }}</el-tag>
|
||||
<el-tag size="small" type="warning">综合 {{ article.composite_score.toFixed(1) }}</el-tag>
|
||||
</div>
|
||||
<p v-if="article.summary" class="brief-article-summary">{{ article.summary }}</p>
|
||||
@@ -73,49 +78,52 @@ onMounted(loadBrief)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.brief-header h2 {
|
||||
margin-bottom: 10px;
|
||||
color: var(--dc-text);
|
||||
.brief-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brief-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.collapse-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--dc-text);
|
||||
}
|
||||
|
||||
.brief-article {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dc-border);
|
||||
}
|
||||
|
||||
.brief-article:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.brief-article-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brief-article-title a {
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--dc-primary);
|
||||
}
|
||||
|
||||
.brief-article-feed {
|
||||
color: var(--dc-text-secondary);
|
||||
color: var(--dc-text-muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brief-article-tags {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.brief-article-summary {
|
||||
color: var(--dc-text-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
line-height: 1.7;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,21 +3,19 @@
|
||||
<h1 class="page-title">仪表盘</h1>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6" v-for="stat in stats" :key="stat.label">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card" v-for="stat in stats" :key="stat.label">
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类分布 + 最近简报 -->
|
||||
<el-row :gutter="20" style="margin-top: 20px;">
|
||||
<el-col :span="16">
|
||||
<el-card class="dark-card">
|
||||
<template #header>
|
||||
<span>分类分布</span>
|
||||
<span class="card-header-title">分类分布</span>
|
||||
</template>
|
||||
<div v-if="categoryDistribution.length" class="daily-bar-wrap">
|
||||
<div
|
||||
@@ -38,7 +36,7 @@
|
||||
<el-col :span="8">
|
||||
<el-card class="dark-card">
|
||||
<template #header>
|
||||
<span>最近简报</span>
|
||||
<span class="card-header-title">最近简报</span>
|
||||
</template>
|
||||
<el-timeline v-if="recentBriefs.length">
|
||||
<el-timeline-item
|
||||
@@ -46,8 +44,8 @@
|
||||
:key="brief.brief_date"
|
||||
:timestamp="brief.brief_date"
|
||||
>
|
||||
<el-link @click="$router.push(`/briefs/${brief.brief_date}`)">
|
||||
{{ brief.unique_articles }} 篇去重后文章 / {{ brief.total_articles }} 篇原始文章
|
||||
<el-link @click="$router.push(`/briefs/${brief.brief_date}`)" style="color: var(--dc-primary);">
|
||||
{{ brief.unique_articles }} 篇去重后 / {{ brief.total_articles }} 篇原始
|
||||
</el-link>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
@@ -61,11 +59,19 @@
|
||||
<el-col :span="24">
|
||||
<el-card class="dark-card">
|
||||
<template #header>
|
||||
<span>定时任务状态</span>
|
||||
<span class="card-header-title">定时任务状态</span>
|
||||
</template>
|
||||
<el-table :data="jobList" style="width: 100%">
|
||||
<el-table-column prop="id" label="任务" />
|
||||
<el-table-column prop="next_run" label="下次执行时间" />
|
||||
<el-table-column prop="id" label="任务">
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight: 500; color: var(--dc-text);">{{ jobNameMap[row.id] || row.id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="next_run" label="下次执行时间">
|
||||
<template #default="{ row }">
|
||||
<span style="color: var(--dc-text-secondary);">{{ row.next_run }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -91,6 +97,13 @@ const statsData = ref({
|
||||
const recentBriefs = ref([])
|
||||
const categoryDistribution = ref([])
|
||||
|
||||
const jobNameMap = {
|
||||
fetch_and_summarize: 'AI 摘要生成',
|
||||
tag_score_deduplicate: '分类 / 打分 / 去重',
|
||||
generate_daily_brief: '每日简报生成',
|
||||
bootstrap_taxonomy: '分类体系初始化',
|
||||
}
|
||||
|
||||
const stats = computed(() => [
|
||||
{ label: '总加工文章', value: statsData.value.total_articles },
|
||||
{ label: '今日文章', value: statsData.value.today_articles },
|
||||
@@ -119,14 +132,12 @@ const loadData = async () => {
|
||||
statsData.value = statsRes
|
||||
recentBriefs.value = briefsRes
|
||||
|
||||
// 计算分类分布
|
||||
const categories = taxonomyRes.filter((t) => t.kind === 'category')
|
||||
const catMap = {}
|
||||
categories.forEach((c) => {
|
||||
catMap[c.name] = 0
|
||||
})
|
||||
|
||||
// 从简报中聚合各分类文章数(取最近一份简报)
|
||||
if (briefsRes.length > 0) {
|
||||
const latestBrief = await datacleanApi.getBrief(briefsRes[0].brief_date)
|
||||
const byCategory = latestBrief.by_category || {}
|
||||
@@ -150,3 +161,16 @@ const loadData = async () => {
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card-header-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--dc-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
+359
-30
@@ -3,86 +3,233 @@
|
||||
<h1 class="page-title">任务管理</h1>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8" v-for="task in tasks" :key="task.id">
|
||||
<el-card class="dark-card" style="margin-bottom: 20px;">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="task in tasks" :key="task.id">
|
||||
<el-card class="dark-card task-card" style="margin-bottom: 20px;">
|
||||
<template #header>
|
||||
<div class="task-header">
|
||||
<el-icon size="24"><component :is="task.icon" /></el-icon>
|
||||
<span>{{ task.title }}</span>
|
||||
<div class="task-icon-wrap">
|
||||
<el-icon size="20"><component :is="task.icon" /></el-icon>
|
||||
</div>
|
||||
<span class="task-title">{{ task.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p class="task-desc">{{ task.description }}</p>
|
||||
<div v-if="task.nextRun" class="task-next-run">
|
||||
下次执行:{{ task.nextRun }}
|
||||
<el-icon size="14"><Timer /></el-icon>
|
||||
<span>下次执行:{{ task.nextRun }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 进度块 -->
|
||||
<div v-if="task.progress && task.progress.status !== 'idle'" class="task-progress">
|
||||
<div class="progress-top">
|
||||
<el-tag :type="statusTagType(task.progress.status)" size="small" effect="dark">
|
||||
{{ statusLabel(task.progress.status) }}
|
||||
</el-tag>
|
||||
<span v-if="task.progress.trigger === 'scheduled'" class="trigger-tag">定时</span>
|
||||
<span v-else-if="task.progress.trigger === 'manual'" class="trigger-tag">手动</span>
|
||||
</div>
|
||||
<div class="task-stage">
|
||||
{{ task.progress.stage || '处理中' }}
|
||||
<span v-if="task.progress.total > 0" class="task-counts">
|
||||
({{ task.progress.current }}/{{ task.progress.total }})
|
||||
</span>
|
||||
</div>
|
||||
<el-progress
|
||||
v-if="task.progress.total > 0"
|
||||
:percentage="progressPercent(task.progress)"
|
||||
:status="progressStatus(task.progress.status)"
|
||||
:stroke-width="8"
|
||||
/>
|
||||
<el-progress
|
||||
v-else
|
||||
:indeterminate="task.progress.status === 'running'"
|
||||
:show-text="false"
|
||||
:stroke-width="8"
|
||||
:percentage="task.progress.status === 'running' ? 100 : 0"
|
||||
:status="progressStatus(task.progress.status)"
|
||||
/>
|
||||
<div v-if="task.progress.status === 'error' && task.progress.message" class="task-error-msg">
|
||||
{{ task.progress.message }}
|
||||
</div>
|
||||
<div v-else-if="task.progress.message" class="task-message">
|
||||
{{ task.progress.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
style="margin-top: 16px;"
|
||||
style="margin-top: 16px; width: 100%;"
|
||||
:loading="task.loading"
|
||||
:disabled="anyRunning"
|
||||
@click="runTask(task)"
|
||||
>
|
||||
立即执行
|
||||
{{ task.progress && task.progress.status === 'running' ? '执行中…' : '立即执行' }}
|
||||
</el-button>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 接口连通测试 -->
|
||||
<h1 class="page-title" style="margin-top: 32px;">接口测试</h1>
|
||||
|
||||
<el-card class="dark-card">
|
||||
<template #header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: 600;">外部服务连通性</span>
|
||||
<el-button type="primary" :loading="testing" @click="runConnectionTest">
|
||||
开始测试
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<div class="test-result-card" :class="resultClass('rss_keeper')">
|
||||
<div class="test-result-header">
|
||||
<div class="task-icon-wrap">
|
||||
<el-icon size="20"><Link /></el-icon>
|
||||
</div>
|
||||
<span class="test-result-title">rssKeeper API</span>
|
||||
</div>
|
||||
<div v-if="!connResults.rss_keeper" class="test-result-pending">
|
||||
点击「开始测试」检测连通性
|
||||
</div>
|
||||
<div v-else-if="connResults.rss_keeper.status === 'ok'" class="test-result-success">
|
||||
<el-icon color="var(--dc-success)" size="18"><CircleCheckFilled /></el-icon>
|
||||
<span>连通正常</span>
|
||||
<span class="test-latency">{{ connResults.rss_keeper.latency_ms }} ms</span>
|
||||
</div>
|
||||
<div v-else class="test-result-fail">
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<el-icon color="var(--dc-danger)" size="18"><CircleCloseFilled /></el-icon>
|
||||
<span>连接失败</span>
|
||||
</div>
|
||||
<div class="test-error-msg">{{ connResults.rss_keeper.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<div class="test-result-card" :class="resultClass('llm')">
|
||||
<div class="test-result-header">
|
||||
<div class="task-icon-wrap">
|
||||
<el-icon size="20"><MagicStick /></el-icon>
|
||||
</div>
|
||||
<span class="test-result-title">LLM API</span>
|
||||
</div>
|
||||
<div v-if="!connResults.llm" class="test-result-pending">
|
||||
点击「开始测试」检测连通性
|
||||
</div>
|
||||
<div v-else-if="connResults.llm.status === 'ok'" class="test-result-success">
|
||||
<el-icon color="var(--dc-success)" size="18"><CircleCheckFilled /></el-icon>
|
||||
<span>连通正常</span>
|
||||
<span class="test-latency">{{ connResults.llm.latency_ms }} ms</span>
|
||||
</div>
|
||||
<div v-else class="test-result-fail">
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<el-icon color="var(--dc-danger)" size="18"><CircleCloseFilled /></el-icon>
|
||||
<span>连接失败</span>
|
||||
</div>
|
||||
<div class="test-error-msg">{{ connResults.llm.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Document, CollectionTag, Collection } from '@element-plus/icons-vue'
|
||||
import { Document, CollectionTag, Collection, Timer, Link, MagicStick, CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue'
|
||||
import { datacleanApi } from '@/api'
|
||||
|
||||
const tasks = ref([
|
||||
{
|
||||
id: 'summarize',
|
||||
jobId: 'fetch_and_summarize',
|
||||
title: '生成 AI 摘要',
|
||||
description: '拉取 rssKeeper 最近文章,为无摘要或短摘要文章生成 AI 摘要。',
|
||||
icon: 'Document',
|
||||
nextRun: '',
|
||||
loading: false,
|
||||
progress: null,
|
||||
action: datacleanApi.summarize,
|
||||
},
|
||||
{
|
||||
id: 'tag_score_deduplicate',
|
||||
id: 'tag_score_dedup',
|
||||
jobId: 'tag_score_deduplicate',
|
||||
title: '分类 / 打分 / 去重',
|
||||
description: '对当天文章进行分类、打标签、计算分数并生成重复组。',
|
||||
icon: 'CollectionTag',
|
||||
nextRun: '',
|
||||
loading: false,
|
||||
progress: null,
|
||||
action: datacleanApi.tagScoreDedup,
|
||||
},
|
||||
{
|
||||
id: 'generate_daily_brief',
|
||||
jobId: 'generate_daily_brief',
|
||||
title: '生成每日简报',
|
||||
description: '基于当天去重后的代表文章生成每日简报。',
|
||||
icon: 'Collection',
|
||||
nextRun: '',
|
||||
loading: false,
|
||||
progress: null,
|
||||
action: datacleanApi.generateBrief,
|
||||
},
|
||||
{
|
||||
id: 'bootstrap_taxonomy',
|
||||
jobId: 'bootstrap_taxonomy',
|
||||
title: '初始化分类体系',
|
||||
description: 'AI 根据样本文章生成分类、标签和打分规则(服务首次启动时自动执行)。',
|
||||
icon: 'CollectionTag',
|
||||
nextRun: '',
|
||||
loading: false,
|
||||
progress: null,
|
||||
action: datacleanApi.bootstrapTaxonomy,
|
||||
},
|
||||
])
|
||||
|
||||
const loadStats = async () => {
|
||||
const anyRunning = computed(() =>
|
||||
tasks.value.some((t) => t.progress?.status === 'running')
|
||||
)
|
||||
|
||||
// 接口测试状态
|
||||
const testing = ref(false)
|
||||
const connResults = ref({ rss_keeper: null, llm: null })
|
||||
|
||||
// 进度轮询
|
||||
let progressTimer = null
|
||||
const fetchProgress = async () => {
|
||||
try {
|
||||
const stats = await datacleanApi.getStats()
|
||||
const nextJobs = stats.next_jobs || {}
|
||||
tasks.value.forEach((task) => {
|
||||
task.nextRun = nextJobs[task.id] || '未调度'
|
||||
const data = await datacleanApi.getTaskProgress()
|
||||
tasks.value.forEach((t) => {
|
||||
t.progress = data[t.id] || null
|
||||
})
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message)
|
||||
// 无任何任务运行时停止轮询
|
||||
if (!anyRunning.value && progressTimer) {
|
||||
clearInterval(progressTimer)
|
||||
progressTimer = null
|
||||
}
|
||||
} catch (e) {
|
||||
// 静默失败,下一轮重试
|
||||
}
|
||||
}
|
||||
const startPolling = () => {
|
||||
if (!progressTimer) {
|
||||
fetchProgress()
|
||||
progressTimer = setInterval(fetchProgress, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
const runTask = async (task) => {
|
||||
task.loading = true
|
||||
try {
|
||||
const res = await task.action()
|
||||
const res = await task.action() // 后台执行,立即返回
|
||||
ElMessage.success(res.message)
|
||||
loadStats()
|
||||
startPolling()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message)
|
||||
} finally {
|
||||
@@ -90,27 +237,209 @@ const runTask = async (task) => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
const statusTagType = (status) => ({
|
||||
running: 'warning', success: 'success', error: 'danger', idle: 'info',
|
||||
}[status] || 'info')
|
||||
|
||||
const statusLabel = (status) => ({
|
||||
running: '执行中', success: '已完成', error: '失败', idle: '空闲',
|
||||
}[status] || status)
|
||||
|
||||
const progressPercent = (p) => {
|
||||
if (!p || !p.total) return 0
|
||||
return Math.min(100, Math.round((p.current / p.total) * 100))
|
||||
}
|
||||
|
||||
const progressStatus = (status) => {
|
||||
if (status === 'success') return 'success'
|
||||
if (status === 'error') return 'exception'
|
||||
return null // running/idle 默认
|
||||
}
|
||||
|
||||
const resultClass = (key) => {
|
||||
if (!connResults.value[key]) return ''
|
||||
return connResults.value[key].status === 'ok' ? 'test-ok' : 'test-err'
|
||||
}
|
||||
|
||||
const runConnectionTest = async () => {
|
||||
testing.value = true
|
||||
connResults.value = { rss_keeper: null, llm: null }
|
||||
try {
|
||||
connResults.value = await datacleanApi.testConnection()
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message)
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const stats = await datacleanApi.getStats()
|
||||
const nextJobs = stats.next_jobs || {}
|
||||
tasks.value.forEach((task) => {
|
||||
task.nextRun = nextJobs[task.jobId] || nextJobs[task.id] || '未调度'
|
||||
})
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadStats()
|
||||
await fetchProgress()
|
||||
if (anyRunning.value) startPolling()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer)
|
||||
progressTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
gap: 12px;
|
||||
}
|
||||
.task-icon-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: var(--dc-primary-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dc-primary);
|
||||
}
|
||||
.task-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--dc-text);
|
||||
}
|
||||
|
||||
.task-desc {
|
||||
color: var(--dc-text-secondary);
|
||||
line-height: 1.6;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.task-next-run {
|
||||
margin-top: 12px;
|
||||
color: var(--dc-text-secondary);
|
||||
min-height: 48px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.task-next-run {
|
||||
margin-top: 12px;
|
||||
color: var(--dc-text-muted);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 进度块 */
|
||||
.task-progress {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
background: var(--dc-surface-raised);
|
||||
border-radius: var(--dc-radius-sm);
|
||||
border: 1px solid var(--dc-border);
|
||||
}
|
||||
.progress-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.trigger-tag {
|
||||
font-size: 11px;
|
||||
color: var(--dc-text-muted);
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--dc-border-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.task-stage {
|
||||
font-size: 13px;
|
||||
color: var(--dc-text);
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.task-counts {
|
||||
color: var(--dc-text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
.task-message {
|
||||
margin-top: 8px;
|
||||
color: var(--dc-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.task-error-msg {
|
||||
margin-top: 8px;
|
||||
color: var(--dc-text-secondary);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
padding: 8px;
|
||||
background: rgba(248, 113, 113, 0.06);
|
||||
border-radius: var(--dc-radius-sm);
|
||||
}
|
||||
|
||||
/* 接口测试卡片 */
|
||||
.test-result-card {
|
||||
background: var(--dc-surface-raised);
|
||||
border: 1px solid var(--dc-border);
|
||||
border-radius: var(--dc-radius);
|
||||
padding: 20px;
|
||||
min-height: 130px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
.test-result-card.test-ok {
|
||||
border-color: var(--dc-success);
|
||||
}
|
||||
.test-result-card.test-err {
|
||||
border-color: var(--dc-danger);
|
||||
}
|
||||
.test-result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.test-result-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--dc-text);
|
||||
}
|
||||
.test-result-pending {
|
||||
color: var(--dc-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.test-result-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--dc-success);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.test-result-fail {
|
||||
color: var(--dc-danger);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.test-error-msg {
|
||||
margin-top: 10px;
|
||||
color: var(--dc-text-secondary);
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
padding: 10px;
|
||||
background: rgba(248, 113, 113, 0.06);
|
||||
border-radius: var(--dc-radius-sm);
|
||||
}
|
||||
.test-latency {
|
||||
margin-left: auto;
|
||||
color: var(--dc-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
title="分类体系在首次启动时由 AI 根据样本文章生成,后续可通过编辑数据库调整。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div style="margin-top: 16px;">
|
||||
<div style="margin-top: 16px; display: flex; gap: 10px;">
|
||||
<el-button type="primary" @click="bootstrap(false)" :loading="bootstrapping">
|
||||
检查/初始化分类体系
|
||||
</el-button>
|
||||
@@ -18,23 +19,25 @@
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-tabs v-model="activeTab" class="dark-tabs">
|
||||
<el-tab-pane label="分类" name="category">
|
||||
<TaxonomyTable :data="taxonomyByKind.category" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="标签" name="tag">
|
||||
<TaxonomyTable :data="taxonomyByKind.tag" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="热度规则" name="heat_rule">
|
||||
<TaxonomyTable :data="taxonomyByKind.heat_rule" show-weight />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="重要性规则" name="importance_rule">
|
||||
<TaxonomyTable :data="taxonomyByKind.importance_rule" show-weight />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="重复性规则" name="duplication_rule">
|
||||
<TaxonomyTable :data="taxonomyByKind.duplication_rule" show-weight />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="taxonomy-tabs-wrap">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="分类" name="category">
|
||||
<TaxonomyTable :data="taxonomyByKind.category" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="标签" name="tag">
|
||||
<TaxonomyTable :data="taxonomyByKind.tag" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="热度规则" name="heat_rule">
|
||||
<TaxonomyTable :data="taxonomyByKind.heat_rule" show-weight />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="重要性规则" name="importance_rule">
|
||||
<TaxonomyTable :data="taxonomyByKind.importance_rule" show-weight />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="重复性规则" name="duplication_rule">
|
||||
<TaxonomyTable :data="taxonomyByKind.duplication_rule" show-weight />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -89,22 +92,10 @@ onMounted(loadTaxonomy)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dark-tabs {
|
||||
background: var(--dc-card-bg);
|
||||
.taxonomy-tabs-wrap {
|
||||
background: var(--dc-surface);
|
||||
border: 1px solid var(--dc-border);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--dc-radius);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dark-tabs :deep(.el-tabs__item) {
|
||||
color: var(--dc-text-secondary);
|
||||
}
|
||||
|
||||
.dark-tabs :deep(.el-tabs__item.is-active) {
|
||||
color: var(--dc-primary);
|
||||
}
|
||||
|
||||
.dark-tabs :deep(.el-tabs__active-bar) {
|
||||
background-color: var(--dc-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user