Add tag posts view and comment counts

This commit is contained in:
LeonspaceX
2026-01-30 21:59:27 +08:00
parent 43ec347f86
commit 8f6ae3fdfc
7 changed files with 366 additions and 9 deletions

View File

@@ -607,6 +607,67 @@ def get_posts_info():
except Exception as e: except Exception as e:
return jsonify({"code": 2003, "data": str(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']) @app.route('/api/post_info', methods=['GET'])
def get_post_info(): def get_post_info():
try: try:

View File

@@ -7,6 +7,7 @@ import CreatePost from './components/CreatePost';
import PostCard from './components/PostCard'; import PostCard from './components/PostCard';
import ImageViewer from './components/ImageViewer'; import ImageViewer from './components/ImageViewer';
import Panel from './components/Panel'; import Panel from './components/Panel';
import TagPosts from './components/TagPosts';
import { fetchArticles, reset_identity_token, getSiteNotice, type Article } from './api'; import { fetchArticles, reset_identity_token, getSiteNotice, type Article } from './api';
import { useLayout } from './context/LayoutContext'; import { useLayout } from './context/LayoutContext';
import './App.css'; import './App.css';
@@ -119,6 +120,7 @@ const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> =
content={article.content} content={article.content}
upvotes={article.upvotes} upvotes={article.upvotes}
downvotes={article.downvotes} downvotes={article.downvotes}
commentCount={article.comment_count ?? 0}
time={article.time} time={article.time}
modified={article.modified} modified={article.modified}
onPreviewImage={onPreviewImage} onPreviewImage={onPreviewImage}
@@ -133,6 +135,7 @@ const Home: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> =
content={article.content} content={article.content}
upvotes={article.upvotes} upvotes={article.upvotes}
downvotes={article.downvotes} downvotes={article.downvotes}
commentCount={article.comment_count ?? 0}
time={article.time} time={article.time}
modified={article.modified} modified={article.modified}
onPreviewImage={onPreviewImage} onPreviewImage={onPreviewImage}
@@ -226,6 +229,7 @@ function App() {
return () => window.removeEventListener('identity_invalid', handler as EventListener); return () => window.removeEventListener('identity_invalid', handler as EventListener);
}, []); }, []);
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
@@ -313,6 +317,7 @@ function App() {
> >
<Route index element={<Home onPreviewImage={openImageViewer} />} /> <Route index element={<Home onPreviewImage={openImageViewer} />} />
<Route path="create" element={<CreatePost />} /> <Route path="create" element={<CreatePost />} />
<Route path="tag" element={<TagPosts onPreviewImage={openImageViewer} />} />
<Route path="panel" element={<Panel />} /> <Route path="panel" element={<Panel />} />
<Route path="about" element={<About />} /> <Route path="about" element={<About />} />
</Route> </Route>

View File

@@ -284,6 +284,23 @@ export const fetchArticles = async (page: number, signal?: AbortSignal): Promise
} }
}; };
export const fetchArticlesByTag = async (tag: string, page: number, signal?: AbortSignal): Promise<Article[]> => {
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<void> => { export const voteArticle = async (id: number, type: 'up' | 'down'): Promise<void> => {
try { try {
const response = await fetch(`/api/${type}`, { const response = await fetch(`/api/${type}`, {

View File

@@ -16,6 +16,7 @@ import { Dismiss24Regular, ArrowReply24Regular, ArrowClockwise24Regular } from '
import { getComments, postComment } from '../api'; import { getComments, postComment } from '../api';
import type { Comment as CommentType } from '../api'; import type { Comment as CommentType } from '../api';
import { useLayout } from '../context/LayoutContext'; import { useLayout } from '../context/LayoutContext';
import { useNavigate } from 'react-router-dom';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@@ -119,9 +120,10 @@ const remarkTagPlugin = () => {
const parts = child.value.split(/(#\S+)/g); const parts = child.value.split(/(#\S+)/g);
return parts.map((part: string) => { return parts.map((part: string) => {
if (part.match(/^#\S+$/)) { if (part.match(/^#\S+$/)) {
const clean = part.replace(/^#/, '');
return { return {
type: 'link', type: 'link',
url: 'tag:' + part, url: '/tag/#' + encodeURIComponent(clean),
children: [{ type: 'text', value: part }] children: [{ type: 'text', value: part }]
}; };
} }
@@ -146,6 +148,7 @@ const CommentSection: React.FC<CommentSectionProps> = ({ postId }) => {
const styles = useStyles(); const styles = useStyles();
const { toasterId, triggerStaticsRefresh } = useLayout(); const { toasterId, triggerStaticsRefresh } = useLayout();
const { dispatchToast } = useToastController(toasterId); const { dispatchToast } = useToastController(toasterId);
const navigate = useNavigate();
const [comments, setComments] = useState<CommentType[]>([]); const [comments, setComments] = useState<CommentType[]>([]);
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [nickname, setNickname] = useState(''); const [nickname, setNickname] = useState('');
@@ -155,6 +158,7 @@ const CommentSection: React.FC<CommentSectionProps> = ({ postId }) => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
const commentCardRefs = useRef<Map<number, HTMLDivElement>>(new Map()); const commentCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const inputContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
setComments([]); setComments([]);
@@ -235,6 +239,9 @@ useEffect(() => {
const handleReply = (comment: CommentType) => { const handleReply = (comment: CommentType) => {
setReplyTo(comment); setReplyTo(comment);
setTimeout(() => {
inputContainerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 0);
}; };
const cancelReply = () => { const cancelReply = () => {
@@ -284,8 +291,18 @@ useEffect(() => {
remarkPlugins={[remarkGfm, remarkTagPlugin]} remarkPlugins={[remarkGfm, remarkTagPlugin]}
components={{ components={{
a: ({ node, ...props }) => { a: ({ node, ...props }) => {
if (props.href && props.href.startsWith('tag:')) { const href = props.href;
return <span className={styles.tag}>{props.children}</span>; if (href && typeof href === 'string' && href.startsWith('/tag/#')) {
const hash = href.slice('/tag/'.length);
return (
<a
{...props}
onClick={(e) => {
e.preventDefault();
navigate({ pathname: '/tag', hash });
}}
/>
);
} }
return <a {...props} />; return <a {...props} />;
}, },
@@ -317,7 +334,7 @@ useEffect(() => {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.inputContainer}> <div className={styles.inputContainer} ref={inputContainerRef}>
<Input <Input
className={styles.nicknameInput} className={styles.nicknameInput}
placeholder="输入昵称" placeholder="输入昵称"

View File

@@ -20,6 +20,7 @@ import {
Warning24Regular, Warning24Regular,
} from '@fluentui/react-icons'; } from '@fluentui/react-icons';
import { useLayout } from '../context/LayoutContext'; import { useLayout } from '../context/LayoutContext';
import { useNavigate } from 'react-router-dom';
import CommentSection from './CommentSection'; import CommentSection from './CommentSection';
import ReportPost from './ReportPost'; import ReportPost from './ReportPost';
@@ -35,9 +36,10 @@ const remarkTagPlugin = () => {
const parts = child.value.split(/(#\S+)/g); const parts = child.value.split(/(#\S+)/g);
return parts.map((part: string) => { return parts.map((part: string) => {
if (part.match(/^#\S+$/)) { if (part.match(/^#\S+$/)) {
const clean = part.replace(/^#/, '');
return { return {
type: 'link', type: 'link',
url: 'tag:' + part, url: '/tag/#' + encodeURIComponent(clean),
children: [{ type: 'text', value: part }] children: [{ type: 'text', value: part }]
}; };
} }
@@ -206,6 +208,7 @@ interface PostCardProps {
content: string; content: string;
upvotes: number; upvotes: number;
downvotes: number; downvotes: number;
commentCount?: number;
time?: string; time?: string;
modified?: number; modified?: number;
onPreviewImage?: (src: string, alt?: string) => void; onPreviewImage?: (src: string, alt?: string) => void;
@@ -216,6 +219,7 @@ const PostCard = ({
content, content,
upvotes, upvotes,
downvotes, downvotes,
commentCount = 0,
time, time,
modified, modified,
onPreviewImage, onPreviewImage,
@@ -223,6 +227,7 @@ const PostCard = ({
const styles = useStyles(); const styles = useStyles();
const { toasterId } = useLayout(); const { toasterId } = useLayout();
const { dispatchToast } = useToastController(toasterId); const { dispatchToast } = useToastController(toasterId);
const navigate = useNavigate();
const [votes, setVotes] = React.useState({ upvotes, downvotes }); const [votes, setVotes] = React.useState({ upvotes, downvotes });
const [hasVoted, setHasVoted] = React.useState(false); const [hasVoted, setHasVoted] = React.useState(false);
const [voteChoice, setVoteChoice] = React.useState<'up' | 'down' | null>(null); const [voteChoice, setVoteChoice] = React.useState<'up' | 'down' | null>(null);
@@ -267,8 +272,18 @@ const PostCard = ({
remarkPlugins={[remarkGfm, remarkIns, remarkTagPlugin]} remarkPlugins={[remarkGfm, remarkIns, remarkTagPlugin]}
components={{ components={{
a: ({ node, ...props }) => { a: ({ node, ...props }) => {
if (props.href && props.href.startsWith('tag:')) { const href = props.href;
return <span style={{ color: tokens.colorBrandForeground1 }}>{props.children}</span>; if (href && typeof href === 'string' && href.startsWith('/tag/#')) {
const hash = href.slice('/tag/'.length);
return (
<a
{...props}
onClick={(e) => {
e.preventDefault();
navigate({ pathname: '/tag', hash });
}}
/>
);
} }
return <a {...props} />; return <a {...props} />;
}, },
@@ -359,7 +374,9 @@ const PostCard = ({
appearance="transparent" appearance="transparent"
className={styles.actionButton} className={styles.actionButton}
onClick={() => setShowComments(!showComments)} onClick={() => setShowComments(!showComments)}
/> >
{commentCount}
</Button>
<Button <Button
icon={<Warning24Regular />} icon={<Warning24Regular />}
appearance="transparent" appearance="transparent"

View File

@@ -0,0 +1,232 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Button, Toast, ToastTitle, makeStyles, tokens, useToastController } from '@fluentui/react-components';
import { ArrowLeft24Regular } from '@fluentui/react-icons';
import { fetchArticlesByTag, type Article } from '../api';
import { useLayout } from '../context/LayoutContext';
import PostCard from './PostCard';
const useStyles = makeStyles({
container: {
width: '100%',
height: '100%',
overflowY: 'auto',
overflowX: 'hidden',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
'&::-webkit-scrollbar': {
width: 0,
height: 0,
},
},
topBar: {
width: '100%',
padding: tokens.spacingVerticalM,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalS,
},
});
const TagPosts: React.FC<{ onPreviewImage: (src: string, alt?: string) => void }> = ({ onPreviewImage }) => {
const styles = useStyles();
const location = useLocation();
const navigate = useNavigate();
const { refreshTrigger, toasterId } = useLayout();
const { dispatchToast } = useToastController(toasterId);
const [tag, setTag] = useState('');
const [articles, setArticles] = useState<Article[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [tagRefreshTick, setTagRefreshTick] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const observer = useRef<IntersectionObserver | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const lastRefreshAtRef = useRef<number>(0);
const REFRESH_COOLDOWN_MS = 5000;
useEffect(() => {
const raw = location.hash ? location.hash.slice(1) : '';
let decoded = raw;
try {
decoded = decodeURIComponent(raw);
} catch {
decoded = raw;
}
decoded = decoded.replace(/^#/, '').trim();
setTag(decoded);
}, [location.pathname, location.hash]);
useEffect(() => {
if (!tag) return;
const key = `tag_scroll_${tag}`;
const saved = sessionStorage.getItem(key);
if (saved && containerRef.current) {
const value = Number(saved);
if (!Number.isNaN(value)) {
containerRef.current.scrollTop = value;
}
}
}, [tag]);
useEffect(() => {
if (!tag) return;
const key = `tag_scroll_${tag}`;
return () => {
if (containerRef.current) {
sessionStorage.setItem(key, String(containerRef.current.scrollTop));
}
};
}, [tag]);
useEffect(() => {
setArticles([]);
setPage(1);
setHasMore(true);
setTagRefreshTick(t => t + 1);
if (containerRef.current) {
containerRef.current.scrollTop = 0;
}
}, [tag]);
const lastArticleRef = useCallback((node: HTMLDivElement | null) => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore) {
setPage(prevPage => prevPage + 1);
}
});
if (node) observer.current.observe(node);
}, [loading, hasMore]);
const doRefresh = useCallback(() => {
if (refreshing || loading) return;
const now = Date.now();
if (now - lastRefreshAtRef.current < REFRESH_COOLDOWN_MS) return;
lastRefreshAtRef.current = now;
setRefreshing(true);
setArticles([]);
setHasMore(true);
setPage(1);
setTagRefreshTick(t => t + 1);
if (containerRef.current) containerRef.current.scrollTop = 0;
}, [refreshing, loading]);
const onWheel: React.WheelEventHandler<HTMLDivElement> = (e) => {
const atTop = (containerRef.current?.scrollTop ?? 0) <= 0;
if (atTop && e.deltaY < 0) {
doRefresh();
}
};
useEffect(() => {
if (refreshTrigger > 0) {
doRefresh();
}
}, [refreshTrigger, doRefresh]);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const loadArticles = async () => {
if (!tag) {
setHasMore(false);
return;
}
if (!hasMore) return;
setLoading(true);
try {
const newArticles = await fetchArticlesByTag(tag, page, signal);
if (newArticles.length === 0) {
setHasMore(false);
} else {
setArticles(prev => [...prev, ...newArticles]);
}
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Failed to load tag articles:', error);
}
} finally {
setLoading(false);
if (refreshing) {
setRefreshing(false);
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'success' }
);
}
}
};
loadArticles();
return () => controller.abort();
}, [tag, page, hasMore, tagRefreshTick]);
return (
<div className={styles.container} ref={containerRef} onWheel={onWheel}>
<div className={styles.topBar}>
<Button appearance="transparent" icon={<ArrowLeft24Regular />} onClick={() => navigate(-1)}>
</Button>
{tag && (
<>
<span style={{ color: tokens.colorNeutralForeground2, marginLeft: tokens.spacingHorizontalS }}>|</span>
<span style={{ color: tokens.colorNeutralForeground2 }}>#{tag}</span>
</>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minHeight: '100%' }}>
{articles.map((article, index) => {
if (articles.length === index + 1 && hasMore) {
return (
<div ref={lastArticleRef} key={article.id}>
<PostCard
id={article.id}
content={article.content}
upvotes={article.upvotes}
downvotes={article.downvotes}
commentCount={article.comment_count ?? 0}
time={article.time}
modified={article.modified}
onPreviewImage={onPreviewImage}
/>
</div>
);
}
return (
<PostCard
key={article.id}
id={article.id}
content={article.content}
upvotes={article.upvotes}
downvotes={article.downvotes}
commentCount={article.comment_count ?? 0}
time={article.time}
modified={article.modified}
onPreviewImage={onPreviewImage}
/>
);
})}
{loading && <div>...</div>}
{!loading && !hasMore && (
<div style={{ width: '100%', display: 'flex', alignItems: 'center', margin: '16px 0' }}>
<div style={{ flex: 1, height: 1, backgroundColor: tokens.colorNeutralStroke2 }} />
<div style={{ padding: '0 12px', color: tokens.colorNeutralForeground3, textAlign: 'center', whiteSpace: 'nowrap' }}>
~
</div>
<div style={{ flex: 1, height: 1, backgroundColor: tokens.colorNeutralStroke2 }} />
</div>
)}
</div>
</div>
);
};
export default TagPosts;

View File

@@ -1,5 +1,6 @@
// v1的时候就连统计信息也5秒获取一次不妥所以改成逻辑触发刷新 // v1的时候就连统计信息也5秒获取一次不妥所以改成逻辑触发刷新
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
makeStyles, makeStyles,
tokens, tokens,
@@ -99,6 +100,7 @@ const Widgets: React.FC = () => {
const styles = useStyles(); const styles = useStyles();
const { toasterId, refreshTrigger, staticsRefreshTrigger } = useLayout(); const { toasterId, refreshTrigger, staticsRefreshTrigger } = useLayout();
const { dispatchToast } = useToastController(toasterId); const { dispatchToast } = useToastController(toasterId);
const navigate = useNavigate();
const [isApiOnline, setIsApiOnline] = useState<boolean | null>(null); const [isApiOnline, setIsApiOnline] = useState<boolean | null>(null);
const [statics, setStatics] = useState<StaticsData | null>(null); const [statics, setStatics] = useState<StaticsData | null>(null);
const [topics, setTopics] = useState<HotTopicItem[]>([]); const [topics, setTopics] = useState<HotTopicItem[]>([]);
@@ -238,7 +240,13 @@ const Widgets: React.FC = () => {
) : ( ) : (
topics.map((topic, index) => ( topics.map((topic, index) => (
<React.Fragment key={`${topic.name}-${index}`}> <React.Fragment key={`${topic.name}-${index}`}>
<div className={styles.topicRow}> <div
className={styles.topicRow}
style={{ cursor: 'pointer' }}
onClick={() =>
navigate(`/tag/#${encodeURIComponent(topic.name)}`)
}
>
<Text className={styles.topicName}>#{topic.name}</Text> <Text className={styles.topicName}>#{topic.name}</Text>
<Text className={styles.topicCount}>{topic.count}稿</Text> <Text className={styles.topicCount}>{topic.count}稿</Text>
</div> </div>