Add timestamps and file upload precheck
This commit is contained in:
25
back/main.py
25
back/main.py
@@ -52,6 +52,7 @@ class Submission(db.Model):
|
|||||||
identity_token = db.Column(db.String(36), nullable=True)
|
identity_token = db.Column(db.String(36), nullable=True)
|
||||||
status = db.Column(db.String(20), default='Pending')
|
status = db.Column(db.String(20), default='Pending')
|
||||||
created_at = db.Column(db.DateTime, default=lambda: datetime.now())
|
created_at = db.Column(db.DateTime, default=lambda: datetime.now())
|
||||||
|
updated_at = db.Column(db.DateTime, default=lambda: datetime.now())
|
||||||
upvotes = db.Column(db.Integer, default=0)
|
upvotes = db.Column(db.Integer, default=0)
|
||||||
downvotes = db.Column(db.Integer, default=0)
|
downvotes = db.Column(db.Integer, default=0)
|
||||||
|
|
||||||
@@ -153,7 +154,9 @@ def get_settings():
|
|||||||
"footer_text": settings.footer_text,
|
"footer_text": settings.footer_text,
|
||||||
"repo_link": settings.repo_link,
|
"repo_link": settings.repo_link,
|
||||||
"enable_repo_button": settings.enable_repo_button,
|
"enable_repo_button": settings.enable_repo_button,
|
||||||
"enable_notice": settings.enable_notice
|
"enable_notice": settings.enable_notice,
|
||||||
|
"file_size_limit": settings.file_size_limit,
|
||||||
|
"file_formats": settings.file_formats
|
||||||
}
|
}
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"code": 1000,
|
"code": 1000,
|
||||||
@@ -252,10 +255,13 @@ def submit_post():
|
|||||||
identity_token = None
|
identity_token = None
|
||||||
|
|
||||||
# 保存
|
# 保存
|
||||||
|
now = datetime.now()
|
||||||
new_post = Submission(
|
new_post = Submission(
|
||||||
content=content,
|
content=content,
|
||||||
identity_token=identity_token,
|
identity_token=identity_token,
|
||||||
status='Pending' if NEED_AUDIT else 'Pass'
|
status='Pending' if NEED_AUDIT else 'Pass',
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
)
|
)
|
||||||
db.session.add(new_post)
|
db.session.add(new_post)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -349,6 +355,10 @@ def submit_report():
|
|||||||
submission_id = data.get('id')
|
submission_id = data.get('id')
|
||||||
title = data.get('title')
|
title = data.get('title')
|
||||||
content = data.get('content')
|
content = data.get('content')
|
||||||
|
title = str(title).strip() if title is not None else ''
|
||||||
|
content = str(content).strip() if content is not None else ''
|
||||||
|
if not title or not content:
|
||||||
|
return jsonify({"code": 2000, "data": "参数错误"})
|
||||||
|
|
||||||
submission = db.session.get(Submission, submission_id)
|
submission = db.session.get(Submission, submission_id)
|
||||||
if not submission:
|
if not submission:
|
||||||
@@ -363,8 +373,8 @@ def submit_report():
|
|||||||
|
|
||||||
report = Report(
|
report = Report(
|
||||||
submission_id=submission_id,
|
submission_id=submission_id,
|
||||||
title=str(title).strip() if title is not None else '',
|
title=title,
|
||||||
content=str(content).strip() if content is not None else '',
|
content=content,
|
||||||
identity_token=identity_token,
|
identity_token=identity_token,
|
||||||
status='Pending',
|
status='Pending',
|
||||||
)
|
)
|
||||||
@@ -399,7 +409,8 @@ def get_comments():
|
|||||||
"id": c.id,
|
"id": c.id,
|
||||||
"nickname": c.nickname,
|
"nickname": c.nickname,
|
||||||
"content": c.content,
|
"content": c.content,
|
||||||
"parent_comment_id": c.parent_comment_id if c.parent_comment_id is not None else 0
|
"parent_comment_id": c.parent_comment_id if c.parent_comment_id is not None else 0,
|
||||||
|
"time": c.created_at.isoformat() if c.created_at else None
|
||||||
} for c in pagination.items]
|
} for c in pagination.items]
|
||||||
|
|
||||||
return jsonify({"code": 1000, "data": {"comments": data, "total_pages": pagination.pages}})
|
return jsonify({"code": 1000, "data": {"comments": data, "total_pages": pagination.pages}})
|
||||||
@@ -549,6 +560,8 @@ def get_posts_info():
|
|||||||
"upvotes": s.upvotes,
|
"upvotes": s.upvotes,
|
||||||
"downvotes": s.downvotes,
|
"downvotes": s.downvotes,
|
||||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
"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),
|
"comment_count": len(s.comments),
|
||||||
"total_pages": pagination.total,
|
"total_pages": pagination.total,
|
||||||
})
|
})
|
||||||
@@ -577,6 +590,8 @@ def get_post_info():
|
|||||||
"upvotes": submission.upvotes,
|
"upvotes": submission.upvotes,
|
||||||
"downvotes": submission.downvotes,
|
"downvotes": submission.downvotes,
|
||||||
"created_at": submission.created_at.isoformat() if submission.created_at else None,
|
"created_at": submission.created_at.isoformat() if submission.created_at else None,
|
||||||
|
"time": submission.created_at.isoformat() if submission.created_at else None,
|
||||||
|
"modified": 0 if (not submission.updated_at or not submission.created_at or submission.updated_at == submission.created_at) else 1,
|
||||||
"comment_count": len(submission.comments),
|
"comment_count": len(submission.comments),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ 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}
|
||||||
|
time={article.time}
|
||||||
|
modified={article.modified}
|
||||||
onPreviewImage={onPreviewImage}
|
onPreviewImage={onPreviewImage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,6 +129,8 @@ 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}
|
||||||
|
time={article.time}
|
||||||
|
modified={article.modified}
|
||||||
onPreviewImage={onPreviewImage}
|
onPreviewImage={onPreviewImage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface SiteSettings {
|
|||||||
enableCodeIcon: boolean;
|
enableCodeIcon: boolean;
|
||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
favicon: string;
|
favicon: string;
|
||||||
|
fileSizeLimit?: number;
|
||||||
|
fileFormats?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSettings = async (): Promise<SiteSettings> => {
|
export const getSettings = async (): Promise<SiteSettings> => {
|
||||||
@@ -15,12 +17,29 @@ export const getSettings = async (): Promise<SiteSettings> => {
|
|||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (json.code === 1000 && json.data) {
|
if (json.code === 1000 && json.data) {
|
||||||
const data = json.data;
|
const data = json.data;
|
||||||
|
let fileFormats: string[] | undefined;
|
||||||
|
if (data.file_formats) {
|
||||||
|
if (Array.isArray(data.file_formats)) {
|
||||||
|
fileFormats = data.file_formats.map((x: any) => String(x).trim().replace(/^\./, '').toLowerCase()).filter((x: string) => x);
|
||||||
|
} else if (typeof data.file_formats === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.file_formats);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
fileFormats = parsed.map((x: any) => String(x).trim().replace(/^\./, '').toLowerCase()).filter((x: string) => x);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
siteTitle: data.title,
|
siteTitle: data.title,
|
||||||
siteFooter: data.footer_text,
|
siteFooter: data.footer_text,
|
||||||
enableCodeIcon: data.enable_repo_button ?? true,
|
enableCodeIcon: data.enable_repo_button ?? true,
|
||||||
repoUrl: data.repo_link,
|
repoUrl: data.repo_link,
|
||||||
favicon: data.icon,
|
favicon: data.icon,
|
||||||
|
fileSizeLimit: data.file_size_limit != null ? Number(data.file_size_limit) : undefined,
|
||||||
|
fileFormats,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid response code or missing data');
|
throw new Error('Invalid response code or missing data');
|
||||||
@@ -213,6 +232,8 @@ export interface Article {
|
|||||||
upvotes: number;
|
upvotes: number;
|
||||||
downvotes: number;
|
downvotes: number;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
time?: string;
|
||||||
|
modified?: number;
|
||||||
comment_count?: number;
|
comment_count?: number;
|
||||||
total_pages?: number;
|
total_pages?: number;
|
||||||
}
|
}
|
||||||
@@ -261,6 +282,7 @@ export interface Comment {
|
|||||||
nickname: string;
|
nickname: string;
|
||||||
content: string;
|
content: string;
|
||||||
parent_comment_id: number;
|
parent_comment_id: number;
|
||||||
|
time?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommentPage {
|
export interface CommentPage {
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ const useStyles = makeStyles({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: tokens.spacingVerticalXS,
|
marginTop: tokens.spacingVerticalXS,
|
||||||
},
|
},
|
||||||
|
commentTime: {
|
||||||
|
marginRight: tokens.spacingHorizontalS,
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
},
|
||||||
replyButton: {
|
replyButton: {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: tokens.colorBrandForeground1,
|
color: tokens.colorBrandForeground1,
|
||||||
@@ -236,6 +241,29 @@ useEffect(() => {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatTimeLabel = (iso?: string) => {
|
||||||
|
if (!iso) return '';
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
const now = new Date();
|
||||||
|
let diffMs = now.getTime() - date.getTime();
|
||||||
|
if (diffMs < 0) diffMs = 0;
|
||||||
|
const diffMinutes = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMinutes < 1) return '现在';
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const sameDay = now.toDateString() === date.toDateString();
|
||||||
|
if (sameDay) return `${diffHours}小时前`;
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
const diffMonths = Math.floor(diffDays / 30);
|
||||||
|
if (diffMonths >= 1 && diffMonths < 12) return `${diffMonths}个月前`;
|
||||||
|
if (diffDays >= 1 && diffMonths < 1) return `${diffDays}天前`;
|
||||||
|
const yyyy = date.getFullYear();
|
||||||
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${yyyy}年${mm}月${dd}日`;
|
||||||
|
};
|
||||||
|
|
||||||
const renderComments = (parentId: number = 0, level: number = 0) => {
|
const renderComments = (parentId: number = 0, level: number = 0) => {
|
||||||
return comments
|
return comments
|
||||||
.filter(comment => comment.parent_comment_id === parentId)
|
.filter(comment => comment.parent_comment_id === parentId)
|
||||||
@@ -267,6 +295,10 @@ useEffect(() => {
|
|||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.commentFooter}>
|
<div className={styles.commentFooter}>
|
||||||
|
{(() => {
|
||||||
|
const timeLabel = formatTimeLabel(comment.time);
|
||||||
|
return timeLabel ? <Text className={styles.commentTime}>{timeLabel}</Text> : null;
|
||||||
|
})()}
|
||||||
<Tooltip content="回复" relationship="label">
|
<Tooltip content="回复" relationship="label">
|
||||||
<div
|
<div
|
||||||
className={styles.replyButton}
|
className={styles.replyButton}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ const remarkTagPlugin = () => {
|
|||||||
const CreatePost: React.FC = () => {
|
const CreatePost: React.FC = () => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isDarkMode, toasterId, triggerRefresh } = useLayout();
|
const { isDarkMode, toasterId, triggerRefresh, settings } = useLayout();
|
||||||
const { dispatchToast } = useToastController(toasterId);
|
const { dispatchToast } = useToastController(toasterId);
|
||||||
const [value, setValue] = useState<string | undefined>("");
|
const [value, setValue] = useState<string | undefined>("");
|
||||||
const [lastSaved, setLastSaved] = useState<string>(() => new Date().toLocaleTimeString('zh-CN', { hour12: false }));
|
const [lastSaved, setLastSaved] = useState<string>(() => new Date().toLocaleTimeString('zh-CN', { hour12: false }));
|
||||||
@@ -226,6 +226,38 @@ const CreatePost: React.FC = () => {
|
|||||||
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
const limitMb = settings?.fileSizeLimit;
|
||||||
|
if (typeof limitMb === 'number' && limitMb > 0) {
|
||||||
|
const limitBytes = limitMb * 1024 * 1024;
|
||||||
|
if (file.size > limitBytes) {
|
||||||
|
dispatchToast(
|
||||||
|
<Toast>
|
||||||
|
<ToastTitle>上传的文件超出{limitMb}MB限制</ToastTitle>
|
||||||
|
</Toast>,
|
||||||
|
{ intent: 'error' }
|
||||||
|
);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const formats = settings?.fileFormats;
|
||||||
|
if (formats && formats.length > 0) {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||||
|
if (!ext || !formats.includes(ext)) {
|
||||||
|
dispatchToast(
|
||||||
|
<Toast>
|
||||||
|
<ToastTitle>上传的文件类型不支持</ToastTitle>
|
||||||
|
</Toast>,
|
||||||
|
{ intent: 'error' }
|
||||||
|
);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const url = await uploadImage(file);
|
const url = await uploadImage(file);
|
||||||
insertImageMarkdown(url, file.name || 'image');
|
insertImageMarkdown(url, file.name || 'image');
|
||||||
|
|||||||
@@ -151,6 +151,18 @@ const useStyles = makeStyles({
|
|||||||
justifyItems: 'center',
|
justifyItems: 'center',
|
||||||
gap: '0 8px',
|
gap: '0 8px',
|
||||||
},
|
},
|
||||||
|
footerRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
gap: tokens.spacingHorizontalM,
|
||||||
|
},
|
||||||
|
timeRow: {
|
||||||
|
color: tokens.colorNeutralForeground3,
|
||||||
|
fontSize: tokens.fontSizeBase200,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
},
|
||||||
actionButton: {
|
actionButton: {
|
||||||
'&:focus': {
|
'&:focus': {
|
||||||
outlineStyle: 'none',
|
outlineStyle: 'none',
|
||||||
@@ -194,6 +206,8 @@ interface PostCardProps {
|
|||||||
content: string;
|
content: string;
|
||||||
upvotes: number;
|
upvotes: number;
|
||||||
downvotes: number;
|
downvotes: number;
|
||||||
|
time?: string;
|
||||||
|
modified?: number;
|
||||||
onPreviewImage?: (src: string, alt?: string) => void;
|
onPreviewImage?: (src: string, alt?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +216,8 @@ const PostCard = ({
|
|||||||
content,
|
content,
|
||||||
upvotes,
|
upvotes,
|
||||||
downvotes,
|
downvotes,
|
||||||
|
time,
|
||||||
|
modified,
|
||||||
onPreviewImage,
|
onPreviewImage,
|
||||||
}: PostCardProps) => {
|
}: PostCardProps) => {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
@@ -217,6 +233,32 @@ const PostCard = ({
|
|||||||
setVotes({ upvotes, downvotes });
|
setVotes({ upvotes, downvotes });
|
||||||
}, [upvotes, downvotes]);
|
}, [upvotes, downvotes]);
|
||||||
|
|
||||||
|
const formatTimeLabel = (iso?: string) => {
|
||||||
|
if (!iso) return '';
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
const now = new Date();
|
||||||
|
let diffMs = now.getTime() - date.getTime();
|
||||||
|
if (diffMs < 0) diffMs = 0;
|
||||||
|
const diffMinutes = Math.floor(diffMs / 60000);
|
||||||
|
if (diffMinutes < 1) return '现在';
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const sameDay = now.toDateString() === date.toDateString();
|
||||||
|
if (sameDay) return `${diffHours}小时前`;
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
const diffMonths = Math.floor(diffDays / 30);
|
||||||
|
if (diffMonths >= 1 && diffMonths < 12) return `${diffMonths}个月前`;
|
||||||
|
if (diffDays >= 1 && diffMonths < 1) return `${diffDays}天前`;
|
||||||
|
const yyyy = date.getFullYear();
|
||||||
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${yyyy}年${mm}月${dd}日`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeLabel = formatTimeLabel(time);
|
||||||
|
const showModified = modified === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={styles.card}>
|
<Card className={styles.card}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -244,6 +286,7 @@ const PostCard = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
|
<div className={styles.footerRow}>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button
|
<Button
|
||||||
icon={<ArrowUp24Regular primaryFill={voteChoice === 'up' ? tokens.colorBrandForegroundLink : undefined} />}
|
icon={<ArrowUp24Regular primaryFill={voteChoice === 'up' ? tokens.colorBrandForegroundLink : undefined} />}
|
||||||
@@ -324,6 +367,12 @@ const PostCard = ({
|
|||||||
onClick={() => setShowReportModal(true)}
|
onClick={() => setShowReportModal(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{timeLabel && (
|
||||||
|
<div className={styles.timeRow}>
|
||||||
|
{showModified ? `已修改 · ${timeLabel}` : timeLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
{showComments && (
|
{showComments && (
|
||||||
<div className={styles.commentSection}>
|
<div className={styles.commentSection}>
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ const ReportPost: React.FC<ReportPostProps> = ({ onClose, postId }) => {
|
|||||||
const [content, setContent] = React.useState('');
|
const [content, setContent] = React.useState('');
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
dispatchToast(
|
||||||
|
<Toast>
|
||||||
|
<ToastTitle>投诉标题和内容不能为空</ToastTitle>
|
||||||
|
</Toast>,
|
||||||
|
{ intent: 'error' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await reportPost({ id: postId, title, content });
|
const response = await reportPost({ id: postId, title, content });
|
||||||
dispatchToast(
|
dispatchToast(
|
||||||
|
|||||||
Reference in New Issue
Block a user