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:
congsh
2026-06-15 17:01:57 +08:00
commit ba6e7669e8
82 changed files with 6859 additions and 0 deletions
+22
View File
@@ -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`),
}
+26
View File
@@ -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),
}
+32
View File
@@ -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}`),
}
+94
View File
@@ -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
+112
View File
@@ -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>
+24
View File
@@ -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')
+59
View File
@@ -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
+86
View File
@@ -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,
}
})
+95
View File
@@ -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
}
+162
View File
@@ -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>
+67
View File
@@ -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>
+273
View File
@@ -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>
+116
View File
@@ -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>