1.4.8-Fix

This commit is contained in:
RachelOS
2025-12-17 17:05:44 +08:00
parent fb4487fb2f
commit 1e7b1bbde8
4 changed files with 407 additions and 299 deletions
+337 -296
View File
@@ -1,297 +1,338 @@
from fastapi import APIRouter, Depends, HTTPException, status as fast_status, Query
from core.auth import get_current_user
from core.db import DB
from core.models.base import DATA_STATUS
from core.models.article import Article,ArticleBase
from sqlalchemy import and_, or_, desc
from .base import success_response, error_response
from core.config import cfg
from apis.base import format_search_kw
from core.print import print_warning, print_info, print_error, print_success
router = APIRouter(prefix=f"/articles", tags=["文章管理"])
@router.delete("/clean", summary="清理无效文章(MP_ID不存在于Feeds表中的文章)")
async def clean_orphan_articles(
current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
from core.models.feed import Feed
from core.models.article import Article
# 找出Articles表中mp_id不在Feeds表中的记录
subquery = session.query(Feed.id).subquery()
deleted_count = session.query(Article)\
.filter(~Article.mp_id.in_(subquery))\
.delete(synchronize_session=False)
session.commit()
return success_response({
"message": "清理无效文章成功",
"deleted_count": deleted_count
})
except Exception as e:
session.rollback()
print(f"清理无效文章错误: {str(e)}")
raise HTTPException(
status_code=fast_status.HTTP_201_CREATED,
detail=error_response(
code=50001,
message="清理无效文章失败"
)
)
@router.delete("/clean_duplicate_articles", summary="清理重复文章")
async def clean_duplicate(
current_user: dict = Depends(get_current_user)
):
try:
from tools.clean import clean_duplicate_articles
(msg, deleted_count) =clean_duplicate_articles()
return success_response({
"message": msg,
"deleted_count": deleted_count
})
except Exception as e:
print(f"清理重复文章: {str(e)}")
raise HTTPException(
status_code=fast_status.HTTP_201_CREATED,
detail=error_response(
code=50001,
message="清理重复文章"
)
)
@router.api_route("", summary="获取文章列表",methods= ["GET", "POST"], operation_id="get_articles_list")
async def get_articles(
offset: int = Query(0, ge=0),
limit: int = Query(5, ge=1, le=100),
status: str = Query(None),
search: str = Query(None),
mp_id: str = Query(None),
has_content:bool=Query(False),
current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
# 构建查询条件
query = session.query(ArticleBase)
if has_content:
query=session.query(Article)
if status:
query = query.filter(Article.status == status)
else:
query = query.filter(Article.status != DATA_STATUS.DELETED)
if mp_id:
query = query.filter(Article.mp_id == mp_id)
if search:
query = query.filter(
format_search_kw(search)
)
# 获取总数
total = query.count()
query= query.order_by(Article.publish_time.desc()).offset(offset).limit(limit)
# query= query.order_by(Article.id.desc()).offset(offset).limit(limit)
# 分页查询(按发布时间降序)
articles = query.all()
# 打印生成的 SQL 语句(包含分页参数)
print_warning(query.statement.compile(compile_kwargs={"literal_binds": True}))
# 查询公众号名称
from core.models.feed import Feed
mp_names = {}
for article in articles:
if article.mp_id and article.mp_id not in mp_names:
feed = session.query(Feed).filter(Feed.id == article.mp_id).first()
mp_names[article.mp_id] = feed.mp_name if feed else "未知公众号"
# 合并公众号名称到文章列表
article_list = []
for article in articles:
article_dict = article.__dict__
article_dict["mp_name"] = mp_names.get(article.mp_id, "未知公众号")
article_list.append(article_dict)
from .base import success_response
return success_response({
"list": article_list,
"total": total
})
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=50001,
message=f"获取文章列表失败: {str(e)}"
)
)
@router.get("/{article_id}", summary="获取文章详情")
async def get_article_detail(
article_id: str,
content: bool = False,
# current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
article = session.query(Article).filter(Article.id==article_id).filter(Article.status != DATA_STATUS.DELETED).first()
if not article:
from .base import error_response
raise HTTPException(
status_code=fast_status.HTTP_404_NOT_FOUND,
detail=error_response(
code=40401,
message="文章不存在"
)
)
return success_response(article)
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=50001,
message=f"获取文章详情失败: {str(e)}"
)
)
@router.delete("/{article_id}", summary="删除文章")
async def delete_article(
article_id: str,
current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
from core.models.article import Article
# 检查文章是否存在
article = session.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=40401,
message="文章不存在"
)
)
# 逻辑删除文章(更新状态为deleted)
article.status = DATA_STATUS.DELETED
if cfg.get("article.true_delete", False):
session.delete(article)
session.commit()
return success_response(None, message="文章已标记为删除")
except Exception as e:
session.rollback()
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=50001,
message=f"删除文章失败: {str(e)}"
)
)
@router.get("/{article_id}/next", summary="获取下一篇文章")
async def get_next_article(
article_id: str,
current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
# 获取当前文章的发布时间
current_article = session.query(Article).filter(Article.id == article_id).first()
if not current_article:
raise HTTPException(
status_code=fast_status.HTTP_404_NOT_FOUND,
detail=error_response(
code=40401,
message="当前文章不存在"
)
)
# 查询发布时间更晚的第一篇文章
next_article = session.query(Article)\
.filter(Article.publish_time > current_article.publish_time)\
.filter(Article.status != DATA_STATUS.DELETED)\
.filter(Article.mp_id == current_article.mp_id)\
.order_by(Article.publish_time.asc())\
.first()
if not next_article:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=40402,
message="没有下一篇文章"
)
)
return success_response(next_article)
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=50001,
message=f"获取下一篇文章失败: {str(e)}"
)
)
@router.get("/{article_id}/prev", summary="获取上一篇文章")
async def get_prev_article(
article_id: str,
current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
# 获取当前文章的发布时间
current_article = session.query(Article).filter(Article.id == article_id).first()
if not current_article:
raise HTTPException(
status_code=fast_status.HTTP_404_NOT_FOUND,
detail=error_response(
code=40401,
message="当前文章不存在"
)
)
# 查询发布时间更早的第一篇文章
prev_article = session.query(Article)\
.filter(Article.publish_time < current_article.publish_time)\
.filter(Article.status != DATA_STATUS.DELETED)\
.filter(Article.mp_id == current_article.mp_id)\
.order_by(Article.publish_time.desc())\
.first()
if not prev_article:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=40403,
message="没有上一篇文章"
)
)
return success_response(prev_article)
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=50001,
message=f"获取上一篇文章失败: {str(e)}"
)
from fastapi import APIRouter, Depends, HTTPException, status as fast_status, Query
from core.auth import get_current_user
from core.db import DB
from core.models.base import DATA_STATUS
from core.models.article import Article,ArticleBase
from sqlalchemy import and_, or_, desc
from .base import success_response, error_response
from core.config import cfg
from apis.base import format_search_kw
from core.print import print_warning, print_info, print_error, print_success
router = APIRouter(prefix=f"/articles", tags=["文章管理"])
@router.delete("/clean", summary="清理无效文章(MP_ID不存在于Feeds表中的文章)")
async def clean_orphan_articles(
current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
from core.models.feed import Feed
from core.models.article import Article
# 找出Articles表中mp_id不在Feeds表中的记录
subquery = session.query(Feed.id).subquery()
deleted_count = session.query(Article)\
.filter(~Article.mp_id.in_(subquery))\
.delete(synchronize_session=False)
session.commit()
return success_response({
"message": "清理无效文章成功",
"deleted_count": deleted_count
})
except Exception as e:
session.rollback()
print(f"清理无效文章错误: {str(e)}")
raise HTTPException(
status_code=fast_status.HTTP_201_CREATED,
detail=error_response(
code=50001,
message="清理无效文章失败"
)
)
@router.put("/{article_id}/read", summary="改变文章阅读状态")
async def toggle_article_read_status(
article_id: str,
is_read: bool = Query(..., description="阅读状态: true为已读, false为未读"),
current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
from core.models.article import Article
# 检查文章是否存在
article = session.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(
status_code=fast_status.HTTP_404_NOT_FOUND,
detail=error_response(
code=40401,
message="文章不存在"
)
)
# 更新阅读状态
article.is_read = 1 if is_read else 0
session.commit()
return success_response({
"message": f"文章已标记为{'已读' if is_read else '未读'}",
"is_read": is_read
})
except HTTPException as e:
raise e
except Exception as e:
session.rollback()
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=50001,
message=f"更新文章阅读状态失败: {str(e)}"
)
)
@router.delete("/clean_duplicate_articles", summary="清理重复文章")
async def clean_duplicate(
current_user: dict = Depends(get_current_user)
):
try:
from tools.clean import clean_duplicate_articles
(msg, deleted_count) =clean_duplicate_articles()
return success_response({
"message": msg,
"deleted_count": deleted_count
})
except Exception as e:
print(f"清理重复文章: {str(e)}")
raise HTTPException(
status_code=fast_status.HTTP_201_CREATED,
detail=error_response(
code=50001,
message="清理重复文章"
)
)
@router.api_route("", summary="获取文章列表",methods= ["GET", "POST"], operation_id="get_articles_list")
async def get_articles(
offset: int = Query(0, ge=0),
limit: int = Query(5, ge=1, le=100),
status: str = Query(None),
search: str = Query(None),
mp_id: str = Query(None),
has_content:bool=Query(False),
current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
# 构建查询条件
query = session.query(ArticleBase)
if has_content:
query=session.query(Article)
if status:
query = query.filter(Article.status == status)
else:
query = query.filter(Article.status != DATA_STATUS.DELETED)
if mp_id:
query = query.filter(Article.mp_id == mp_id)
if search:
query = query.filter(
format_search_kw(search)
)
# 获取总数
total = query.count()
query= query.order_by(Article.publish_time.desc()).offset(offset).limit(limit)
# query= query.order_by(Article.id.desc()).offset(offset).limit(limit)
# 分页查询(按发布时间降序)
articles = query.all()
# 打印生成的 SQL 语句(包含分页参数)
print_warning(query.statement.compile(compile_kwargs={"literal_binds": True}))
# 查询公众号名称
from core.models.feed import Feed
mp_names = {}
for article in articles:
if article.mp_id and article.mp_id not in mp_names:
feed = session.query(Feed).filter(Feed.id == article.mp_id).first()
mp_names[article.mp_id] = feed.mp_name if feed else "未知公众号"
# 合并公众号名称到文章列表
article_list = []
for article in articles:
article_dict = article.__dict__
article_dict["mp_name"] = mp_names.get(article.mp_id, "未知公众号")
article_list.append(article_dict)
from .base import success_response
return success_response({
"list": article_list,
"total": total
})
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=50001,
message=f"获取文章列表失败: {str(e)}"
)
)
@router.get("/{article_id}", summary="获取文章详情")
async def get_article_detail(
article_id: str,
content: bool = False,
# current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
article = session.query(Article).filter(Article.id==article_id).filter(Article.status != DATA_STATUS.DELETED).first()
if not article:
from .base import error_response
raise HTTPException(
status_code=fast_status.HTTP_404_NOT_FOUND,
detail=error_response(
code=40401,
message="文章不存在"
)
)
return success_response(article)
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=50001,
message=f"获取文章详情失败: {str(e)}"
)
)
@router.delete("/{article_id}", summary="删除文章")
async def delete_article(
article_id: str,
current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
from core.models.article import Article
# 检查文章是否存在
article = session.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=40401,
message="文章不存在"
)
)
# 逻辑删除文章(更新状态为deleted)
article.status = DATA_STATUS.DELETED
if cfg.get("article.true_delete", False):
session.delete(article)
session.commit()
return success_response(None, message="文章已标记为删除")
except Exception as e:
session.rollback()
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=50001,
message=f"删除文章失败: {str(e)}"
)
)
@router.get("/{article_id}/next", summary="获取下一篇文章")
async def get_next_article(
article_id: str,
current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
# 获取当前文章的发布时间
current_article = session.query(Article).filter(Article.id == article_id).first()
if not current_article:
raise HTTPException(
status_code=fast_status.HTTP_404_NOT_FOUND,
detail=error_response(
code=40401,
message="当前文章不存在"
)
)
# 查询发布时间更晚的第一篇文章
next_article = session.query(Article)\
.filter(Article.publish_time > current_article.publish_time)\
.filter(Article.status != DATA_STATUS.DELETED)\
.filter(Article.mp_id == current_article.mp_id)\
.order_by(Article.publish_time.asc())\
.first()
if not next_article:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=40402,
message="没有下一篇文章"
)
)
return success_response(next_article)
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=50001,
message=f"获取下一篇文章失败: {str(e)}"
)
)
@router.get("/{article_id}/prev", summary="获取上一篇文章")
async def get_prev_article(
article_id: str,
current_user: dict = Depends(get_current_user)
):
session = DB.get_session()
try:
# 获取当前文章的发布时间
current_article = session.query(Article).filter(Article.id == article_id).first()
if not current_article:
raise HTTPException(
status_code=fast_status.HTTP_404_NOT_FOUND,
detail=error_response(
code=40401,
message="当前文章不存在"
)
)
# 查询发布时间更早的第一篇文章
prev_article = session.query(Article)\
.filter(Article.publish_time < current_article.publish_time)\
.filter(Article.status != DATA_STATUS.DELETED)\
.filter(Article.mp_id == current_article.mp_id)\
.order_by(Article.publish_time.desc())\
.first()
if not prev_article:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=40403,
message="没有上一篇文章"
)
)
return success_response(prev_article)
except HTTPException as e:
raise e
except Exception as e:
raise HTTPException(
status_code=fast_status.HTTP_406_NOT_ACCEPTABLE,
detail=error_response(
code=50001,
message=f"获取上一篇文章失败: {str(e)}"
)
)
+1
View File
@@ -13,5 +13,6 @@ class ArticleBase(Base):
created_at = Column(DateTime)
updated_at = Column(DateTime)
is_export = Column(Integer)
is_read = Column(Integer, default=0)
class Article(ArticleBase):
content = Column(Text)
+14
View File
@@ -10,6 +10,7 @@ import http from './http'
* @property status 文章状态
* @property link 文章链接
* @property created_at 创建时间
* @property is_read 阅读状态
*/
export interface Article {
id: number
@@ -20,6 +21,7 @@ export interface Article {
status: number
link: string
created_at: string
is_read?: number
}
/**
@@ -131,3 +133,15 @@ export const ClearDuplicateArticle = (id: number) => {
return http.delete<{code: number, message: string}>(`/wx/articles/clean_duplicate_articles`)
}
/**
* 切换文章阅读状态
* @param id 文章ID
* @param is_read 阅读状态
* @returns 操作结果
*/
export const toggleArticleReadStatus = (id: number, is_read: boolean) => {
return http.put<{code: number, message: string, is_read: boolean}>(`/wx/articles/${id}/read`, null, {
params: { is_read }
})
}
@@ -190,8 +190,8 @@ import { Avatar } from '@/utils/constants'
import { translatePage, setCurrentLanguage } from '@/utils/translate';
import { ref, onMounted, h } from 'vue'
import axios from 'axios'
import { IconApps, IconAtt, IconDelete, IconEdit, IconEye, IconRefresh, IconScan, IconWeiboCircleFill, IconWifi, IconCode } from '@arco-design/web-vue/es/icon'
import { getArticles, deleteArticle as deleteArticleApi, ClearArticle, ClearDuplicateArticle, getArticleDetail } from '@/api/article'
import { IconApps, IconAtt, IconDelete, IconEdit, IconEye, IconRefresh, IconScan, IconWeiboCircleFill, IconWifi, IconCode, IconCheck, IconClose } from '@arco-design/web-vue/es/icon'
import { getArticles, deleteArticle as deleteArticleApi, ClearArticle, ClearDuplicateArticle, getArticleDetail, toggleArticleReadStatus } from '@/api/article'
import { ExportOPML, ExportMPS, ImportMPS } from '@/api/export'
import ExportModal from '@/components/ExportModal.vue'
import { getSubscriptions, UpdateMps } from '@/api/subscription'
@@ -245,6 +245,30 @@ const statusColorMap = {
}
const columns = [
{
title: '阅读状态',
dataIndex: 'is_read',
width: '100',
render: ({ record }) => {
const isRead = record.is_read === 1;
return h('div', {
style: {
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
color: isRead ? 'var(--color-success)' : 'var(--color-text-3)'
},
onClick: () => toggleReadStatus(record)
}, [
h(isRead ? IconCheck : IconClose, {
style: { marginRight: '4px' }
}),
h('span', {
style: { fontSize: '12px' }
}, isRead ? '已读' : '未读')
]);
}
},
{
title: '文章标题',
dataIndex: 'title',
@@ -254,7 +278,11 @@ const columns = [
href: record.url || '#',
title: record.title,
target: '_blank',
style: { color: 'var(--color-text-1)' }
style: {
color: 'var(--color-text-1)',
textDecoration: record.is_read === 1 ? 'line-through' : 'none',
opacity: record.is_read === 1 ? 0.7 : 1
}
}, record.title)
},
{
@@ -540,6 +568,11 @@ const viewArticle = async (record: any,action_type: number) => {
}
articleModalVisible.value = true
window.location="#topreader"
// 自动标记为已读(仅在查看当前文章时,不是上一篇/下一篇)
if (action_type === 0 && record.is_read !== 1) {
await toggleReadStatus(record)
}
} catch (error) {
console.error('获取文章详情错误:', error)
Message.error(error)
@@ -732,6 +765,25 @@ const exportArticles = () => {
URL.revokeObjectURL(url);
Message.success('导出成功');
};
// 切换文章阅读状态
const toggleReadStatus = async (record: any) => {
try {
const newReadStatus = record.is_read === 1 ? false : true;
await toggleArticleReadStatus(record.id, newReadStatus);
// 更新本地数据
const index = articles.value.findIndex(item => item.id === record.id);
if (index !== -1) {
articles.value[index].is_read = newReadStatus ? 1 : 0;
}
Message.success(`文章已标记为${newReadStatus ? '已读' : '未读'}`);
} catch (error) {
console.error('更新阅读状态失败:', error);
Message.error('更新阅读状态失败');
}
};
</script>
<style scoped>