diff --git a/back/main.py b/back/main.py index f86dbfd..a64b61a 100644 --- a/back/main.py +++ b/back/main.py @@ -607,6 +607,67 @@ def get_posts_info(): except Exception as e: return jsonify({"code": 2003, "data": str(e)}) +@app.route('/api/get_posts_by_tag', methods=['GET']) +def get_posts_by_tag(): + try: + tag = request.args.get("tag") + if not tag: + return jsonify({"code": 2000, "data": "参数错误"}) + + tag = str(tag).strip().strip('"').strip("'") + if tag.startswith('#'): + tag = tag[1:] + if not tag: + return jsonify({"code": 2000, "data": "参数错误"}) + + page = request.args.get("page", 1, type=int) + if page < 1: + page = 1 + + per_page = 10 + submission_ids_query = db.session.query( + Hashtag.target_id.label('submission_id') + ).filter( + Hashtag.type == 0, + Hashtag.name == tag + ) + + comment_submission_ids_query = db.session.query( + Comment.submission_id.label('submission_id') + ).join( + Hashtag, + db.and_(Hashtag.type == 1, Hashtag.target_id == Comment.id) + ).filter( + Hashtag.name == tag + ) + + union_subq = submission_ids_query.union(comment_submission_ids_query).subquery() + + query = Submission.query.filter( + Submission.id.in_(db.select(union_subq.c.submission_id)), + Submission.status == 'Pass' + ).order_by(Submission.id.desc()) + + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + + data = [] + for s in pagination.items: + data.append({ + "id": s.id, + "content": s.content, + "upvotes": s.upvotes, + "downvotes": s.downvotes, + "created_at": s.created_at.isoformat() if s.created_at else None, + "time": s.created_at.isoformat() if s.created_at else None, + "modified": 0 if (not s.updated_at or not s.created_at or s.updated_at == s.created_at) else 1, + "comment_count": len(s.comments), + "total_pages": pagination.total, + }) + + return jsonify({"code": 1000, "data": data}) + except Exception as e: + return jsonify({"code": 2003, "data": str(e)}) + @app.route('/api/post_info', methods=['GET']) def get_post_info(): try: diff --git a/front/src/App.tsx b/front/src/App.tsx index a6cfab2..3e7c347 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -7,6 +7,7 @@ import CreatePost from './components/CreatePost'; import PostCard from './components/PostCard'; import ImageViewer from './components/ImageViewer'; import Panel from './components/Panel'; +import TagPosts from './components/TagPosts'; import { fetchArticles, reset_identity_token, getSiteNotice, type Article } from './api'; import { useLayout } from './context/LayoutContext'; import './App.css'; @@ -119,6 +120,7 @@ const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> = content={article.content} upvotes={article.upvotes} downvotes={article.downvotes} + commentCount={article.comment_count ?? 0} time={article.time} modified={article.modified} onPreviewImage={onPreviewImage} @@ -133,6 +135,7 @@ const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> = content={article.content} upvotes={article.upvotes} downvotes={article.downvotes} + commentCount={article.comment_count ?? 0} time={article.time} modified={article.modified} onPreviewImage={onPreviewImage} @@ -226,6 +229,7 @@ function App() { return () => window.removeEventListener('identity_invalid', handler as EventListener); }, []); + return ( @@ -313,6 +317,7 @@ function App() { > } /> } /> + } /> } /> } /> diff --git a/front/src/api.ts b/front/src/api.ts index c260f38..7fe063c 100644 --- a/front/src/api.ts +++ b/front/src/api.ts @@ -284,6 +284,23 @@ export const fetchArticles = async (page: number, signal?: AbortSignal): Promise } }; +export const fetchArticlesByTag = async (tag: string, page: number, signal?: AbortSignal): Promise => { + try { + const response = await fetch(`/api/get_posts_by_tag?tag=${encodeURIComponent(tag)}&page=${page}`, { signal }); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const json = await response.json(); + if (json.code === 1000 && Array.isArray(json.data)) { + return json.data as Article[]; + } + throw new Error('Invalid response code or missing data'); + } catch (error) { + console.error('Failed to fetch articles by tag:', error); + throw error; + } +}; + export const voteArticle = async (id: number, type: 'up' | 'down'): Promise => { try { const response = await fetch(`/api/${type}`, { diff --git a/front/src/components/CommentSection.tsx b/front/src/components/CommentSection.tsx index c3cce4b..a424a54 100644 --- a/front/src/components/CommentSection.tsx +++ b/front/src/components/CommentSection.tsx @@ -16,6 +16,7 @@ import { Dismiss24Regular, ArrowReply24Regular, ArrowClockwise24Regular } from ' import { getComments, postComment } from '../api'; import type { Comment as CommentType } from '../api'; import { useLayout } from '../context/LayoutContext'; +import { useNavigate } from 'react-router-dom'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -119,9 +120,10 @@ const remarkTagPlugin = () => { const parts = child.value.split(/(#\S+)/g); return parts.map((part: string) => { if (part.match(/^#\S+$/)) { + const clean = part.replace(/^#/, ''); return { type: 'link', - url: 'tag:' + part, + url: '/tag/#' + encodeURIComponent(clean), children: [{ type: 'text', value: part }] }; } @@ -146,6 +148,7 @@ const CommentSection: React.FC = ({ postId }) => { const styles = useStyles(); const { toasterId, triggerStaticsRefresh } = useLayout(); const { dispatchToast } = useToastController(toasterId); + const navigate = useNavigate(); const [comments, setComments] = useState([]); const [content, setContent] = useState(''); const [nickname, setNickname] = useState(''); @@ -155,6 +158,7 @@ const CommentSection: React.FC = ({ postId }) => { const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const commentCardRefs = useRef>(new Map()); + const inputContainerRef = useRef(null); useEffect(() => { setComments([]); @@ -235,6 +239,9 @@ useEffect(() => { const handleReply = (comment: CommentType) => { setReplyTo(comment); + setTimeout(() => { + inputContainerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 0); }; const cancelReply = () => { @@ -284,8 +291,18 @@ useEffect(() => { remarkPlugins={[remarkGfm, remarkTagPlugin]} components={{ a: ({ node, ...props }) => { - if (props.href && props.href.startsWith('tag:')) { - return {props.children}; + const href = props.href; + if (href && typeof href === 'string' && href.startsWith('/tag/#')) { + const hash = href.slice('/tag/'.length); + return ( + { + e.preventDefault(); + navigate({ pathname: '/tag', hash }); + }} + /> + ); } return ; }, @@ -317,7 +334,7 @@ useEffect(() => { return (
-
+
{ const parts = child.value.split(/(#\S+)/g); return parts.map((part: string) => { if (part.match(/^#\S+$/)) { + const clean = part.replace(/^#/, ''); return { type: 'link', - url: 'tag:' + part, + url: '/tag/#' + encodeURIComponent(clean), children: [{ type: 'text', value: part }] }; } @@ -206,6 +208,7 @@ interface PostCardProps { content: string; upvotes: number; downvotes: number; + commentCount?: number; time?: string; modified?: number; onPreviewImage?: (src: string, alt?: string) => void; @@ -216,6 +219,7 @@ const PostCard = ({ content, upvotes, downvotes, + commentCount = 0, time, modified, onPreviewImage, @@ -223,6 +227,7 @@ const PostCard = ({ const styles = useStyles(); const { toasterId } = useLayout(); const { dispatchToast } = useToastController(toasterId); + const navigate = useNavigate(); const [votes, setVotes] = React.useState({ upvotes, downvotes }); const [hasVoted, setHasVoted] = React.useState(false); const [voteChoice, setVoteChoice] = React.useState<'up' | 'down' | null>(null); @@ -267,8 +272,18 @@ const PostCard = ({ remarkPlugins={[remarkGfm, remarkIns, remarkTagPlugin]} components={{ a: ({ node, ...props }) => { - if (props.href && props.href.startsWith('tag:')) { - return {props.children}; + const href = props.href; + if (href && typeof href === 'string' && href.startsWith('/tag/#')) { + const hash = href.slice('/tag/'.length); + return ( + { + e.preventDefault(); + navigate({ pathname: '/tag', hash }); + }} + /> + ); } return ; }, @@ -359,7 +374,9 @@ const PostCard = ({ appearance="transparent" className={styles.actionButton} onClick={() => setShowComments(!showComments)} - /> + > + {commentCount} + + {tag && ( + <> + | + #{tag} + + )} +
+
+ {articles.map((article, index) => { + if (articles.length === index + 1 && hasMore) { + return ( +
+ +
+ ); + } + return ( + + ); + })} + {loading &&
加载中...
} + {!loading && !hasMore && ( +
+
+
+ 已经到底了喵~ +
+
+
+ )} +
+
+ ); +}; + +export default TagPosts; diff --git a/front/src/components/Widgets.tsx b/front/src/components/Widgets.tsx index 9870f89..98d94f3 100644 --- a/front/src/components/Widgets.tsx +++ b/front/src/components/Widgets.tsx @@ -1,5 +1,6 @@ // v1的时候就连统计信息也5秒获取一次,不妥,所以改成逻辑触发刷新 import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import { makeStyles, tokens, @@ -99,6 +100,7 @@ const Widgets: React.FC = () => { const styles = useStyles(); const { toasterId, refreshTrigger, staticsRefreshTrigger } = useLayout(); const { dispatchToast } = useToastController(toasterId); + const navigate = useNavigate(); const [isApiOnline, setIsApiOnline] = useState(null); const [statics, setStatics] = useState(null); const [topics, setTopics] = useState([]); @@ -238,7 +240,13 @@ const Widgets: React.FC = () => { ) : ( topics.map((topic, index) => ( -
+
+ navigate(`/tag/#${encodeURIComponent(topic.name)}`) + } + > #{topic.name} {topic.count}个投稿