Add timestamps and file upload precheck

This commit is contained in:
LeonspaceX
2026-01-30 17:44:04 +08:00
parent fcc4b95f7a
commit abc8d58bf4
7 changed files with 170 additions and 7 deletions

View File

@@ -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),
} }

View File

@@ -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}
/> />
); );

View File

@@ -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 {

View File

@@ -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}

View File

@@ -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');

View File

@@ -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}>

View File

@@ -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(