diff --git a/back/main.py b/back/main.py index 17c9ada..0452797 100644 --- a/back/main.py +++ b/back/main.py @@ -1,6 +1,6 @@ # 这里是Sycamore whisper的后端代码喵! # 但愿比V1写的好喵(逃 -from flask import Flask, jsonify, request +from flask import Flask, jsonify, request, abort from flask_cors import CORS from flask_sqlalchemy import SQLAlchemy import os @@ -56,11 +56,10 @@ class Comment(db.Model): __tablename__ = 'comments' id = db.Column(db.Integer, primary_key=True, autoincrement=True) submission_id = db.Column(db.Integer, db.ForeignKey('submissions.id'), nullable=False) + nickname = db.Column(db.String(50), default='匿名用户') content = db.Column(db.Text, nullable=False) identity_token = db.Column(db.String(36), nullable=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now()) - upvotes = db.Column(db.Integer, default=0) - downvotes = db.Column(db.Integer, default=0) parent_comment_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True) class Hashtag(db.Model): @@ -192,6 +191,7 @@ def submit_post(): return jsonify({"code": 2000, "data": "内容不能为空"}) content = data['content'] + nickname = data.get('nickname') or '匿名用户' hashtopic = data.get('hashtopic', []) if not isinstance(hashtopic, list): @@ -245,6 +245,12 @@ def submit_comment(): submission_id = data['submission_id'] content = data['content'] + nickname = data.get('nickname') or '匿名用户' + parent_comment_id = data.get('parent_comment_id', 0) + try: + parent_comment_id = int(parent_comment_id) + except Exception: + parent_comment_id = 0 hashtopic = data.get('hashtopic', []) if not isinstance(hashtopic, list): @@ -253,8 +259,8 @@ def submit_comment(): identity_token = data.get('identity') # 检查 submission 是否存在 - if not Submission.query.get(submission_id): - return jsonify({"code": 2004, "data": "投稿不存在"}) + if not db.session.get(Submission, submission_id): + return jsonify({"code": 2002, "data": "投稿不存在"}) # 违禁词检测 for word in DENY_WORDS_CACHE: @@ -270,8 +276,10 @@ def submit_comment(): new_comment = Comment( submission_id=submission_id, + nickname=nickname, content=content, - identity_token=identity_token + identity_token=identity_token, + parent_comment_id=None if parent_comment_id == 0 else parent_comment_id ) db.session.add(new_comment) db.session.commit() @@ -291,6 +299,28 @@ def submit_comment(): except Exception as e: return jsonify({"code": 2003, "data": f"评论失败: {str(e)}"}) +@app.route('/api/get/comment', methods=['GET']) +def get_comments(): + try: + submission_id = request.args.get("id", type=int) + if not submission_id: + return jsonify({"code": 2000, "data": "参数错误"}) + + submission = db.session.get(Submission, submission_id) + if not submission or submission.status != "Pass": + return jsonify({"code": 2002, "data": "投稿不存在"}) + + data = [{ + "id": c.id, + "nickname": c.nickname, + "content": c.content, + "parent_comment_id": c.parent_comment_id if c.parent_comment_id is not None else 0 + } for c in submission.comments] + + return jsonify({"code": 1000, "data": data}) + except Exception as e: + return jsonify({"code": 2003, "data": str(e)}) + @app.route('/api/up', methods=['POST']) def upvote(): try: @@ -301,17 +331,14 @@ def upvote(): item_id = data['id'] item_type = data['type'] - item = None if item_type == 'submission': item = db.session.get(Submission, item_id) - elif item_type == 'comment': - item = db.session.get(Comment, item_id) - - if not item: - return jsonify({"code": 2004, "data": "对象不存在"}) - - item.upvotes += 1 - db.session.commit() + if not item: + return jsonify({"code": 2002, "data": "对象不存在"}) + item.upvotes += 1 + db.session.commit() + else: + return jsonify({"code": 2000, "data": "参数错误"}) return jsonify({"code": 1000, "data": ""}) except Exception as e: @@ -327,17 +354,14 @@ def downvote(): item_id = data['id'] item_type = data['type'] - item = None if item_type == 'submission': item = db.session.get(Submission, item_id) - elif item_type == 'comment': - item = db.session.get(Comment, item_id) - - if not item: - return jsonify({"code": 2004, "data": "对象不存在"}) - - item.downvotes += 1 - db.session.commit() + if not item: + return jsonify({"code": 2002, "data": "对象不存在"}) + item.downvotes += 1 + db.session.commit() + else: + return jsonify({"code": 2000, "data": "参数错误"}) return jsonify({"code": 1000, "data": ""}) except Exception as e: diff --git a/code_meaning.md b/code_meaning.md index abbe917..2ca3030 100644 --- a/code_meaning.md +++ b/code_meaning.md @@ -35,7 +35,7 @@ | 2001 | 失败。未初始化。不应该在成功初始化后继续使用该code。 | | 2002 | 失败。数据不存在。 | | 2003 | 失败。服务器内部错误。 | -| 2004 | 失败。试图请求不存在的资源或使用不存在的Identity。 | +| 2004 | 失败。试图使用不存在的Identity。 | | 2005 | 失败。提交内容包含违禁词。 | | 404 | api端点不存在。 | | | | diff --git a/front/index.html b/front/index.html index 05e3829..f22f907 100644 --- a/front/index.html +++ b/front/index.html @@ -1,5 +1,4 @@ - - + diff --git a/front/src/App.tsx b/front/src/App.tsx index 2acfb15..b007f65 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -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([]); 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( + + 刷新成功 + , + { intent: 'success' } + ); } } }; diff --git a/front/src/api.ts b/front/src/api.ts index 11f0af8..c1557b9 100644 --- a/front/src/api.ts +++ b/front/src/api.ts @@ -211,3 +211,70 @@ export const voteArticle = async (id: number, type: 'up' | 'down'): Promise => { + 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 => { + 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; + } +}; diff --git a/front/src/components/CommentSection.tsx b/front/src/components/CommentSection.tsx new file mode 100644 index 0000000..9527f75 --- /dev/null +++ b/front/src/components/CommentSection.tsx @@ -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 = ({ postId }) => { + const styles = useStyles(); + const { toasterId } = useLayout(); + const { dispatchToast } = useToastController(toasterId); + const [comments, setComments] = useState([]); + const [content, setContent] = useState(''); + const [nickname, setNickname] = useState(''); + const [replyTo, setReplyTo] = useState(null); + const [loading, setLoading] = useState(false); + const commentCardRefs = useRef>(new Map()); + + useEffect(() => { + fetchComments(); + }, [postId]); + + const fetchComments = async () => { + try { + setLoading(true); + const data = await getComments(postId); + setComments(data as CommentType[]); + } catch (error) { + dispatchToast( + + 获取评论失败 + , + { intent: 'error' } + ); + console.error('Error fetching comments:', error); + } finally { + setLoading(false); + } + }; + + const handleSubmitComment = async () => { + if (!content.trim() || !nickname.trim()) { + dispatchToast( + + 评论内容和昵称不能为空 + , + { intent: 'error' } + ); + return; + } + + try { + setLoading(true); + await postComment({ + submission_id: postId, + nickname, + content, + parent_comment_id: replyTo ? replyTo.id : 0, + }); + dispatchToast( + + 评论成功 + , + { intent: 'success' } + ); + setContent(''); + if (replyTo) setReplyTo(null); + fetchComments(); + } catch (error: any) { + dispatchToast( + + 评论失败 + , + { 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 => ( +
0 ? styles.childComment : ''} + ref={el => { + if (el) commentCardRefs.current.set(comment.id, el); + }} + > + +
+ {comment.nickname} +
+ {comment.content} +
+ +
handleReply(comment)} + > + + 回复 +
+
+
+
+ {renderComments(comment.id, level + 1)} +
+ )); + }; + + return ( +
+
+ setNickname(e.target.value)} + /> + + {replyTo && ( +
+ 回复:{replyTo.nickname} + +
+ )} + + setContent(e.target.value)} + /> + + +
+ + + +
+ {loading && 加载评论中..} + {!loading && comments.length === 0 && 暂无评论} + {renderComments()} +
+
+ ); +}; + +export default CommentSection; diff --git a/front/src/components/PostCard.tsx b/front/src/components/PostCard.tsx index 1809128..ed40617 100644 --- a/front/src/components/PostCard.tsx +++ b/front/src/components/PostCard.tsx @@ -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 = ({