Add v2 comments support and align APIs

This commit is contained in:
LeonspaceX
2026-01-26 17:34:35 +08:00
parent ffa6c927bf
commit f6d9521f80
7 changed files with 396 additions and 29 deletions

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { tokens } from '@fluentui/react-components';
import { tokens, useToastController, Toast, ToastTitle } from '@fluentui/react-components';
import { MainLayout } from './layouts/MainLayout';
import About from './components/About';
import CreatePost from './components/CreatePost';
@@ -11,6 +11,8 @@ import './App.css';
const Home: React.FC = () => {
const { refreshTrigger } = useLayout();
const { toasterId } = useLayout();
const { dispatchToast } = useToastController(toasterId);
const [articles, setArticles] = useState<Article[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
@@ -81,6 +83,12 @@ const Home: React.FC = () => {
setLoading(false);
if (refreshing) {
setRefreshing(false);
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'success' }
);
}
}
};

View File

@@ -211,3 +211,70 @@ export const voteArticle = async (id: number, type: 'up' | 'down'): Promise<void
throw error;
}
};
export interface Comment {
id: number;
nickname: string;
content: string;
parent_comment_id: number;
}
export const getComments = async (id: string | number): Promise<Comment[]> => {
try {
const response = await fetch(`/api/get/comment?id=${id}`);
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 Comment[];
}
throw new Error('Invalid response code or missing data');
} catch (error) {
console.error('Failed to fetch comments:', error);
throw error;
}
};
export interface PostCommentRequest {
content: string;
submission_id: number;
parent_comment_id: number;
nickname: string;
}
export interface PostCommentResponse {
id: number;
}
export const postComment = async (commentData: PostCommentRequest): Promise<PostCommentResponse> => {
try {
const identity = await get_id_token();
const response = await fetch('/api/comment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...commentData,
identity,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.code === 1001 && json.data?.id !== undefined) {
return { id: Number(json.data.id) };
}
if (json.code === 2005) {
throw new Error('评论包含违禁词');
}
throw new Error('Comment failed');
} catch (error) {
console.error('Failed to post comment:', error);
throw error;
}
};

View File

