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 @@
# 这里是Sycamore whisper的后端代码喵 # 这里是Sycamore whisper的后端代码喵
# 但愿比V1写的好喵 # 但愿比V1写的好喵
from flask import Flask, jsonify, request from flask import Flask, jsonify, request, abort
from flask_cors import CORS from flask_cors import CORS
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
import os import os
@@ -56,11 +56,10 @@ class Comment(db.Model):
__tablename__ = 'comments' __tablename__ = 'comments'
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
submission_id = db.Column(db.Integer, db.ForeignKey('submissions.id'), nullable=False) 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) content = db.Column(db.Text, nullable=False)
identity_token = db.Column(db.String(36), nullable=True) identity_token = db.Column(db.String(36), nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now()) 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) parent_comment_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True)
class Hashtag(db.Model): class Hashtag(db.Model):
@@ -192,6 +191,7 @@ def submit_post():
return jsonify({"code": 2000, "data": "内容不能为空"}) return jsonify({"code": 2000, "data": "内容不能为空"})
content = data['content'] content = data['content']
nickname = data.get('nickname') or '匿名用户'
hashtopic = data.get('hashtopic', []) hashtopic = data.get('hashtopic', [])
if not isinstance(hashtopic, list): if not isinstance(hashtopic, list):
@@ -245,6 +245,12 @@ def submit_comment():
submission_id = data['submission_id'] submission_id = data['submission_id']
content = data['content'] 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', []) hashtopic = data.get('hashtopic', [])
if not isinstance(hashtopic, list): if not isinstance(hashtopic, list):
@@ -253,8 +259,8 @@ def submit_comment():
identity_token = data.get('identity') identity_token = data.get('identity')
# 检查 submission 是否存在 # 检查 submission 是否存在
if not Submission.query.get(submission_id): if not db.session.get(Submission, submission_id):
return jsonify({"code": 2004, "data": "投稿不存在"}) return jsonify({"code": 2002, "data": "投稿不存在"})
# 违禁词检测 # 违禁词检测
for word in DENY_WORDS_CACHE: for word in DENY_WORDS_CACHE:
@@ -270,8 +276,10 @@ def submit_comment():
new_comment = Comment( new_comment = Comment(
submission_id=submission_id, submission_id=submission_id,
nickname=nickname,
content=content, 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.add(new_comment)
db.session.commit() db.session.commit()
@@ -291,6 +299,28 @@ def submit_comment():
except Exception as e: except Exception as e:
return jsonify({"code": 2003, "data": f"评论失败: {str(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']) @app.route('/api/up', methods=['POST'])
def upvote(): def upvote():
try: try:
@@ -301,17 +331,14 @@ def upvote():
item_id = data['id'] item_id = data['id']
item_type = data['type'] item_type = data['type']
item = None
if item_type == 'submission': if item_type == 'submission':
item = db.session.get(Submission, item_id) item = db.session.get(Submission, item_id)
elif item_type == 'comment': if not item:
item = db.session.get(Comment, item_id) return jsonify({"code": 2002, "data": "对象不存在"})
item.upvotes += 1
if not item: db.session.commit()
return jsonify({"code": 2004, "data": "对象不存在"}) else:
return jsonify({"code": 2000, "data": "参数错误"})
item.upvotes += 1
db.session.commit()
return jsonify({"code": 1000, "data": ""}) return jsonify({"code": 1000, "data": ""})
except Exception as e: except Exception as e:
@@ -327,17 +354,14 @@ def downvote():
item_id = data['id'] item_id = data['id']
item_type = data['type'] item_type = data['type']
item = None
if item_type == 'submission': if item_type == 'submission':
item = db.session.get(Submission, item_id) item = db.session.get(Submission, item_id)
elif item_type == 'comment': if not item:
item = db.session.get(Comment, item_id) return jsonify({"code": 2002, "data": "对象不存在"})
item.downvotes += 1
if not item: db.session.commit()
return jsonify({"code": 2004, "data": "对象不存在"}) else:
return jsonify({"code": 2000, "data": "参数错误"})
item.downvotes += 1
db.session.commit()
return jsonify({"code": 1000, "data": ""}) return jsonify({"code": 1000, "data": ""})
except Exception as e: except Exception as e:

View File

@@ -35,7 +35,7 @@
| 2001 | 失败。未初始化。不应该在成功初始化后继续使用该code。 | | 2001 | 失败。未初始化。不应该在成功初始化后继续使用该code。 |
| 2002 | 失败。数据不存在。 | | 2002 | 失败。数据不存在。 |
| 2003 | 失败。服务器内部错误。 | | 2003 | 失败。服务器内部错误。 |
| 2004 | 失败。试图请求不存在的资源或使用不存在的Identity。 | | 2004 | 失败。试图使用不存在的Identity。 |
| 2005 | 失败。提交内容包含违禁词。 | | 2005 | 失败。提交内容包含违禁词。 |
| 404 | api端点不存在。 | | 404 | api端点不存在。 |
| | | | | |

View File

@@ -1,5 +1,4 @@
<!--这里没有有用的东西哦(--> <!--既然都看到这里了,有没有兴趣加入开发喵!-->
<!--不过既然都看到这里了,有没有兴趣加入开发喵!-->
<!--https://github.com/Sycamore-Whisper/v2--> <!--https://github.com/Sycamore-Whisper/v2-->
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom'; 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 { MainLayout } from './layouts/MainLayout';
import About from './components/About'; import About from './components/About';
import CreatePost from './components/CreatePost'; import CreatePost from './components/CreatePost';
@@ -11,6 +11,8 @@ import './App.css';
const Home: React.FC = () => { const Home: React.FC = () => {
const { refreshTrigger } = useLayout(); const { refreshTrigger } = useLayout();
const { toasterId } = useLayout();
const { dispatchToast } = useToastController(toasterId);
const [articles, setArticles] = useState<Article[]>([]); const [articles, setArticles] = useState<Article[]>([]);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -81,6 +83,12 @@ const Home: React.FC = () => {
setLoading(false); setLoading(false);
if (refreshing) { if (refreshing) {
setRefreshing(false); 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; 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, Warning24Regular,
} from '@fluentui/react-icons'; } from '@fluentui/react-icons';
import { useLayout } from '../context/LayoutContext'; import { useLayout } from '../context/LayoutContext';
import CommentSection from './CommentSection';
// 自定义 remark 插件,用于高亮 #tag // 自定义 remark 插件,用于高亮 #tag
const remarkTagPlugin = () => { const remarkTagPlugin = () => {
@@ -149,6 +150,11 @@ const useStyles = makeStyles({
justifyItems: 'center', justifyItems: 'center',
gap: '0 8px', gap: '0 8px',
}, },
commentSection: {
marginTop: tokens.spacingVerticalM,
borderTop: `1px solid ${tokens.colorNeutralStroke1}`,
paddingTop: tokens.spacingVerticalM,
},
}); });
interface PostCardProps { interface PostCardProps {
@@ -171,6 +177,7 @@ const PostCard = ({
const { dispatchToast } = useToastController(toasterId); const { dispatchToast } = useToastController(toasterId);
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 [showComments, setShowComments] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
setVotes({ upvotes, downvotes }); setVotes({ upvotes, downvotes });
@@ -267,7 +274,7 @@ const PostCard = ({
<Button <Button
icon={<Comment24Regular />} icon={<Comment24Regular />}
appearance="transparent" appearance="transparent"
onClick={() => {}} onClick={() => setShowComments(!showComments)}
/> />
<Button <Button
icon={<Warning24Regular />} icon={<Warning24Regular />}
@@ -276,6 +283,11 @@ const PostCard = ({
/> />
</div> </div>
</CardFooter> </CardFooter>
{showComments && (
<div className={styles.commentSection}>
<CommentSection postId={id} />
</div>
)}
</Card> </Card>
); );
}; };