feat: 修复代码审核报告问题

This commit is contained in:
congsh
2026-06-12 16:04:03 +08:00
commit bae47a2411
46 changed files with 6231 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1628
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "dataclean-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"element-plus": "^2.6.3",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.6.8"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.2.0"
}
}
+125
View File
@@ -0,0 +1,125 @@
<template>
<el-container class="layout-container">
<el-aside width="220px">
<div class="logo">
<el-icon size="28"><DataLine /></el-icon>
<span>dataClean</span>
</div>
<el-menu
:default-active="$route.path"
router
background-color="transparent"
text-color="#a0a0a0"
active-text-color="#409eff"
>
<el-menu-item index="/dashboard">
<el-icon><Odometer /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/articles">
<el-icon><Document /></el-icon>
<span>文章列表</span>
</el-menu-item>
<el-menu-item index="/briefs">
<el-icon><Collection /></el-icon>
<span>每日简报</span>
</el-menu-item>
<el-menu-item index="/taxonomy">
<el-icon><CollectionTag /></el-icon>
<span>分类体系</span>
</el-menu-item>
<el-menu-item index="/tasks">
<el-icon><Timer /></el-icon>
<span>任务管理</span>
</el-menu-item>
<el-menu-item index="/settings">
<el-icon><Setting /></el-icon>
<span>系统配置</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="top-header" height="60px">
<div class="header-right">
<el-input
v-model="apiTokenInput"
placeholder="API Token(未设置可留空)"
size="small"
show-password
style="width: 260px;"
@keyup.enter="saveToken"
/>
<el-button size="small" type="primary" @click="saveToken">
{{ hasToken ? '更新 Token' : '设置 Token' }}
</el-button>
</div>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { DataLine, Odometer, Document, Collection, CollectionTag, Timer, Setting } from '@element-plus/icons-vue'
import { getApiToken, setApiToken } from '@/api'
const apiTokenInput = ref('')
const hasToken = ref(false)
onMounted(() => {
apiTokenInput.value = getApiToken()
hasToken.value = !!apiTokenInput.value
})
const saveToken = () => {
setApiToken(apiTokenInput.value.trim())
hasToken.value = !!apiTokenInput.value.trim()
ElMessage.success('API Token 已保存')
}
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.logo {
height: 60px;
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);
}
.top-header {
display: flex;
align-items: center;
justify-content: flex-end;
border-bottom: 1px solid var(--dc-border);
background-color: var(--dc-card-bg);
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
}
.el-menu-item {
height: 50px;
line-height: 50px;
}
.el-menu-item .el-icon {
margin-right: 8px;
}
</style>
+74
View File
@@ -0,0 +1,74 @@
import axios from 'axios'
const API_TOKEN_KEY = 'dataclean_api_token'
const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
export function getApiToken() {
return localStorage.getItem(API_TOKEN_KEY) || ''
}
export function setApiToken(token) {
if (token) {
localStorage.setItem(API_TOKEN_KEY, token)
} else {
localStorage.removeItem(API_TOKEN_KEY)
}
}
api.interceptors.request.use((config) => {
const token = getApiToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(response) => response.data,
(error) => {
const status = error.response?.status
const detail = error.response?.data?.detail || error.message || '请求失败'
if (status === 401 || status === 403) {
return Promise.reject(new Error(`${detail},请检查 API Token 是否设置正确`))
}
return Promise.reject(new Error(detail))
}
)
export default api
export const datacleanApi = {
// 健康检查
health: () => axios.get('/health').then((r) => r.data),
// 仪表盘
getStats: () => api.get('/stats'),
// 文章
getArticles: (params) => api.get('/articles', { params }),
getArticle: (id) => api.get(`/articles/${id}`),
// 简报
getBriefs: (params) => api.get('/briefs', { params }),
getBrief: (date) => api.get(`/briefs/${date}`),
regenerateBrief: (date) => api.post(`/briefs/${date}/regenerate`),
// 分类体系
getTaxonomy: (kind) => api.get('/taxonomy', { params: kind ? { kind } : {} }),
bootstrapTaxonomy: (force = false) => api.post(`/taxonomy/bootstrap?force=${force}`),
// 任务
summarize: () => api.post('/tasks/summarize'),
tagScoreDedup: () => api.post('/tasks/tag-score-dedup'),
generateBrief: () => api.post('/tasks/brief'),
// 配置
getSettings: () => api.get('/settings'),
updateSetting: (key, value) => api.put(`/settings/${key}`, { value }),
updateSettingsBatch: (settings) => api.put('/settings', { settings }),
resetSettings: () => api.post('/settings/reset'),
}
+20
View File
@@ -0,0 +1,20 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue'
import router from './router'
import './style.css'
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')
+28
View File
@@ -0,0 +1,28 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '@/views/Dashboard.vue'
import Articles from '@/views/Articles.vue'
import ArticleDetail from '@/views/ArticleDetail.vue'
import Briefs from '@/views/Briefs.vue'
import BriefDetail from '@/views/BriefDetail.vue'
import Taxonomy from '@/views/Taxonomy.vue'
import Tasks from '@/views/Tasks.vue'
import Settings from '@/views/Settings.vue'
const routes = [
{ path: '/', redirect: '/dashboard' },
{ path: '/dashboard', name: 'Dashboard', component: Dashboard },
{ path: '/articles', name: 'Articles', component: Articles },
{ path: '/articles/:id', name: 'ArticleDetail', component: ArticleDetail, props: true },
{ path: '/briefs', name: 'Briefs', component: Briefs },
{ path: '/briefs/:date', name: 'BriefDetail', component: BriefDetail, props: true },
{ path: '/taxonomy', name: 'Taxonomy', component: Taxonomy },
{ path: '/tasks', name: 'Tasks', component: Tasks },
{ path: '/settings', name: 'Settings', component: Settings },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
+164
View File
@@ -0,0 +1,164 @@
: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;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
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;
}
.dark-card {
background: var(--dc-card-bg) !important;
border: 1px solid var(--dc-border) !important;
color: var(--dc-text) !important;
}
.dark-card .el-card__header {
border-bottom: 1px solid var(--dc-border) !important;
color: var(--dc-text) !important;
}
.daily-bar-wrap {
display: flex;
align-items: flex-end;
gap: 8px;
height: 120px;
padding: 10px 0;
}
.daily-bar {
flex: 1;
background: linear-gradient(to top, var(--dc-primary), #66b1ff);
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;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: var(--dc-text-secondary);
white-space: nowrap;
}
.daily-bar-value {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: var(--dc-text);
}
.score-progress {
margin-top: 8px;
}
.score-progress .el-progress-bar__outer {
background-color: rgba(255, 255, 255, 0.1) !important;
}
.article-link {
color: var(--dc-primary);
text-decoration: none;
}
.article-link:hover {
text-decoration: underline;
}
.tag-item {
margin-right: 6px;
margin-bottom: 4px;
}
/* Element Plus 暗色覆盖 */
.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-container {
background-color: var(--dc-bg) !important;
}
.el-main {
background-color: var(--dc-bg) !important;
}
.el-table {
background-color: transparent !important;
}
.el-table th,
.el-table tr {
background-color: transparent !important;
}
.el-table--enable-row-hover .el-table__body tr:hover > td {
background-color: rgba(64, 158, 255, 0.1) !important;
}
.el-input__wrapper,
.el-textarea__inner {
background-color: rgba(255, 255, 255, 0.05) !important;
}
+163
View File
@@ -0,0 +1,163 @@
<template>
<div v-loading="loading">
<el-page-header @back="$router.push('/articles')" title="文章详情" />
<el-card v-if="article" class="dark-card" style="margin-top: 20px;">
<template #header>
<div class="article-header">
<h2>{{ article.title }}</h2>
<div class="article-meta">
<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>
</div>
</div>
</template>
<div class="article-section">
<h3>AI 摘要</h3>
<p v-if="article.ai_summary" class="ai-summary">{{ article.ai_summary }}</p>
<p v-else class="no-data">暂无 AI 摘要</p>
</div>
<div class="article-section">
<h3>标签与分类</h3>
<div>
<span class="section-label">分类</span>
<el-tag type="primary" size="large">{{ article.category }}</el-tag>
</div>
<div style="margin-top: 10px;">
<span class="section-label">标签</span>
<el-tag v-for="tag in article.tags" :key="tag" class="tag-item" type="info">{{ tag }}</el-tag>
</div>
</div>
<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>
<div class="article-section" v-if="article.link">
<h3>原文链接</h3>
<a :href="article.link" target="_blank" class="article-link">{{ article.link }}</a>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { datacleanApi } from '@/api'
import { OfficeBuilding, User, Timer } from '@element-plus/icons-vue'
const props = defineProps({
id: {
type: String,
required: true,
},
})
const loading = ref(false)
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' },
]
})
const loadArticle = async () => {
loading.value = true
try {
article.value = await datacleanApi.getArticle(props.id)
} catch (err) {
ElMessage.error(err.message)
} finally {
loading.value = false
}
}
onMounted(loadArticle)
</script>
<style scoped>
.article-header h2 {
margin-bottom: 12px;
color: var(--dc-text);
}
.article-meta {
display: flex;
gap: 20px;
align-items: center;
color: var(--dc-text-secondary);
font-size: 14px;
}
.article-meta .el-icon {
margin-right: 4px;
vertical-align: middle;
}
.article-section {
margin-bottom: 24px;
}
.article-section h3 {
font-size: 16px;
margin-bottom: 12px;
color: var(--dc-text);
border-left: 4px solid var(--dc-primary);
padding-left: 10px;
}
.ai-summary {
line-height: 1.8;
color: var(--dc-text);
background: rgba(64, 158, 255, 0.1);
padding: 16px;
border-radius: 8px;
}
.no-data {
color: var(--dc-text-secondary);
}
.section-label {
color: var(--dc-text-secondary);
margin-right: 8px;
}
.score-item {
background: rgba(255, 255, 255, 0.03);
padding: 16px;
border-radius: 8px;
text-align: center;
}
.score-label {
color: var(--dc-text-secondary);
font-size: 14px;
}
.score-value {
font-size: 24px;
font-weight: 700;
margin: 8px 0;
color: var(--dc-text);
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<template>
<div>
<h1 class="page-title">文章列表</h1>
<el-card class="dark-card" style="margin-bottom: 20px;">
<el-form :inline="true" :model="filters">
<el-form-item label="日期">
<el-date-picker
v-model="filters.date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
clearable
/>
</el-form-item>
<el-form-item label="分类">
<el-input v-model="filters.category" placeholder="分类" clearable />
</el-form-item>
<el-form-item label="标签">
<el-input v-model="filters.tag" placeholder="标签" clearable />
</el-form-item>
<el-form-item>
<el-checkbox v-model="filters.representative_only" label="仅看代表文章" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadArticles">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="dark-card">
<el-table :data="articles" v-loading="loading" style="width: 100%">
<el-table-column label="标题" min-width="280">
<template #default="{ row }">
<el-link @click="$router.push(`/articles/${row.id}`)" type="primary">{{ row.title }}</el-link>
</template>
</el-table-column>
<el-table-column prop="feed_title" label="来源" width="160" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column label="标签" min-width="180">
<template #default="{ row }">
<el-tag v-for="tag in row.tags" :key="tag" size="small" class="tag-item">{{ tag }}</el-tag>
</template>
</el-table-column>
<el-table-column label="热度" width="120">
<template #default="{ row }">
<el-progress :percentage="Math.round(row.heat_score)" :color="scoreColor" />
</template>
</el-table-column>
<el-table-column label="重要性" width="120">
<template #default="{ row }">
<el-progress :percentage="Math.round(row.importance_score)" :color="scoreColor" />
</template>
</el-table-column>
<el-table-column label="综合分" width="100">
<template #default="{ row }">
<el-tag :type="row.composite_score >= 60 ? 'danger' : row.composite_score >= 40 ? 'warning' : 'info'">
{{ row.composite_score.toFixed(1) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="published_at" label="发布时间" width="180" />
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
layout="total, prev, pager, next"
style="margin-top: 20px; justify-content: flex-end;"
@change="loadArticles"
/>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { datacleanApi } from '@/api'
const loading = ref(false)
const articles = ref([])
const filters = reactive({
date: '',
category: '',
tag: '',
representative_only: false,
})
const pagination = reactive({
page: 1,
size: 20,
total: 0,
})
const scoreColor = '#409eff'
const loadArticles = async () => {
loading.value = true
try {
const params = {
limit: pagination.size,
offset: (pagination.page - 1) * pagination.size,
...filters,
}
const res = await datacleanApi.getArticles(params)
articles.value = res.items || []
pagination.total = res.total || 0
} catch (err) {
ElMessage.error(err.message)
} finally {
loading.value = false
}
}
onMounted(loadArticles)
</script>
+121
View File
@@ -0,0 +1,121 @@
<template>
<div v-loading="loading">
<el-page-header @back="$router.push('/briefs')" title="简报详情" />
<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>
</div>
</div>
</template>
<el-collapse v-model="activeCategories">
<el-collapse-item
v-for="(articles, category) in brief.by_category"
:key="category"
:title="`${category} (${articles.length})`"
:name="category"
>
<div
v-for="article in articles"
:key="article.id"
class="brief-article"
>
<div class="brief-article-title">
<a :href="article.link" target="_blank" class="article-link">{{ article.title }}</a>
<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 size="small" type="warning">综合 {{ article.composite_score.toFixed(1) }}</el-tag>
</div>
<p v-if="article.summary" class="brief-article-summary">{{ article.summary }}</p>
</div>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { datacleanApi } from '@/api'
const props = defineProps({
date: {
type: String,
required: true,
},
})
const loading = ref(false)
const brief = ref(null)
const activeCategories = ref([])
const loadBrief = async () => {
loading.value = true
try {
brief.value = await datacleanApi.getBrief(props.date)
activeCategories.value = Object.keys(brief.value.by_category || {})
} catch (err) {
ElMessage.error(err.message)
} finally {
loading.value = false
}
}
onMounted(loadBrief)
</script>
<style scoped>
.brief-header h2 {
margin-bottom: 10px;
color: var(--dc-text);
}
.brief-meta {
display: flex;
gap: 10px;
}
.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;
}
.brief-article-title a {
font-size: 16px;
font-weight: 500;
}
.brief-article-feed {
color: var(--dc-text-secondary);
font-size: 13px;
}
.brief-article-tags {
margin-bottom: 8px;
}
.brief-article-summary {
color: var(--dc-text-secondary);
font-size: 14px;
line-height: 1.6;
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<template>
<div>
<h1 class="page-title">每日简报</h1>
<el-card class="dark-card">
<el-table :data="briefs" v-loading="loading">
<el-table-column prop="brief_date" label="日期" width="150" />
<el-table-column prop="total_articles" label="原始文章数" width="130" />
<el-table-column prop="unique_articles" label="去重后文章数" width="140" />
<el-table-column label="分类数">
<template #default="{ row }">
{{ Object.keys(row.by_category || {}).length }}
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="$router.push(`/briefs/${row.brief_date}`)">查看</el-button>
<el-button size="small" type="primary" @click="regenerate(row.brief_date)">重新生成</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { datacleanApi } from '@/api'
const loading = ref(false)
const briefs = ref([])
const loadBriefs = async () => {
loading.value = true
try {
briefs.value = await datacleanApi.getBriefs({ limit: 50 })
} catch (err) {
ElMessage.error(err.message)
} finally {
loading.value = false
}
}
const regenerate = async (date) => {
try {
await datacleanApi.regenerateBrief(date)
ElMessage.success('简报重新生成成功')
loadBriefs()
} catch (err) {
ElMessage.error(err.message)
}
}
onMounted(loadBriefs)
</script>
+152
View File
@@ -0,0 +1,152 @@
<template>
<div>
<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>
<!-- 分类分布 + 最近简报 -->
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="16">
<el-card class="dark-card">
<template #header>
<span>分类分布</span>
</template>
<div v-if="categoryDistribution.length" class="daily-bar-wrap">
<div
v-for="item in categoryDistribution"
:key="item.category"
class="daily-bar"
:style="{ height: item.percentage + '%' }"
:title="`${item.category}: ${item.count}`"
>
<span class="daily-bar-value">{{ item.count }}</span>
<span class="daily-bar-label">{{ item.category }}</span>
</div>
</div>
<el-empty v-else description="暂无数据" />
</el-card>
</el-col>
<el-col :span="8">
<el-card class="dark-card">
<template #header>
<span>最近简报</span>
</template>
<el-timeline v-if="recentBriefs.length">
<el-timeline-item
v-for="brief in recentBriefs"
: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>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无简报" />
</el-card>
</el-col>
</el-row>
<!-- 任务状态 -->
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="24">
<el-card class="dark-card">
<template #header>
<span>定时任务状态</span>
</template>
<el-table :data="jobList" style="width: 100%">
<el-table-column prop="id" label="任务" />
<el-table-column prop="next_run" label="下次执行时间" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { datacleanApi } from '@/api'
const statsData = ref({
total_articles: 0,
today_articles: 0,
ai_summarized: 0,
categories: 0,
tags: 0,
duplicate_groups: 0,
briefs: 0,
next_jobs: {},
})
const recentBriefs = ref([])
const categoryDistribution = ref([])
const stats = computed(() => [
{ label: '总加工文章', value: statsData.value.total_articles },
{ label: '今日文章', value: statsData.value.today_articles },
{ label: 'AI 摘要覆盖', value: statsData.value.ai_summarized },
{ label: '分类数', value: statsData.value.categories },
{ label: '标签数', value: statsData.value.tags },
{ label: '去重组数', value: statsData.value.duplicate_groups },
{ label: '已生成简报', value: statsData.value.briefs },
])
const jobList = computed(() => {
return Object.entries(statsData.value.next_jobs || {}).map(([id, next_run]) => ({
id,
next_run: next_run || '未知',
}))
})
const loadData = async () => {
try {
const [statsRes, briefsRes, taxonomyRes] = await Promise.all([
datacleanApi.getStats(),
datacleanApi.getBriefs({ limit: 5 }),
datacleanApi.getTaxonomy(),
])
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 || {}
Object.entries(byCategory).forEach(([cat, articles]) => {
catMap[cat] = articles.length
})
}
const maxCount = Math.max(...Object.values(catMap), 1)
categoryDistribution.value = Object.entries(catMap)
.map(([category, count]) => ({
category,
count,
percentage: (count / maxCount) * 100,
}))
.filter((item) => item.count > 0)
} catch (err) {
ElMessage.error(err.message)
}
}
onMounted(loadData)
</script>
+103
View File
@@ -0,0 +1,103 @@
<template>
<div>
<h1 class="page-title">系统配置</h1>
<el-card class="dark-card">
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>配置项</span>
<el-button type="danger" @click="resetSettings">重置为默认值</el-button>
</div>
</template>
<el-alert
title="配置修改后会保存到 SQLite 数据库,重启服务后生效。"
type="warning"
:closable="false"
style="margin-bottom: 20px;"
/>
<el-form :model="settings" label-position="top" v-loading="loading">
<el-row :gutter="20">
<el-col :span="12" v-for="item in settings" :key="item.key">
<el-form-item :label="`${item.description} (${item.key})`">
<el-input
v-if="item.is_sensitive"
v-model="item.value"
type="password"
show-password
placeholder="请输入"
/>
<el-input
v-else
v-model="item.value"
placeholder="请输入"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item>
<el-button type="primary" size="large" :loading="saving" @click="saveSettings">保存配置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { datacleanApi } from '@/api'
const settings = ref([])
const loading = ref(false)
const saving = ref(false)
const loadSettings = async () => {
loading.value = true
try {
const res = await datacleanApi.getSettings()
settings.value = res
} catch (err) {
ElMessage.error(err.message)
} finally {
loading.value = false
}
}
const saveSettings = async () => {
saving.value = true
try {
const payload = {}
for (const item of settings.value) {
payload[item.key] = item.value
}
await datacleanApi.updateSettingsBatch(payload)
ElMessage.success('配置已保存,请重启服务后生效')
} catch (err) {
ElMessage.error(err.message)
} finally {
saving.value = false
}
}
const resetSettings = async () => {
try {
await ElMessageBox.confirm('确定要重置所有配置为环境变量默认值吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await datacleanApi.resetSettings()
ElMessage.success('配置已重置,请重启服务后生效')
loadSettings()
} catch (err) {
if (err !== 'cancel') {
ElMessage.error(err.message)
}
}
}
onMounted(loadSettings)
</script>
+116
View File
@@ -0,0 +1,116 @@
<template>
<div>
<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;">
<template #header>
<div class="task-header">
<el-icon size="24"><component :is="task.icon" /></el-icon>
<span>{{ task.title }}</span>
</div>
</template>
<p class="task-desc">{{ task.description }}</p>
<div v-if="task.nextRun" class="task-next-run">
下次执行{{ task.nextRun }}
</div>
<el-button
type="primary"
style="margin-top: 16px;"
:loading="task.loading"
@click="runTask(task)"
>
立即执行
</el-button>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Document, CollectionTag, Collection } from '@element-plus/icons-vue'
import { datacleanApi } from '@/api'
const tasks = ref([
{
id: 'summarize',
title: '生成 AI 摘要',
description: '拉取 rssKeeper 最近文章,为无摘要或短摘要文章生成 AI 摘要。',
icon: 'Document',
nextRun: '',
loading: false,
action: datacleanApi.summarize,
},
{
id: 'tag_score_deduplicate',
title: '分类 / 打分 / 去重',
description: '对当天文章进行分类、打标签、计算分数并生成重复组。',
icon: 'CollectionTag',
nextRun: '',
loading: false,
action: datacleanApi.tagScoreDedup,
},
{
id: 'generate_daily_brief',
title: '生成每日简报',
description: '基于当天去重后的代表文章生成每日简报。',
icon: 'Collection',
nextRun: '',
loading: false,
action: datacleanApi.generateBrief,
},
])
const loadStats = async () => {
try {
const stats = await datacleanApi.getStats()
const nextJobs = stats.next_jobs || {}
tasks.value.forEach((task) => {
task.nextRun = nextJobs[task.id] || '未调度'
})
} catch (err) {
ElMessage.error(err.message)
}
}
const runTask = async (task) => {
task.loading = true
try {
const res = await task.action()
ElMessage.success(res.message)
loadStats()
} catch (err) {
ElMessage.error(err.message)
} finally {
task.loading = false
}
}
onMounted(loadStats)
</script>
<style scoped>
.task-header {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
}
.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);
font-size: 13px;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<div>
<h1 class="page-title">分类体系</h1>
<el-card class="dark-card" style="margin-bottom: 20px;">
<el-alert
title="分类体系在首次启动时由 AI 根据样本文章生成,后续可通过编辑数据库调整。"
type="info"
:closable="false"
/>
<div style="margin-top: 16px;">
<el-button type="primary" @click="bootstrap(false)" :loading="bootstrapping">
检查/初始化分类体系
</el-button>
<el-button type="danger" @click="bootstrap(true)" :loading="bootstrapping">
强制重新生成
</el-button>
</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>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { datacleanApi } from '@/api'
import TaxonomyTable from './TaxonomyTable.vue'
const activeTab = ref('category')
const taxonomy = ref([])
const bootstrapping = ref(false)
const taxonomyByKind = computed(() => {
const grouped = {
category: [],
tag: [],
heat_rule: [],
importance_rule: [],
duplication_rule: [],
}
taxonomy.value.forEach((item) => {
if (grouped[item.kind]) {
grouped[item.kind].push(item)
}
})
return grouped
})
const loadTaxonomy = async () => {
try {
taxonomy.value = await datacleanApi.getTaxonomy()
} catch (err) {
ElMessage.error(err.message)
}
}
const bootstrap = async (force) => {
bootstrapping.value = true
try {
const res = await datacleanApi.bootstrapTaxonomy(force)
ElMessage.success(res.message)
loadTaxonomy()
} catch (err) {
ElMessage.error(err.message)
} finally {
bootstrapping.value = false
}
}
onMounted(loadTaxonomy)
</script>
<style scoped>
.dark-tabs {
background: var(--dc-card-bg);
border: 1px solid var(--dc-border);
border-radius: 8px;
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>
+30
View File
@@ -0,0 +1,30 @@
<template>
<el-table :data="data" style="width: 100%">
<el-table-column prop="name" label="名称" width="160" />
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column label="关键词" min-width="250">
<template #default="{ row }">
<el-tag v-for="kw in row.keywords" :key="kw" size="small" class="tag-item" type="info">{{ kw }}</el-tag>
</template>
</el-table-column>
<el-table-column v-if="showWeight" prop="weight" label="权重" width="100" />
<el-table-column label="来源" width="120">
<template #default="{ row }">
<el-tag :type="row.created_by_ai ? 'success' : 'info'">{{ row.created_by_ai ? 'AI 生成' : '手动' }}</el-tag>
</template>
</el-table-column>
</el-table>
</template>
<script setup>
defineProps({
data: {
type: Array,
default: () => [],
},
showWeight: {
type: Boolean,
default: false,
},
})
</script>
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 7332,
proxy: {
'/api': {
target: 'http://localhost:7331',
changeOrigin: true,
},
'/health': {
target: 'http://localhost:7331',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
assetsDir: 'assets',
},
})