diff --git a/front/package.json b/front/package.json
index e56eb64..d320cba 100644
--- a/front/package.json
+++ b/front/package.json
@@ -18,7 +18,8 @@
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.12.0",
- "remark-gfm": "^4.0.1"
+ "remark-gfm": "^4.0.1",
+ "remark-ins": "^1.1.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
diff --git a/front/src/App.tsx b/front/src/App.tsx
index b5d1b32..2acfb15 100644
--- a/front/src/App.tsx
+++ b/front/src/App.tsx
@@ -1,10 +1,139 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import { tokens } from '@fluentui/react-components';
import { MainLayout } from './layouts/MainLayout';
import About from './components/About';
import CreatePost from './components/CreatePost';
+import PostCard from './components/PostCard';
+import { fetchArticles, type Article } from './api';
+import { useLayout } from './context/LayoutContext';
import './App.css';
-const Home = () =>
Home Page
;
+const Home: React.FC = () => {
+ const { refreshTrigger } = useLayout();
+ const [articles, setArticles] = useState([]);
+ const [page, setPage] = useState(1);
+ const [loading, setLoading] = useState(false);
+ const [hasMore, setHasMore] = useState(true);
+ const [homeRefreshTick, setHomeRefreshTick] = useState(0);
+ const [refreshing, setRefreshing] = useState(false);
+ const observer = useRef(null);
+ const containerRef = useRef(null);
+ const lastRefreshAtRef = useRef(0);
+ const REFRESH_COOLDOWN_MS = 5000;
+
+ 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);
+ setHomeRefreshTick(t => t + 1);
+ if (containerRef.current) containerRef.current.scrollTop = 0;
+ }, [refreshing, loading]);
+
+ const onWheel: React.WheelEventHandler = (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 (!hasMore) return;
+ setLoading(true);
+ try {
+ const newArticles = await fetchArticles(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 articles:', error);
+ }
+ } finally {
+ setLoading(false);
+ if (refreshing) {
+ setRefreshing(false);
+ }
+ }
+ };
+
+ loadArticles();
+ return () => controller.abort();
+ }, [page, hasMore, homeRefreshTick]);
+
+ return (
+
+
+ {articles.map((article, index) => {
+ if (articles.length === index + 1 && hasMore) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ })}
+ {loading &&
加载中...
}
+ {!loading && !hasMore && (
+
+ )}
+
+
+ );
+};
+
const NotFound = () => 404 Not Found
;
function App() {
diff --git a/front/src/api.ts b/front/src/api.ts
index 0c9084b..11f0af8 100644
--- a/front/src/api.ts
+++ b/front/src/api.ts
@@ -162,3 +162,52 @@ export const createPost = async (content: string): Promise =
throw error;
}
};
+
+export interface Article {
+ id: number;
+ content: string;
+ upvotes: number;
+ downvotes: number;
+ created_at?: string;
+ comment_count?: number;
+ total_pages?: number;
+}
+
+export const fetchArticles = async (page: number, signal?: AbortSignal): Promise => {
+ try {
+ const response = await fetch(`/api/10_info?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:', error);
+ throw error;
+ }
+};
+
+export const voteArticle = async (id: number, type: 'up' | 'down'): Promise => {
+ try {
+ const response = await fetch(`/api/${type}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ id, type: 'submission' }),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const json = await response.json();
+ if (json.code !== 1000) {
+ throw new Error('Vote failed');
+ }
+ } catch (error) {
+ console.error('Failed to vote:', error);
+ throw error;
+ }
+};
diff --git a/front/src/components/PostCard.tsx b/front/src/components/PostCard.tsx
new file mode 100644
index 0000000..1809128
--- /dev/null
+++ b/front/src/components/PostCard.tsx
@@ -0,0 +1,283 @@
+import {
+ makeStyles,
+ Card,
+ CardFooter,
+ Button,
+ tokens,
+ useToastController,
+ Toast,
+ ToastTitle,
+} from '@fluentui/react-components';
+import React from 'react';
+import { voteArticle } from '../api';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+import remarkIns from 'remark-ins';
+import {
+ ArrowUp24Regular,
+ ArrowDown24Regular,
+ Comment24Regular,
+ Warning24Regular,
+} from '@fluentui/react-icons';
+import { useLayout } from '../context/LayoutContext';
+
+// 自定义 remark 插件,用于高亮 #tag
+const remarkTagPlugin = () => {
+ return (tree: any) => {
+ const transformNode = (node: any, inLink = false) => {
+ if (node.type === 'link') inLink = true;
+
+ if (node.children) {
+ node.children = node.children.flatMap((child: any) => {
+ if (child.type === 'text' && !inLink) {
+ const parts = child.value.split(/(#\S+)/g);
+ return parts.map((part: string) => {
+ if (part.match(/^#\S+$/)) {
+ return {
+ type: 'link',
+ url: 'tag:' + part,
+ children: [{ type: 'text', value: part }]
+ };
+ }
+ if (part === "") return [];
+ return { type: 'text', value: part };
+ }).flat();
+ }
+ return transformNode(child, inLink);
+ });
+ }
+ return node;
+ };
+ transformNode(tree);
+ };
+};
+
+const useStyles = makeStyles({
+ card: {
+ width: '100%',
+ maxWidth: '800px',
+ padding: tokens.spacingVerticalL,
+ marginBottom: tokens.spacingVerticalL,
+ },
+ content: {
+ paddingTop: tokens.spacingVerticalS,
+ paddingBottom: tokens.spacingVerticalS,
+ '& h1, & h2, & h3, & h4, & h5, & h6': {
+ marginTop: '1em',
+ marginBottom: '0.5em',
+ fontWeight: 'bold',
+ },
+ '& p': {
+ marginTop: '0.5em',
+ marginBottom: '0.5em',
+ lineHeight: '1.6',
+ },
+ '& ul, & ol': {
+ marginTop: '0.5em',
+ marginBottom: '0.5em',
+ paddingLeft: '2em',
+ },
+ '& li': {
+ marginTop: '0.25em',
+ marginBottom: '0.25em',
+ },
+ '& blockquote': {
+ margin: '1em 0',
+ paddingLeft: '1em',
+ borderLeft: `3px solid ${tokens.colorNeutralStroke1}`,
+ color: tokens.colorNeutralForeground2,
+ },
+ '& code': {
+ backgroundColor: tokens.colorNeutralBackground1,
+ padding: '2px 4px',
+ borderRadius: '3px',
+ fontFamily: 'monospace',
+ },
+ '& pre': {
+ backgroundColor: tokens.colorNeutralBackground1,
+ padding: '1em',
+ borderRadius: '5px',
+ overflowX: 'auto',
+ marginTop: '1em',
+ marginBottom: '1em',
+ },
+ '& table': {
+ borderCollapse: 'collapse',
+ width: '100%',
+ marginTop: '1em',
+ marginBottom: '1em',
+ },
+ '& th, & td': {
+ border: `1px solid ${tokens.colorNeutralStroke1}`,
+ padding: '8px',
+ textAlign: 'left',
+ },
+ '& th': {
+ backgroundColor: tokens.colorNeutralBackground1,
+ fontWeight: 'bold',
+ },
+ '& ins': {
+ textDecoration: 'underline',
+ backgroundColor: 'transparent',
+ },
+ '& a': {
+ color: tokens.colorBrandForegroundLink,
+ textDecoration: 'underline',
+ wordBreak: 'break-word',
+ },
+ '& a:hover': {
+ textDecoration: 'underline',
+ },
+ '& a:visited': {
+ color: tokens.colorBrandForegroundLink,
+ },
+ '& a:focus': {
+ outline: `2px solid ${tokens.colorNeutralStroke1}`,
+ outlineOffset: '2px',
+ },
+ '& img': {
+ maxWidth: '100%',
+ height: 'auto',
+ display: 'block',
+ borderRadius: tokens.borderRadiusSmall,
+ }
+ },
+ actions: {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
+ alignItems: 'center',
+ justifyItems: 'center',
+ gap: '0 8px',
+ },
+});
+
+interface PostCardProps {
+ id: number;
+ content: string;
+ upvotes: number;
+ downvotes: number;
+ onPreviewImage?: (src: string, alt?: string) => void;
+}
+
+const PostCard = ({
+ id,
+ content,
+ upvotes,
+ downvotes,
+ onPreviewImage,
+}: PostCardProps) => {
+ const styles = useStyles();
+ const { toasterId } = useLayout();
+ const { dispatchToast } = useToastController(toasterId);
+ const [votes, setVotes] = React.useState({ upvotes, downvotes });
+ const [hasVoted, setHasVoted] = React.useState(false);
+
+ React.useEffect(() => {
+ setVotes({ upvotes, downvotes });
+ }, [upvotes, downvotes]);
+
+ return (
+
+
+
+
{
+ if (props.href && props.href.startsWith('tag:')) {
+ return {props.children};
+ }
+ return ;
+ },
+ img: (props) => (
+
onPreviewImage?.(props.src as string, props.alt as string)}
+ />
+ ),
+ }}
+ >
+ {content}
+
+
+
+
+
+ }
+ appearance="transparent"
+ onClick={async () => {
+ if (hasVoted) {
+ dispatchToast(
+
+ 你已经点过一次了哦
+ ,
+ { intent: 'info' }
+ );
+ return;
+ }
+ try {
+ await voteArticle(id, 'up');
+ setVotes(prev => ({ ...prev, upvotes: prev.upvotes + 1 }));
+ setHasVoted(true);
+ } catch (error) {
+ console.error('Failed to upvote:', error);
+ dispatchToast(
+
+ 投票失败,请稍后重试
+ ,
+ { intent: 'error' }
+ );
+ }
+ }}
+ >
+ {votes.upvotes}
+
+ }
+ appearance="transparent"
+ onClick={async () => {
+ if (hasVoted) {
+ dispatchToast(
+
+ 你已经点过一次了哦
+ ,
+ { intent: 'info' }
+ );
+ return;
+ }
+ try {
+ await voteArticle(id, 'down');
+ setVotes(prev => ({ ...prev, downvotes: prev.downvotes + 1 }));
+ setHasVoted(true);
+ } catch (error) {
+ console.error('Failed to downvote:', error);
+ dispatchToast(
+
+ 投票失败,请稍后重试
+ ,
+ { intent: 'error' }
+ );
+ }
+ }}
+ >
+ {votes.downvotes}
+
+ }
+ appearance="transparent"
+ onClick={() => {}}
+ />
+ }
+ appearance="transparent"
+ onClick={() => {}}
+ />
+
+
+
+ );
+};
+
+export default PostCard;