Initial commit: RSS platform phase 1 skeleton with code review fixes
Features: - FastAPI + SQLAlchemy 2.0 async + PostgreSQL/pgvector + Redis backend - Vue 3 + TypeScript + Element Plus frontend - JWT auth with access/refresh tokens and revocation - Admin/member RBAC - RSS feed CRUD and article listing - Settings management with Fernet encryption for sensitive values - Redis distributed lock service - Alembic initial migration - Docker Compose development environment Fixes from code review: - Fix DB session leak in dependency injection - Restrict registration to admin only - Add default admin password warning - Implement JWT refresh tokens and jti blacklist - Strengthen password policy - Use func.count for pagination totals - Replace NullPool with AsyncAdaptedQueuePool - Remove init_db from lifespan to enforce alembic migrations - Add request_id middleware and logging filter - Fix vite.config.ts env loading - Add frontend token refresh interceptor - Add Vue error handler Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>RSS Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "rss-platform-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.ts,.tsx --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0",
|
||||
"element-plus": "^2.7.0",
|
||||
"axios": "^1.7.0",
|
||||
"@element-plus/icons-vue": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0",
|
||||
"vue-tsc": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import api from './index'
|
||||
import type { Article, PaginatedResponse } from '@/types'
|
||||
|
||||
export interface ArticleListParams {
|
||||
skip?: number
|
||||
limit?: number
|
||||
feed_id?: string
|
||||
category?: string
|
||||
tag?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
export const articlesApi = {
|
||||
list: (params: ArticleListParams = {}): Promise<PaginatedResponse<Article>> =>
|
||||
api.get('/articles', { params }),
|
||||
|
||||
get: (id: string): Promise<Article> =>
|
||||
api.get(`/articles/${id}`),
|
||||
|
||||
markRead: (id: string): Promise<{ message: string }> =>
|
||||
api.put(`/articles/${id}/read`),
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import api from './index'
|
||||
import axios from 'axios'
|
||||
import type { LoginCredentials, TokenResponse, User } from '@/types'
|
||||
|
||||
export const authApi = {
|
||||
login: (credentials: LoginCredentials): Promise<TokenResponse> =>
|
||||
api.post('/auth/login', credentials),
|
||||
|
||||
register: (data: { username: string; password: string; role?: string }): Promise<User> =>
|
||||
api.post('/auth/register', data),
|
||||
|
||||
getMe: (): Promise<User> =>
|
||||
api.get('/auth/me'),
|
||||
|
||||
refresh: (refreshToken: string): Promise<TokenResponse> =>
|
||||
axios.post(
|
||||
`${import.meta.env.VITE_API_BASE_URL || '/api/v1'}/auth/refresh`,
|
||||
{ refresh_token: refreshToken }
|
||||
).then((res) => res.data),
|
||||
|
||||
logout: (refreshToken: string): Promise<void> =>
|
||||
axios.post(
|
||||
`${import.meta.env.VITE_API_BASE_URL || '/api/v1'}/auth/logout`,
|
||||
{ refresh_token: refreshToken }
|
||||
).then(() => undefined),
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import api from './index'
|
||||
import type {
|
||||
Feed,
|
||||
FeedCreateRequest,
|
||||
FeedUpdateRequest,
|
||||
PaginatedResponse,
|
||||
} from '@/types'
|
||||
|
||||
export interface FeedListParams {
|
||||
skip?: number
|
||||
limit?: number
|
||||
category?: string
|
||||
search?: string
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export const feedsApi = {
|
||||
list: (params: FeedListParams = {}): Promise<PaginatedResponse<Feed>> =>
|
||||
api.get('/feeds', { params }),
|
||||
|
||||
get: (id: string): Promise<Feed> =>
|
||||
api.get(`/feeds/${id}`),
|
||||
|
||||
create: (data: FeedCreateRequest): Promise<Feed> =>
|
||||
api.post('/feeds', data),
|
||||
|
||||
update: (id: string, data: FeedUpdateRequest): Promise<Feed> =>
|
||||
api.put(`/feeds/${id}`, data),
|
||||
|
||||
delete: (id: string): Promise<{ message: string }> =>
|
||||
api.delete(`/feeds/${id}`),
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api/v1',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
let isRefreshing = false
|
||||
let refreshSubscribers: Array<(token: string) => void> = []
|
||||
|
||||
function onRefreshed(token: string) {
|
||||
refreshSubscribers.forEach((callback) => callback(token))
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
function addRefreshSubscriber(callback: (token: string) => void) {
|
||||
refreshSubscribers.push(callback)
|
||||
}
|
||||
|
||||
function rejectRefreshSubscribers() {
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse) => response.data,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
const status = error.response?.status
|
||||
const detail = (error.response?.data as any)?.detail || error.message
|
||||
|
||||
if (status === 401 && originalRequest && !originalRequest._retry) {
|
||||
const authStore = useAuthStore()
|
||||
const refreshToken = authStore.refreshToken
|
||||
|
||||
if (!refreshToken) {
|
||||
authStore.logout()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(new Error(detail))
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
addRefreshSubscriber((newToken: string) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
resolve(api(originalRequest))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const refreshed = await authStore.refreshAccessToken()
|
||||
if (!refreshed || !authStore.token) {
|
||||
throw new Error('Refresh failed')
|
||||
}
|
||||
onRefreshed(authStore.token)
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
return api(originalRequest)
|
||||
} catch (refreshError) {
|
||||
rejectRefreshSubscribers()
|
||||
authStore.logout()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(detail))
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<el-aside width="220px" class="sidebar">
|
||||
<div class="logo">
|
||||
<el-icon size="28"><DataAnalysis /></el-icon>
|
||||
<span>RSS Platform</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
router
|
||||
class="sidebar-menu"
|
||||
background-color="#1a1a2e"
|
||||
text-color="#e2e8f0"
|
||||
active-text-color="#409eff"
|
||||
>
|
||||
<el-menu-item index="/dashboard">
|
||||
<el-icon><Odometer /></el-icon>
|
||||
<span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/feeds">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>RSS 源</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/articles">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>文章</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="header-right">
|
||||
<span class="username">{{ authStore.user?.username }}</span>
|
||||
<el-tag :type="authStore.isAdmin ? 'danger' : 'info'" size="small">
|
||||
{{ authStore.isAdmin ? '管理员' : '成员' }}
|
||||
</el-tag>
|
||||
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="main-content">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
ElMessage.success('已退出登录')
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: #1a1a2e;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background-color: #f5f7fa;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.config.errorHandler = (err, _instance, info) => {
|
||||
console.error('Unhandled Vue error:', err, info)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/components/Layout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/DashboardView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'feeds',
|
||||
name: 'Feeds',
|
||||
component: () => import('@/views/FeedsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'articles',
|
||||
name: 'Articles',
|
||||
component: () => import('@/views/ArticlesView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.public) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
const hasToken = !!localStorage.getItem('token')
|
||||
if (hasToken) {
|
||||
const ok = await authStore.fetchUser()
|
||||
if (ok) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return '/login'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,86 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { authApi } from '@/api/auth'
|
||||
import type { User } from '@/types'
|
||||
|
||||
const TOKEN_KEY = 'token'
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
|
||||
const refreshToken = ref<string | null>(localStorage.getItem(REFRESH_TOKEN_KEY))
|
||||
const user = ref<User | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await authApi.login({ username, password })
|
||||
setTokens(response.access_token, response.refresh_token)
|
||||
await fetchUser()
|
||||
return true
|
||||
} catch (error) {
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUser() {
|
||||
try {
|
||||
const response = await authApi.getMe()
|
||||
user.value = response
|
||||
return true
|
||||
} catch (error) {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const currentRefresh = refreshToken.value
|
||||
if (!currentRefresh) {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const response = await authApi.refresh(currentRefresh)
|
||||
setTokens(response.access_token, response.refresh_token)
|
||||
return true
|
||||
} catch (error) {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setTokens(access: string, refresh: string) {
|
||||
token.value = access
|
||||
refreshToken.value = refresh
|
||||
localStorage.setItem(TOKEN_KEY, access)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refresh)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
refreshToken.value = null
|
||||
user.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
user,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
login,
|
||||
fetchUser,
|
||||
refreshAccessToken,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
role: 'admin' | 'member'
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface Feed {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
is_active: boolean
|
||||
fetch_interval_minutes: number
|
||||
priority: number
|
||||
parser_config: Record<string, any>
|
||||
proxy_policy: string
|
||||
last_fetch_at: string | null
|
||||
last_fetch_status: string | null
|
||||
last_error: string | null
|
||||
error_type: string | null
|
||||
success_count: number
|
||||
fail_count: number
|
||||
article_count: number
|
||||
health_status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
id: string
|
||||
raw_article_id: string | null
|
||||
feed_id: string
|
||||
title: string | null
|
||||
link: string
|
||||
author: string | null
|
||||
feed_title: string | null
|
||||
feed_category: string | null
|
||||
published_at: string | null
|
||||
fetched_at: string
|
||||
content: string | null
|
||||
original_summary: string | null
|
||||
ai_summary: string | null
|
||||
category: string | null
|
||||
tags: string[]
|
||||
heat_score: number
|
||||
importance_score: number
|
||||
duplication_score: number
|
||||
composite_score: number
|
||||
is_representative: boolean
|
||||
reference_links: any[]
|
||||
processing_status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
total: number
|
||||
items: T[]
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export interface FeedCreateRequest {
|
||||
url: string
|
||||
title?: string
|
||||
description?: string
|
||||
category?: string
|
||||
is_active?: boolean
|
||||
fetch_interval_minutes?: number
|
||||
priority?: number
|
||||
parser_config?: Record<string, any>
|
||||
proxy_policy?: string
|
||||
}
|
||||
|
||||
export interface FeedUpdateRequest {
|
||||
title?: string
|
||||
description?: string
|
||||
category?: string
|
||||
is_active?: boolean
|
||||
fetch_interval_minutes?: number
|
||||
priority?: number
|
||||
parser_config?: Record<string, any>
|
||||
proxy_policy?: string
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="articles-view">
|
||||
<div class="page-header">
|
||||
<h1>文章列表</h1>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索标题/摘要"
|
||||
clearable
|
||||
style="width: 300px"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-model="categoryFilter"
|
||||
placeholder="分类"
|
||||
clearable
|
||||
style="width: 150px; margin-left: 12px"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-model="tagFilter"
|
||||
placeholder="Tag"
|
||||
clearable
|
||||
style="width: 150px; margin-left: 12px"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-table :data="articles" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="title" label="标题" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="feed_title" label="来源" width="150" />
|
||||
<el-table-column prop="category" label="分类" width="120" />
|
||||
<el-table-column label="Tags" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="tag in row.tags" :key="tag" size="small" style="margin-right: 4px">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="composite_score" label="综合分" width="100" sortable />
|
||||
<el-table-column label="发布时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.published_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="showDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="loadArticles"
|
||||
style="margin-top: 20px"
|
||||
/>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<el-dialog v-model="detailVisible" :title="currentArticle?.title || '文章详情'" width="800px">
|
||||
<div v-if="currentArticle">
|
||||
<p><strong>来源:</strong> {{ currentArticle.feed_title }}</p>
|
||||
<p><strong>分类:</strong> {{ currentArticle.category || '未分类' }}</p>
|
||||
<p><strong>Tags:</strong>
|
||||
<el-tag v-for="tag in currentArticle.tags" :key="tag" size="small" style="margin-right: 4px">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</p>
|
||||
<p><strong>综合分:</strong> {{ currentArticle.composite_score }}</p>
|
||||
<p><strong>原文链接:</strong>
|
||||
<a :href="currentArticle.link" target="_blank" rel="noopener">{{ currentArticle.link }}</a>
|
||||
</p>
|
||||
<hr />
|
||||
<p><strong>AI 摘要:</strong></p>
|
||||
<p>{{ currentArticle.ai_summary || '暂无摘要' }}</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { articlesApi } from '@/api/articles'
|
||||
import type { Article } from '@/types'
|
||||
|
||||
const loading = ref(false)
|
||||
const articles = ref<Article[]>([])
|
||||
const total = ref(0)
|
||||
const searchQuery = ref('')
|
||||
const categoryFilter = ref('')
|
||||
const tagFilter = ref('')
|
||||
const detailVisible = ref(false)
|
||||
const currentArticle = ref<Article | null>(null)
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const loadArticles = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await articlesApi.list({
|
||||
skip: (pagination.page - 1) * pagination.pageSize,
|
||||
limit: pagination.pageSize,
|
||||
search: searchQuery.value || undefined,
|
||||
category: categoryFilter.value || undefined,
|
||||
tag: tagFilter.value || undefined,
|
||||
})
|
||||
articles.value = res.items
|
||||
total.value = res.total
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadArticles, 300)
|
||||
}
|
||||
|
||||
const showDetail = (row: Article) => {
|
||||
currentArticle.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
onMounted(loadArticles)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.articles-view {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h1>仪表盘</h1>
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<template #header>RSS 源</template>
|
||||
<div class="stat-value">{{ stats.feeds }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<template #header>文章数</template>
|
||||
<div class="stat-value">{{ stats.articles }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<template #header>健康源</template>
|
||||
<div class="stat-value">{{ stats.healthyFeeds }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<template #header>异常源</template>
|
||||
<div class="stat-value">{{ stats.unhealthyFeeds }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive } from 'vue'
|
||||
import { feedsApi } from '@/api/feeds'
|
||||
|
||||
const stats = reactive({
|
||||
feeds: 0,
|
||||
articles: 0,
|
||||
healthyFeeds: 0,
|
||||
unhealthyFeeds: 0,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await feedsApi.list({ limit: 1000 })
|
||||
stats.feeds = res.total
|
||||
stats.healthyFeeds = res.items.filter((f) => f.health_status === 'healthy').length
|
||||
stats.unhealthyFeeds = res.items.filter((f) => f.health_status === 'unhealthy').length
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard stats', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard h1 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="feeds-view">
|
||||
<div class="page-header">
|
||||
<h1>RSS 源管理</h1>
|
||||
<el-button type="primary" @click="openAddDialog">添加 RSS 源</el-button>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索源名称/URL"
|
||||
clearable
|
||||
style="width: 300px"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filterStatus"
|
||||
placeholder="状态"
|
||||
clearable
|
||||
style="width: 120px; margin-left: 12px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="启用" :value="true" />
|
||||
<el-option label="禁用" :value="false" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-table :data="feeds" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="title" label="名称" min-width="200" />
|
||||
<el-table-column prop="url" label="URL" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="category" label="分类" width="120" />
|
||||
<el-table-column prop="fetch_interval_minutes" label="抓取间隔(分)" width="130" />
|
||||
<el-table-column label="健康度" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getHealthType(row.health_status)">
|
||||
{{ getHealthLabel(row.health_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@change="loadFeeds"
|
||||
style="margin-top: 20px"
|
||||
/>
|
||||
|
||||
<!-- Add/Edit Dialog -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑 RSS 源' : '添加 RSS 源'"
|
||||
width="600px"
|
||||
>
|
||||
<el-form :model="form" :rules="formRules" ref="formRef" label-width="120px">
|
||||
<el-form-item label="RSS URL" prop="url">
|
||||
<el-input v-model="form.url" placeholder="https://example.com/feed.xml" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="title">
|
||||
<el-input v-model="form.title" placeholder="源名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类" prop="category">
|
||||
<el-input v-model="form.category" placeholder="科技/新闻/..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="抓取间隔" prop="fetch_interval_minutes">
|
||||
<el-input-number v-model="form.fetch_interval_minutes" :min="15" />
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级" prop="priority">
|
||||
<el-input-number v-model="form.priority" :min="1" :max="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用" prop="is_active">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { feedsApi } from '@/api/feeds'
|
||||
import type { Feed, FeedCreateRequest } from '@/types'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref('')
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const feeds = ref<Feed[]>([])
|
||||
const total = ref(0)
|
||||
const searchQuery = ref('')
|
||||
const filterStatus = ref<boolean | ''>('')
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
|
||||
const form = reactive<FeedCreateRequest>({
|
||||
url: '',
|
||||
title: '',
|
||||
description: '',
|
||||
category: '',
|
||||
is_active: true,
|
||||
fetch_interval_minutes: 60,
|
||||
priority: 5,
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
url: [{ required: true, message: '请输入 RSS URL', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const loadFeeds = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await feedsApi.list({
|
||||
skip: (pagination.page - 1) * pagination.pageSize,
|
||||
limit: pagination.pageSize,
|
||||
search: searchQuery.value || undefined,
|
||||
is_active: filterStatus.value === '' ? undefined : filterStatus.value,
|
||||
})
|
||||
feeds.value = res.items
|
||||
total.value = res.total
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.page = 1
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(loadFeeds, 300)
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
isEdit.value = false
|
||||
currentId.value = ''
|
||||
form.url = ''
|
||||
form.title = ''
|
||||
form.description = ''
|
||||
form.category = ''
|
||||
form.is_active = true
|
||||
form.fetch_interval_minutes = 60
|
||||
form.priority = 5
|
||||
dialogVisible.value = true
|
||||
nextTick(() => formRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
const openEditDialog = (row: Feed) => {
|
||||
isEdit.value = true
|
||||
currentId.value = row.id
|
||||
form.url = row.url
|
||||
form.title = row.title
|
||||
form.description = row.description
|
||||
form.category = row.category
|
||||
form.is_active = row.is_active
|
||||
form.fetch_interval_minutes = row.fetch_interval_minutes
|
||||
form.priority = row.priority
|
||||
dialogVisible.value = true
|
||||
nextTick(() => formRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
|
||||
if (isEdit.value) {
|
||||
await feedsApi.update(currentId.value, { ...form })
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await feedsApi.create({ ...form })
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
loadFeeds()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: Feed) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除 RSS 源 "${row.title || row.url}" 吗?`, '确认删除', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
await feedsApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadFeeds()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getHealthType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
healthy: 'success',
|
||||
warning: 'warning',
|
||||
unhealthy: 'danger',
|
||||
unknown: 'info',
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const getHealthLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
healthy: '健康',
|
||||
warning: '警告',
|
||||
unhealthy: '异常',
|
||||
unknown: '未知',
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
onMounted(loadFeeds)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feeds-view {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<el-card class="login-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="login-header">
|
||||
<el-icon size="40" color="#409eff"><DataAnalysis /></el-icon>
|
||||
<h2>RSS Platform</h2>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
show-password
|
||||
:prefix-icon="Lock"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="authStore.loading"
|
||||
@click="handleLogin"
|
||||
style="width: 100%"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, message: '用户名至少3个字符', trigger: 'blur' },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码至少6个字符', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
await authStore.login(form.username, form.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '登录失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
margin: 12px 0 0;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiTarget = env.VITE_API_BASE_URL || 'http://localhost:8000'
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/health': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user