diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index aa1f75a..e7157e9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { - Home, Star, LayoutDashboard, FolderGit2, ArrowLeft, + Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, } from 'lucide-react'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import type { Board } from '../api/types'; @@ -150,6 +150,13 @@ export default function Sidebar({ })} + ) : isAdmin ? ( + <> +
板块
+ + ) : null} {isAdmin && ( diff --git a/frontend/src/components/VirtualPostList.tsx b/frontend/src/components/VirtualPostList.tsx index 6dedc3f..24721bc 100644 --- a/frontend/src/components/VirtualPostList.tsx +++ b/frontend/src/components/VirtualPostList.tsx @@ -35,6 +35,8 @@ interface Props { boardId?: number; /** 当前板块名 */ boardName?: string; + /** 站点是否尚无任何板块(全新安装) */ + noBoards?: boolean; } export default function VirtualPostList({ @@ -55,6 +57,7 @@ export default function VirtualPostList({ keyword = '', boardId = 0, boardName = '', + noBoards = false, }: Props) { const nav = useNavigate(); const { user } = useAuth(); @@ -81,6 +84,7 @@ export default function VirtualPostList({ const isEmpty = !loading && posts.length === 0; const isSearchEmpty = isEmpty && !!keyword.trim(); const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose'; + const isAdmin = user?.role === 'admin'; useLayoutEffect(() => { if (resetScrollKey <= 0) return; @@ -116,7 +120,17 @@ export default function VirtualPostList({ const emptyActions = (
- {isSearchEmpty ? ( + {noBoards ? ( + isAdmin ? ( + + ) : ( + + ) + ) : isSearchEmpty ? ( <>
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index a6c16b2..5513ddf 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -283,11 +283,12 @@ export default function MainLayout() { keyword: outletKeyword, setBoardId, boards, + boardsLoading, stats, refreshBoards, isMobile, setPostOutline: setPostOutlineSafe, - }), [boardId, outletKeyword, boards, stats, refreshBoards, isMobile, setPostOutlineSafe]); + }), [boardId, outletKeyword, boards, boardsLoading, stats, refreshBoards, isMobile, setPostOutlineSafe]); const selectBoardChip = (id: number) => { setBoardId(id); @@ -696,6 +697,7 @@ export type LayoutCtx = { keyword: string; setBoardId: (id: number) => void; boards: Board[]; + boardsLoading: boolean; stats: ForumStats | null; refreshBoards: () => void; isMobile: boolean; diff --git a/frontend/src/pages/ComposePage.tsx b/frontend/src/pages/ComposePage.tsx index 410a2a6..ba3de12 100644 --- a/frontend/src/pages/ComposePage.tsx +++ b/frontend/src/pages/ComposePage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom'; import { ArrowLeft, Send, Pencil } from 'lucide-react'; import { notify } from '@/lib/notify'; @@ -17,12 +17,6 @@ import type { LayoutCtx } from '../layouts/MainLayout'; import { loginPath } from '../utils/authRedirect'; import { useNoIndexSEO } from '../hooks/usePageSEO'; import { parsePermalinkID, postPath } from '../utils/permalink'; -import { - loadComposeDraft, - saveComposeDraft, - clearComposeDraft, - draftHasContent, -} from '../utils/composeDraft'; interface ComposeBaseline { title: string; @@ -77,9 +71,6 @@ export default function ComposePage() { ); const [baseline, setBaseline] = useState(null); const [editWindowHint, setEditWindowHint] = useState(''); - const [draftHint, setDraftHint] = useState(''); - const draftReadyRef = useRef(false); - const draftTimerRef = useRef>(); useEffect(() => { if (authLoading) return; @@ -90,7 +81,6 @@ export default function ComposePage() { if (isEdit) { setLoading(true); - draftReadyRef.current = false; const cached = resolveBoards(layoutCtx?.boards); const boardsPromise = cached.length > 0 ? Promise.resolve({ boards: cached }) @@ -120,6 +110,9 @@ export default function ComposePage() { }; setBoardId(loadedBoardId); setBaseline(serverBaseline); + setTitle(serverBaseline.title); + setTags(serverBaseline.tags); + setContent(serverBaseline.content); const windowHours = postData.post_edit_window_hours ?? 0; if (user.role !== 'admin' && windowHours > 0) { @@ -127,28 +120,6 @@ export default function ComposePage() { } else { setEditWindowHint(''); } - - const draft = loadComposeDraft(editId); - const useDraft = draft - && draftHasContent(draft) - && ( - draft.title !== serverBaseline.title - || draft.tags !== serverBaseline.tags - || draft.content !== serverBaseline.content - ); - if (useDraft && draft) { - setTitle(draft.title); - setTags(draft.tags); - setContent(draft.content); - setDraftHint('已恢复未保存的编辑草稿'); - notify.success('已恢复未保存的编辑草稿'); - } else { - setTitle(serverBaseline.title); - setTags(serverBaseline.tags); - setContent(serverBaseline.content); - setDraftHint(''); - } - draftReadyRef.current = true; }) .catch((e: unknown) => { notify.error(e instanceof Error ? e.message : '加载帖子失败'); @@ -158,39 +129,17 @@ export default function ComposePage() { return; } - draftReadyRef.current = false; const applyNewBaseline = (list: Board[], initialBoardId: string) => { setBoards(list); if (!defaultBoard) setBoardId(initialBoardId); const boardForBaseline = defaultBoard || initialBoardId; setBoardId(prev => prev || boardForBaseline); - - const draft = loadComposeDraft(null); - if (draft && draftHasContent(draft)) { - setTitle(draft.title); - setTags(draft.tags); - setContent(draft.content); - if (draft.boardId && list.some(b => String(b.id) === draft.boardId)) { - setBoardId(draft.boardId); - } - setBaseline({ - title: '', - tags: '', - content: '', - boardId: draft.boardId || boardForBaseline, - }); - setDraftHint('已恢复本地草稿'); - notify.success('已恢复本地草稿'); - } else { - setBaseline({ - title: '', - tags: '', - content: '', - boardId: boardForBaseline, - }); - setDraftHint(''); - } - draftReadyRef.current = true; + setBaseline({ + title: '', + tags: '', + content: '', + boardId: boardForBaseline, + }); }; const list = resolveBoards(layoutCtx?.boards); @@ -209,27 +158,11 @@ export default function ComposePage() { }).catch(() => { setBoards([]); }).finally(() => setBoardsReady(true)); - }, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards]); - - // 防抖自动保存草稿 - useEffect(() => { - if (!draftReadyRef.current || !user) return; - clearTimeout(draftTimerRef.current); - draftTimerRef.current = setTimeout(() => { - saveComposeDraft(isEdit ? editId : null, { - title, - tags, - content, - boardId, - }); - if (title.trim() || tags.trim() || content.trim()) { - setDraftHint('草稿已自动保存'); - } - }, 800); - return () => clearTimeout(draftTimerRef.current); - }, [title, tags, content, boardId, isEdit, editId, user]); + }, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards, limits]); const isDirty = useMemo(() => { + // 无可发帖板块时不拦截导航(空态页本身没有可保存内容) + if (!isEdit && boards.length === 0) return false; if (!baseline) return false; return ( title !== baseline.title @@ -237,7 +170,7 @@ export default function ComposePage() { || content !== baseline.content || boardId !== baseline.boardId ); - }, [baseline, title, tags, content, boardId]); + }, [baseline, title, tags, content, boardId, isEdit, boards.length]); const { dialogOpen, @@ -247,6 +180,11 @@ export default function ComposePage() { markSaved, } = useUnsavedChangesGuard({ isDirty }); + const leaveTo = (path: string) => { + markSaved(); + nav(path); + }; + if (authLoading) { return (
@@ -267,24 +205,32 @@ export default function ComposePage() { if (!isEdit && boards.length === 0) { return ( -
-
-
- + <> +
+
+
+ +
+

暂无可发帖板块

+

需要管理员先创建板块后才能发布内容

+ {user.role === 'admin' ? ( + + ) : ( + + )}
-

暂无可发帖板块

-

需要管理员先创建板块后才能发布内容

- {user.role === 'admin' ? ( - - ) : ( - - )}
-
+ + ); } @@ -305,13 +251,11 @@ export default function ComposePage() { if (isEdit) { await api.updatePost(editId!, payload); notify.success(user?.role === 'admin' ? '帖子已更新' : '已更新并重新提交审核'); - clearComposeDraft(editId); markSaved(); nav(postPath(editId!, limits)); } else { const res = await api.createPost(payload); notify.success(res.message || (res.status === 'pending' ? '已提交审核' : '发帖成功')); - clearComposeDraft(null); markSaved(); nav(postPath(res.post_id, limits)); } @@ -340,9 +284,9 @@ export default function ComposePage() { 返回

{isEdit ? '编辑帖子' : '写新帖'}

- {(draftHint || editWindowHint) && ( - - {editWindowHint || draftHint} + {editWindowHint && ( + + {editWindowHint} )}
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index a2091da..5be0e40 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -258,6 +258,7 @@ export default function HomePage() { keyword={keyword} boardId={boardId} boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''} + noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0} />
diff --git a/frontend/src/utils/composeDraft.ts b/frontend/src/utils/composeDraft.ts deleted file mode 100644 index 8f12f2b..0000000 --- a/frontend/src/utils/composeDraft.ts +++ /dev/null @@ -1,61 +0,0 @@ -export interface ComposeDraft { - title: string; - tags: string; - content: string; - boardId: string; - savedAt: number; -} - -const PREFIX = 'j13-compose-draft:'; - -function draftKey(editId: number | null): string { - return editId == null ? `${PREFIX}new` : `${PREFIX}edit:${editId}`; -} - -/** 读取发帖/编辑草稿 */ -export function loadComposeDraft(editId: number | null): ComposeDraft | null { - try { - const raw = localStorage.getItem(draftKey(editId)); - if (!raw) return null; - const data = JSON.parse(raw) as ComposeDraft; - if (!data || typeof data !== 'object') return null; - return { - title: typeof data.title === 'string' ? data.title : '', - tags: typeof data.tags === 'string' ? data.tags : '', - content: typeof data.content === 'string' ? data.content : '', - boardId: typeof data.boardId === 'string' ? data.boardId : '', - savedAt: typeof data.savedAt === 'number' ? data.savedAt : 0, - }; - } catch { - return null; - } -} - -/** 写入发帖/编辑草稿 */ -export function saveComposeDraft(editId: number | null, draft: Omit): void { - try { - const payload: ComposeDraft = { ...draft, savedAt: Date.now() }; - localStorage.setItem(draftKey(editId), JSON.stringify(payload)); - } catch { - // 配额不足等场景静默忽略 - } -} - -/** 清除发帖/编辑草稿 */ -export function clearComposeDraft(editId: number | null): void { - try { - localStorage.removeItem(draftKey(editId)); - } catch { - // ignore - } -} - -/** 草稿是否相对 baseline 有实质内容 */ -export function draftHasContent(draft: ComposeDraft): boolean { - return Boolean( - draft.title.trim() - || draft.tags.trim() - || draft.content.trim() - || draft.boardId.trim(), - ); -} diff --git a/router/router.go b/router/router.go index ffa9679..74efda4 100644 --- a/router/router.go +++ b/router/router.go @@ -44,6 +44,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc) userSvc := service.NewUserService(filter, settingsSvc) boardSvc := service.NewBoardService() + boardSvc.EnsureDefaultBoard() postSvc := service.NewPostService(filter, settingsSvc) commentSvc := service.NewCommentService(filter, settingsSvc) messageSvc := service.NewMessageService(filter, settingsSvc) diff --git a/service/board.go b/service/board.go index cb8f381..127f979 100644 --- a/service/board.go +++ b/service/board.go @@ -76,3 +76,12 @@ func (s *BoardService) Delete(id uint) error { } return model.DB.Delete(&model.Board{}, id).Error } + +// EnsureDefaultBoard 若尚无板块则创建默认「综合讨论」,便于全新安装后直接发帖 +func (s *BoardService) EnsureDefaultBoard() { + var n int64 + if err := model.DB.Model(&model.Board{}).Count(&n).Error; err != nil || n > 0 { + return + } + _, _ = s.Create("综合讨论", "默认板块,欢迎发帖交流", "message-square", 0, 0) +}