升级帖子编辑与 Feed 体验:TipTap 富文本、修订历史、排序与编辑时限。
将 Markdown 编辑器替换为 TipTap WYSIWYG,新增帖子修订记录与 diff 展示;首页支持最新/回复排序与本地缓存;后台可配置编辑时限与锁定帖子;侧边栏整合板块导航并优化 Feed 布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,9 +5,9 @@ import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import type { Board } from '../api/types';
|
||||
import { isHtmlEmpty } from '../utils/postContent';
|
||||
import ArticleEditor from '../components/ArticleEditor';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { markdownToHtml, htmlToMarkdown } from '../utils/markdown';
|
||||
|
||||
export default function ComposePage() {
|
||||
const nav = useNavigate();
|
||||
@@ -37,16 +37,21 @@ export default function ComposePage() {
|
||||
const list = boardsData.boards ?? [];
|
||||
setBoards(list);
|
||||
const post = postData.post;
|
||||
const canEdit = user.role === 'admin' || post.user_id === user.id;
|
||||
if (!canEdit) {
|
||||
const isOwnerOrAdmin = user.role === 'admin' || post.user_id === user.id;
|
||||
if (!isOwnerOrAdmin) {
|
||||
notify.error('无权编辑此帖子');
|
||||
nav(`/post/${editId}`);
|
||||
return;
|
||||
}
|
||||
if (!postData.can_edit) {
|
||||
notify.error(postData.edit_block_reason || '当前无法编辑此帖子');
|
||||
nav(`/post/${editId}`);
|
||||
return;
|
||||
}
|
||||
setBoardId(String(post.board_id));
|
||||
setTitle(post.title);
|
||||
setTags(post.tags ?? '');
|
||||
setContent(htmlToMarkdown(post.content ?? ''));
|
||||
setContent(post.content ?? '');
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notify.error(e instanceof Error ? e.message : '加载帖子失败');
|
||||
@@ -108,13 +113,13 @@ export default function ComposePage() {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!isEdit && !boardId) { notify.warning('请选择板块'); return; }
|
||||
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
|
||||
if (!content.trim()) { notify.warning('请输入正文内容'); return; }
|
||||
if (isHtmlEmpty(content)) { notify.warning('请输入正文内容'); return; }
|
||||
|
||||
setPublishing(true);
|
||||
try {
|
||||
const payload = {
|
||||
title: trimmedTitle,
|
||||
content: markdownToHtml(content),
|
||||
content: content.trim(),
|
||||
tags: tags.trim(),
|
||||
};
|
||||
if (isEdit) {
|
||||
@@ -204,7 +209,7 @@ export default function ComposePage() {
|
||||
<ArticleEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder="开始写作。支持 Markdown 语法,右侧可实时预览渲染效果。"
|
||||
placeholder="开始写作。所见即所得,选中文字后使用工具栏设置格式。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
@@ -6,7 +6,8 @@ import type { PostItem } from '../api/types';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import VirtualPostList from '../components/VirtualPostList';
|
||||
import FeedHeader from '../components/FeedHeader';
|
||||
import BoardGrid from '../components/BoardGrid';
|
||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||
import { getFeedCache, setFeedCache, clearAllFeedCache } from '../utils/feedCache';
|
||||
|
||||
export default function HomePage() {
|
||||
const nav = useNavigate();
|
||||
@@ -14,17 +15,27 @@ export default function HomePage() {
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||
const keyword = params.get('keyword') || '';
|
||||
const sort = parseFeedSort(params.get('sort'));
|
||||
const initialCache = getFeedCache(boardId, keyword, sort);
|
||||
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [postTotal, setPostTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [posts, setPosts] = useState<PostItem[]>(() => initialCache?.posts ?? []);
|
||||
const [postTotal, setPostTotal] = useState(() => initialCache?.postTotal ?? 0);
|
||||
const [page, setPage] = useState(() => initialCache?.page ?? 1);
|
||||
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
|
||||
const [loading, setLoading] = useState(() => !initialCache);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
|
||||
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
|
||||
|
||||
const load = useCallback(async (p: number, reset = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.posts({ page: p, size: 30, board_id: boardId || '', keyword });
|
||||
const data = await api.posts({
|
||||
page: p,
|
||||
size: 30,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
// 切换筛选时保留旧列表,避免中间区域瞬间空白
|
||||
setPosts(prev => (reset ? batch : [...prev, ...batch]));
|
||||
@@ -37,50 +48,113 @@ export default function HomePage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [boardId, keyword]);
|
||||
}, [boardId, keyword, sort]);
|
||||
|
||||
/** 有缓存时静默拉取已加载页,更新置顶等状态同时保留滚动位置 */
|
||||
const revalidate = useCallback(async (maxPage: number) => {
|
||||
try {
|
||||
const all: PostItem[] = [];
|
||||
let total = 0;
|
||||
let hasMore = true;
|
||||
for (let p = 1; p <= maxPage; p++) {
|
||||
const data = await api.posts({
|
||||
page: p,
|
||||
size: 30,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
all.push(...batch);
|
||||
total = data.total ?? 0;
|
||||
hasMore = !!data.has_more;
|
||||
if (!data.has_more) break;
|
||||
}
|
||||
setPosts(all);
|
||||
setPostTotal(total);
|
||||
setHasMore(hasMore);
|
||||
setPage(maxPage);
|
||||
} catch {
|
||||
// 静默失败,保留缓存数据
|
||||
}
|
||||
}, [boardId, keyword, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
const cached = getFeedCache(boardId, keyword, sort);
|
||||
if (cached) {
|
||||
setPosts(cached.posts);
|
||||
setPostTotal(cached.postTotal);
|
||||
setPage(cached.page);
|
||||
setHasMore(cached.hasMore);
|
||||
setRestoreScrollTop(cached.scrollTop);
|
||||
scrollTopRef.current = cached.scrollTop;
|
||||
setLoading(false);
|
||||
revalidate(cached.page);
|
||||
return;
|
||||
}
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
load(1, true);
|
||||
}, [boardId, keyword, load]);
|
||||
}, [boardId, keyword, sort, load, revalidate]);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => load(1, true);
|
||||
return () => {
|
||||
if (posts.length === 0) return;
|
||||
setFeedCache(boardId, keyword, sort, {
|
||||
posts,
|
||||
postTotal,
|
||||
page,
|
||||
hasMore,
|
||||
scrollTop: scrollTopRef.current,
|
||||
});
|
||||
};
|
||||
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => {
|
||||
clearAllFeedCache();
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
load(1, true);
|
||||
};
|
||||
window.addEventListener('posts-refresh', fn);
|
||||
return () => window.removeEventListener('posts-refresh', fn);
|
||||
}, [load]);
|
||||
}, [boardId, keyword, sort, load]);
|
||||
|
||||
const showBoardGrid = !keyword;
|
||||
const handleSortChange = (next: FeedSort) => {
|
||||
if (next === sort) return;
|
||||
clearAllFeedCache();
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
nav(buildHomeUrl(boardId, next));
|
||||
};
|
||||
|
||||
const showSortBar = !keyword;
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
boards={ctx?.boards ?? []}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
/>
|
||||
{showBoardGrid && (
|
||||
<BoardGrid
|
||||
<div className="feed-top">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
boards={ctx?.boards ?? []}
|
||||
loading={!ctx?.layoutReady}
|
||||
selectedId={boardId}
|
||||
onSelect={(id) => {
|
||||
ctx?.setBoardId(id);
|
||||
nav(id ? `/?board=${id}` : '/');
|
||||
}}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
/>
|
||||
)}
|
||||
<div className="post-list-bar">
|
||||
<span>{keyword ? '搜索结果' : '帖子列表'}</span>
|
||||
<span>共 {postTotal} 条</span>
|
||||
{showSortBar && (
|
||||
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
|
||||
)}
|
||||
</div>
|
||||
<VirtualPostList
|
||||
posts={posts}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={() => !loading && hasMore && load(page + 1)}
|
||||
onSelect={(id) => nav(`/post/${id}`)}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin } from 'lucide-react';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock } from 'lucide-react';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
@@ -10,9 +11,11 @@ import type { PostItem, Comment } from '../api/types';
|
||||
import CommentThreadList from '../components/CommentThreadList';
|
||||
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
|
||||
import PostContent from '../components/PostContent';
|
||||
import PostRevisionPanel from '../components/PostRevisionPanel';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
||||
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
|
||||
import { clearAllFeedCache } from '../utils/feedCache';
|
||||
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
||||
|
||||
export default function PostDetailPage() {
|
||||
@@ -30,6 +33,10 @@ export default function PostDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
|
||||
const [submitCount, setSubmitCount] = useState(0);
|
||||
const [canEdit, setCanEdit] = useState(false);
|
||||
const [isEdited, setIsEdited] = useState(false);
|
||||
const [editBlockReason, setEditBlockReason] = useState('');
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
@@ -55,6 +62,9 @@ export default function PostDetailPage() {
|
||||
setPost(detail.post);
|
||||
setLiked(detail.liked);
|
||||
setFavorited(detail.favorited);
|
||||
setCanEdit(detail.can_edit ?? false);
|
||||
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
|
||||
setEditBlockReason(detail.edit_block_reason ?? '');
|
||||
setComments(commList);
|
||||
await refresh();
|
||||
} catch (e: unknown) {
|
||||
@@ -161,14 +171,34 @@ export default function PostDetailPage() {
|
||||
|
||||
const authorInitial = post.user?.nickname?.[0] || '?';
|
||||
const tags = post.tags?.split(/[,,]/).map(t => t.trim()).filter(Boolean) ?? [];
|
||||
const canEdit = user && (user.role === 'admin' || user.id === post.user_id);
|
||||
const isOwnerOrAdmin = user && (user.role === 'admin' || user.id === post.user_id);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const showEdited = isEdited && post.updated_at;
|
||||
|
||||
const handlePin = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const r = await api.adminPinPost(postId, !post.pinned);
|
||||
setPost(p => p ? { ...p, pinned: r.pinned } : p);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLock = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const r = await api.adminLockPost(postId, !post.edit_locked);
|
||||
setPost(p => p ? { ...p, edit_locked: r.edit_locked } : p);
|
||||
if (user?.role === 'admin') {
|
||||
setCanEdit(true);
|
||||
} else if (user?.id === post.user_id && r.edit_locked) {
|
||||
setCanEdit(false);
|
||||
setEditBlockReason('帖子已被管理员锁定,无法编辑');
|
||||
}
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
@@ -190,7 +220,7 @@ export default function PostDetailPage() {
|
||||
|
||||
<div className="post-detail-head">
|
||||
<h1 className="post-detail-title">
|
||||
{post.pinned && <Badge variant="orange" className="mr-2 align-middle">置顶</Badge>}
|
||||
{post.pinned && <PinnedIcon className="mr-2" size={18} />}
|
||||
{post.title}
|
||||
</h1>
|
||||
<div className="post-detail-author-row">
|
||||
@@ -200,7 +230,16 @@ export default function PostDetailPage() {
|
||||
<div className="post-detail-author-info">
|
||||
<span className="post-detail-author-name">{post.user?.nickname}</span>
|
||||
<span className="post-detail-meta-line">
|
||||
{formatTime(post.created_at)} · {post.view_count} 次浏览
|
||||
发布于 {formatDateTime(post.created_at)}
|
||||
{showEdited && (
|
||||
<> · 编辑于 {formatDateTime(post.updated_at!)}</>
|
||||
)}
|
||||
{' · '}{post.view_count} 次浏览
|
||||
{post.edit_locked && (
|
||||
<span className="post-detail-locked-tag" title="管理员已锁定编辑">
|
||||
<Lock size={12} /> 已锁定
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,15 +268,40 @@ export default function PostDetailPage() {
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button variant="outline" size="sm" onClick={handlePin}>
|
||||
<Pin />
|
||||
{post.pinned ? '取消置顶' : '置顶'}
|
||||
{isOwnerOrAdmin && isEdited && (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowRevisions(true)}>
|
||||
<History />
|
||||
编辑历史
|
||||
</Button>
|
||||
)}
|
||||
{isOwnerOrAdmin && !canEdit && editBlockReason && (
|
||||
<span className="post-detail-edit-hint" title={editBlockReason}>
|
||||
{editBlockReason}
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handlePin}>
|
||||
<Pin />
|
||||
{post.pinned ? '取消置顶' : '置顶'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleLock}>
|
||||
<Lock />
|
||||
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PostRevisionPanel
|
||||
postId={postId}
|
||||
currentPost={{ title: post.title, content: post.content ?? '', tags: post.tags ?? '' }}
|
||||
open={showRevisions}
|
||||
onClose={() => setShowRevisions(false)}
|
||||
isLoggedIn={!!user}
|
||||
/>
|
||||
|
||||
<div className="comment-section" ref={commentSectionRef}>
|
||||
<div className="comment-section-bar">
|
||||
<span className="comment-section-title">评论区</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Search, Lock, LockOpen } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -14,6 +14,7 @@ import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { PostItem } from '../../api/types';
|
||||
import { clearAllFeedCache } from '../../utils/feedCache';
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
const nav = useNavigate();
|
||||
@@ -44,6 +45,18 @@ export default function AdminPostsPage() {
|
||||
const togglePin = async (post: PostItem) => {
|
||||
try {
|
||||
const r = await api.adminPinPost(post.id, !post.pinned);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLock = async (post: PostItem) => {
|
||||
try {
|
||||
const r = await api.adminLockPost(post.id, !post.edit_locked);
|
||||
notify.success(r.message);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
@@ -100,6 +113,7 @@ export default function AdminPostsPage() {
|
||||
<th>板块</th>
|
||||
<th>作者</th>
|
||||
<th>置顶</th>
|
||||
<th>锁定</th>
|
||||
<th>点赞</th>
|
||||
<th>浏览</th>
|
||||
<th>时间</th>
|
||||
@@ -118,6 +132,7 @@ export default function AdminPostsPage() {
|
||||
<td>{p.board?.name ?? '—'}</td>
|
||||
<td>{p.user?.nickname ?? '—'}</td>
|
||||
<td>{p.pinned ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||
<td>{p.like_count}</td>
|
||||
<td>{p.view_count}</td>
|
||||
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
|
||||
@@ -126,6 +141,9 @@ export default function AdminPostsPage() {
|
||||
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
|
||||
{p.pinned ? '取消置顶' : '置顶'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
|
||||
{p.edit_locked ? <><LockOpen size={14} /> 解锁</> : <><Lock size={14} /> 锁定</>}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">删除</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Database } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
@@ -12,14 +13,37 @@ export default function AdminSettingsPage() {
|
||||
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [backing, setBacking] = useState(false);
|
||||
const [editWindowHours, setEditWindowHours] = useState('24');
|
||||
const [savingForum, setSavingForum] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
api.adminSettings()
|
||||
.then(setSettings)
|
||||
.then(s => {
|
||||
setSettings(s);
|
||||
setEditWindowHours(String(s.post_edit_window_hours ?? 24));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready]);
|
||||
|
||||
const handleSaveForumSettings = async () => {
|
||||
const hours = parseInt(editWindowHours, 10);
|
||||
if (Number.isNaN(hours) || hours < 0) {
|
||||
notify.warning('请输入有效的小时数(0 表示不限)');
|
||||
return;
|
||||
}
|
||||
setSavingForum(true);
|
||||
try {
|
||||
const r = await api.adminUpdateForumSettings({ post_edit_window_hours: hours });
|
||||
notify.success(r.message);
|
||||
setSettings(s => s ? { ...s, post_edit_window_hours: r.post_edit_window_hours } : s);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSavingForum(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackup = async () => {
|
||||
setBacking(true);
|
||||
try {
|
||||
@@ -45,6 +69,27 @@ export default function AdminSettingsPage() {
|
||||
<p>数据目录、敏感词配置与数据库备份</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">论坛规则</div>
|
||||
<p className="admin-card-desc">
|
||||
设置普通用户发布后可编辑帖子的时限。超过时限后仅管理员可编辑。设为 0 表示不限时。
|
||||
</p>
|
||||
<div className="admin-form-row">
|
||||
<label htmlFor="edit-window-hours">编辑时限(小时)</label>
|
||||
<Input
|
||||
id="edit-window-hours"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editWindowHours}
|
||||
onChange={e => setEditWindowHours(e.target.value)}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
<Button onClick={handleSaveForumSettings} loading={savingForum}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">运行信息</div>
|
||||
<dl className="admin-dl">
|
||||
|
||||
Reference in New Issue
Block a user