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:
|
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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`, {
|
||||||
|
|||||||
@@ -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="输入昵称"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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秒获取一次,不妥,所以改成逻辑触发刷新
|
// 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user