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:
congsh
2026-06-14 15:14:40 +08:00
parent bae47a2411
commit 778ccefb22
24 changed files with 1853 additions and 312 deletions
+4 -1
View File
@@ -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
View File
@@ -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;
}
+5
View File
@@ -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
View File
@@ -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);
}
+53 -38
View File
@@ -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>
+27 -19
View File
@@ -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>
+41 -17
View File
@@ -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
View File
@@ -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>
+24 -33
View File
@@ -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>