@@ -0,0 +1,257 @@
import React, { useEffect, useRef, useState } from 'react';
import {
makeStyles,
Button,
Input,
Text,
tokens,
Card,
Tooltip,
Divider,
useToastController,
Toast,
ToastTitle,
} from '@fluentui/react-components';
import { Dismiss24Regular, ArrowReply24Regular } from '@fluentui/react-icons';
import { getComments, postComment } from '../api';
import type { Comment as CommentType } from '../api';
import { useLayout } from '../context/LayoutContext';
const useStyles = makeStyles({
container: {
padding: tokens.spacingVerticalM,
width: '100%',
},
commentInput: {
marginBottom: tokens.spacingVerticalS,
width: '100%',
height: '40px',
fontSize: '16px',
},
commentButton: {
marginBottom: tokens.spacingVerticalM,
},
commentList: {
marginTop: tokens.spacingVerticalM,
},
commentCard: {
marginBottom: tokens.spacingVerticalS,
padding: tokens.spacingHorizontalM,
width: '100%',
},
childComment: {
marginLeft: tokens.spacingHorizontalL,
borderLeft: `2px solid ${tokens.colorNeutralStroke1}`,
paddingLeft: tokens.spacingHorizontalM,
},
commentHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: tokens.spacingVerticalXS,
},
nickname: {
fontWeight: 'bold',
},
commentFooter: {
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
marginTop: tokens.spacingVerticalXS,
},
replyButton: {
cursor: 'pointer',
color: tokens.colorBrandForeground1,
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalXS,
},
replyInfo: {
display: 'flex',
alignItems: 'center',
marginBottom: tokens.spacingVerticalXS,
},
cancelReply: {
marginLeft: tokens.spacingHorizontalS,
cursor: 'pointer',
},
inputContainer: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalS,
},
nicknameInput: {
width: '100%',
height: '40px',
fontSize: '16px',
},
});
interface CommentSectionProps {
postId: number;
}
const CommentSection: React.FC<CommentSectionProps> = ({ postId }) => {
const styles = useStyles();
const { toasterId } = useLayout();
const { dispatchToast } = useToastController(toasterId);
const [comments, setComments] = useState<CommentType[]>([]);
const [content, setContent] = useState('');
const [nickname, setNickname] = useState('');
const [replyTo, setReplyTo] = useState<CommentType | null>(null);
const [loading, setLoading] = useState(false);
const commentCardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
useEffect(() => {
fetchComments();
}, [postId]);
const fetchComments = async () => {
try {
setLoading(true);
const data = await getComments(postId);
setComments(data as CommentType[]);
} catch (error) {
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'error' }
);
console.error('Error fetching comments:', error);
} finally {
setLoading(false);
}
};
const handleSubmitComment = async () => {
if (!content.trim() || !nickname.trim()) {
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'error' }
);
return;
}
try {
setLoading(true);
await postComment({
submission_id: postId,
nickname,
content,
parent_comment_id: replyTo ? replyTo.id : 0,
});
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'success' }
);
setContent('');
if (replyTo) setReplyTo(null);
fetchComments();
} catch (error: any) {
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'error' }
);
console.error('Error posting comment:', error);
} finally {
setLoading(false);
}
};
const handleReply = (comment: CommentType) => {
setReplyTo(comment);
};
const cancelReply = () => {
setReplyTo(null);
};
const renderComments = (parentId: number = 0, level: number = 0) => {
return comments
.filter(comment => comment.parent_comment_id === parentId)
.map(comment => (
<div
key={comment.id}
className={level > 0 ? styles.childComment : ''}
ref={el => {
if (el) commentCardRefs.current.set(comment.id, el);
}}
>
<Card className={styles.commentCard}>
<div className={styles.commentHeader}>
<Text className={styles.nickname}>{comment.nickname}</Text>
</div>
<Text>{comment.content}</Text>
<div className={styles.commentFooter}>
<Tooltip content="回复" relationship="label">
<div
className={styles.replyButton}
onClick={() => handleReply(comment)}
>
<ArrowReply24Regular />
<Text size={200}></Text>
</div>
</Tooltip>
</div>
</Card>
{renderComments(comment.id, level + 1)}
</div>
));
};
return (
<div className={styles.container}>
<div className={styles.inputContainer}>
<Input
className={styles.nicknameInput}
placeholder="输入昵称"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
/>
{replyTo && (
<div className={styles.replyInfo}>
<Text>{replyTo.nickname}</Text>
<Dismiss24Regular
className={styles.cancelReply}
onClick={cancelReply}
/>
</div>
)}
<Input
className={styles.commentInput}
placeholder="输入评论"
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<Button
className={styles.commentButton}
appearance="primary"
onClick={handleSubmitComment}
disabled={loading || !content.trim() || !nickname.trim()}
>
</Button>
</div>
<Divider />
<div className={styles.commentList}>
{loading && <Text>..</Text>}
{!loading && comments.length === 0 && <Text></Text>}
{renderComments()}
</div>
</div>
);
};
export default CommentSection;

View File

@@ -20,6 +20,7 @@ import {
Warning24Regular,
} from '@fluentui/react-icons';
import { useLayout } from '../context/LayoutContext';
import CommentSection from './CommentSection';
// 自定义 remark 插件,用于高亮 #tag
const remarkTagPlugin = () => {
@@ -149,6 +150,11 @@ const useStyles = makeStyles({
justifyItems: 'center',
gap: '0 8px',
},
commentSection: {
marginTop: tokens.spacingVerticalM,
borderTop: `1px solid ${tokens.colorNeutralStroke1}`,
paddingTop: tokens.spacingVerticalM,
},
});
interface PostCardProps {
@@ -171,6 +177,7 @@ const PostCard = ({
const { dispatchToast } = useToastController(toasterId);
const [votes, setVotes] = React.useState({ upvotes, downvotes });
const [hasVoted, setHasVoted] = React.useState(false);
const [showComments, setShowComments] = React.useState(false);
React.useEffect(() => {
setVotes({ upvotes, downvotes });
@@ -267,7 +274,7 @@ const PostCard = ({
<Button
icon={<Comment24Regular />}
appearance="transparent"
onClick={() => {}}
onClick={() => setShowComments(!showComments)}
/>
<Button
icon={<Warning24Regular />}
@@ -276,6 +283,11 @@ const PostCard = ({
/>
</div>
</CardFooter>
{showComments && (
<div className={styles.commentSection}>
<CommentSection postId={id} />
</div>
)}
</Card>
);
};