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)
+}