feat: 修复代码审核报告问题
This commit is contained in:
@@ -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>
|
||||
Generated
+1628
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'),
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user