新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。

作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 16:58:22 +08:00
parent 9487c8ab02
commit 822eef96be
83 changed files with 8578 additions and 1222 deletions

View File

@@ -1,7 +1,19 @@
import { Clock, MessageSquare, X } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Clock, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import type { Comment } from '../api/types';
import type { Comment, User } from '../api/types';
import CommentContent from './CommentContent';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
commentNick,
commentInitial,
@@ -10,33 +22,75 @@ import {
buildCommentTree,
type CommentNode,
} from '../utils/comment';
import { isTimeDiffSignificant } from '../utils/content';
import { useForumLimits } from '../hooks/useForumLimits';
function canManageComment(c: Comment, user?: User | null): boolean {
if (!user) return false;
if (user.role === 'admin') return true;
return c.user_id > 0 && c.user_id === user.id;
}
interface ItemProps {
node: CommentNode;
nested?: boolean;
highlightFloor?: number | null;
replyToId?: number | null;
editingId?: number | null;
currentUser?: User | null;
onReply: (comment: Comment) => void;
onCancelReply: () => void;
onStartEdit: (comment: Comment) => void;
onCancelEdit: () => void;
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
onDelete: (comment: Comment) => Promise<void>;
renderReplyBox?: (comment: Comment) => ReactNode;
}
/** 单条评论(支持嵌套子回复 + 内联回复框) */
/** 单条评论(支持嵌套子回复 + 内联回复框 + 编辑/删除 */
function CommentItem({
node,
nested,
highlightFloor,
replyToId,
editingId,
currentUser,
onReply,
onCancelReply,
onStartEdit,
onCancelEdit,
onSaveEdit,
onDelete,
renderReplyBox,
}: ItemProps) {
const { limits } = useForumLimits();
const c = node.comment;
const nick = commentNick(c);
const guest = isGuestComment(c);
const isHighlighted = highlightFloor === c.floor;
const hidden = !!c.content_hidden;
const isReplying = replyToId === c.id;
const isEditing = editingId === c.id;
const manageable = canManageComment(c, currentUser);
const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at);
const [editText, setEditText] = useState(c.content);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (isEditing) setEditText(c.content);
}, [isEditing, c.content, c.id]);
const handleSave = async () => {
const next = editText.trim();
if (!next) return;
setSaving(true);
try {
await onSaveEdit(c, next);
} finally {
setSaving(false);
}
};
return (
<div
@@ -66,6 +120,29 @@ function CommentItem({
<div className="waline-comment-private-mask">
</div>
) : isEditing ? (
<div className="waline-comment-edit">
<textarea
className="waline-comment-edit-input"
value={editText}
onChange={e => setEditText(e.target.value)}
rows={3}
maxLength={limits.comment_max > 0 ? limits.comment_max : undefined}
/>
<div className="waline-comment-edit-actions">
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelEdit} disabled={saving}>
</button>
<button
type="button"
className="waline-comment-reply-btn"
onClick={handleSave}
disabled={saving || !editText.trim()}
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
) : (
<div className="waline-comment-bubble">
{c.reply_target && (
@@ -79,18 +156,58 @@ function CommentItem({
<span className="waline-comment-date">
<Clock size={14} />
{formatCommentDate(c.created_at)}
{showEdited && <span className="waline-comment-edited"> · </span>}
</span>
{isReplying ? (
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
<X size={14} />
</button>
) : (
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
<MessageSquare size={14} />
{!hidden && !isEditing && (
isReplying ? (
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
<X size={14} />
</button>
) : (
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
<MessageSquare size={14} />
</button>
)
)}
{!hidden && !isEditing && manageable && (
<button type="button" className="waline-comment-reply-btn" onClick={() => onStartEdit(c)}>
<Pencil size={14} />
</button>
)}
{!hidden && !isEditing && manageable && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button type="button" className="waline-comment-reply-btn cancel" disabled={deleting}>
<Trash2 size={14} />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription></AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
setDeleting(true);
try {
await onDelete(c);
} finally {
setDeleting(false);
}
}}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
{isReplying && renderReplyBox && (
@@ -108,8 +225,14 @@ function CommentItem({
nested
highlightFloor={highlightFloor}
replyToId={replyToId}
editingId={editingId}
currentUser={currentUser}
onReply={onReply}
onCancelReply={onCancelReply}
onStartEdit={onStartEdit}
onCancelEdit={onCancelEdit}
onSaveEdit={onSaveEdit}
onDelete={onDelete}
renderReplyBox={renderReplyBox}
/>
))}
@@ -124,8 +247,14 @@ interface Props {
comments: Comment[];
highlightFloor?: number | null;
replyToId?: number | null;
editingId?: number | null;
currentUser?: User | null;
onReply: (comment: Comment) => void;
onCancelReply: () => void;
onStartEdit: (comment: Comment) => void;
onCancelEdit: () => void;
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
onDelete: (comment: Comment) => Promise<void>;
renderReplyBox?: (comment: Comment) => ReactNode;
}
@@ -134,8 +263,14 @@ export default function CommentThreadList({
comments,
highlightFloor,
replyToId,
editingId,
currentUser,
onReply,
onCancelReply,
onStartEdit,
onCancelEdit,
onSaveEdit,
onDelete,
renderReplyBox,
}: Props) {
const tree = buildCommentTree(comments);
@@ -148,8 +283,14 @@ export default function CommentThreadList({
node={node}
highlightFloor={highlightFloor}
replyToId={replyToId}
editingId={editingId}
currentUser={currentUser}
onReply={onReply}
onCancelReply={onCancelReply}
onStartEdit={onStartEdit}
onCancelEdit={onCancelEdit}
onSaveEdit={onSaveEdit}
onDelete={onDelete}
renderReplyBox={renderReplyBox}
/>
))}