Add tag posts view and comment counts
This commit is contained in:
61
back/main.py
61
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:
|
||||
|
||||
@@ -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 (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
@@ -313,6 +317,7 @@ function App() {
|
||||
>
|
||||
<Route index element={<Home onPreviewImage={openImageViewer} />} />
|
||||
<Route path="create" element={<CreatePost />} />
|
||||
<Route path="tag" element={<TagPosts onPreviewImage={openImageViewer} />} />
|
||||
<Route path="panel" element={<Panel />} />
|
||||
<Route path="about" element={<About />} />
|
||||
</Route>
|
||||
|
||||
@@ -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> => {
|
||||
try {
|
||||
const response = await fetch(`/api/${type}`, {
|
||||
|
||||
@@ -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<CommentSectionProps> = ({ postId }) => {
|
||||
const styles = useStyles();
|
||||
const { toasterId, triggerStaticsRefresh } = useLayout();
|
||||
const { dispatchToast } = useToastController(toasterId);
|
||||
const navigate = useNavigate();
|
||||
const [comments, setComments] = useState<CommentType[]>([]);
|
||||
const [content, setContent] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
@@ -155,6 +158,7 @@ const CommentSection: React.FC<CommentSectionProps> = ({ postId }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const commentCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const inputContainerRef = useRef<HTMLDivElement>(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 <span className={styles.tag}>{props.children}</span>;
|
||||
const href = props.href;
|
||||
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} />;
|
||||
},
|
||||
@@ -317,7 +334,7 @@ useEffect(() => {
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.inputContainer} ref={inputContainerRef}>
|
||||
<Input
|
||||
className={styles.nicknameInput}
|
||||
placeholder="输入昵称"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Warning24Regular,
|
||||
} from '@fluentui/react-icons';
|
||||
import { useLayout } from '../context/LayoutContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CommentSection from './CommentSection';
|
||||
import ReportPost from './ReportPost';
|
||||
|
||||
@@ -35,9 +36,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 }]
|
||||
};
|
||||
}
|
||||
@@ -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 <span style={{ color: tokens.colorBrandForeground1 }}>{props.children}</span>;
|
||||
const href = props.href;
|
||||
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} />;
|
||||
},
|
||||
@@ -359,7 +374,9 @@ const PostCard = ({
|
||||
appearance="transparent"
|
||||
className={styles.actionButton}
|
||||
onClick={() => setShowComments(!showComments)}
|
||||
/>
|
||||
>
|
||||
{commentCount}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Warning24Regular />}
|
||||
appearance="transparent"
|
||||
|
||||
232
front/src/components/TagPosts.tsx
Normal file
232
front/src/components/TagPosts.tsx
Normal 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;
|
||||
@@ -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<boolean | null>(null);
|
||||
const [statics, setStatics] = useState<StaticsData | null>(null);
|
||||
const [topics, setTopics] = useState<HotTopicItem[]>([]);
|
||||
@@ -238,7 +240,13 @@ const Widgets: React.FC = () => {
|
||||
) : (
|
||||
topics.map((topic, 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.topicCount}>{topic.count}个投稿</Text>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user