修复无板块时空态导航失效,去除发帖本地草稿,并种子默认板块。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 17:12:17 +08:00
parent 81f5d451bc
commit dd9e185847
8 changed files with 97 additions and 172 deletions

View File

@@ -1,5 +1,5 @@
import { import {
Home, Star, LayoutDashboard, FolderGit2, ArrowLeft, Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft,
} from 'lucide-react'; } from 'lucide-react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import type { Board } from '../api/types'; import type { Board } from '../api/types';
@@ -150,6 +150,13 @@ export default function Sidebar({
})} })}
</nav> </nav>
</> </>
) : isAdmin ? (
<>
<div className="sidebar-section sidebar-section--boards"></div>
<nav className="sidebar-nav">
{navItem('boards-empty', '创建第一个板块', <FolderKanban aria-hidden />, () => nav('/admin/boards'))}
</nav>
</>
) : null} ) : null}
{isAdmin && ( {isAdmin && (

View File

@@ -35,6 +35,8 @@ interface Props {
boardId?: number; boardId?: number;
/** 当前板块名 */ /** 当前板块名 */
boardName?: string; boardName?: string;
/** 站点是否尚无任何板块(全新安装) */
noBoards?: boolean;
} }
export default function VirtualPostList({ export default function VirtualPostList({
@@ -55,6 +57,7 @@ export default function VirtualPostList({
keyword = '', keyword = '',
boardId = 0, boardId = 0,
boardName = '', boardName = '',
noBoards = false,
}: Props) { }: Props) {
const nav = useNavigate(); const nav = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
@@ -81,6 +84,7 @@ export default function VirtualPostList({
const isEmpty = !loading && posts.length === 0; const isEmpty = !loading && posts.length === 0;
const isSearchEmpty = isEmpty && !!keyword.trim(); const isSearchEmpty = isEmpty && !!keyword.trim();
const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose'; const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose';
const isAdmin = user?.role === 'admin';
useLayoutEffect(() => { useLayoutEffect(() => {
if (resetScrollKey <= 0) return; if (resetScrollKey <= 0) return;
@@ -116,7 +120,17 @@ export default function VirtualPostList({
const emptyActions = ( const emptyActions = (
<div className="empty-feed-actions"> <div className="empty-feed-actions">
{isSearchEmpty ? ( {noBoards ? (
isAdmin ? (
<Button type="button" size="sm" onClick={() => nav('/admin/boards')}>
</Button>
) : (
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
</Button>
)
) : isSearchEmpty ? (
<> <>
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}> <Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
@@ -155,13 +169,21 @@ export default function VirtualPostList({
{isSearchEmpty {isSearchEmpty
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} /> ? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
: <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />} : <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />}
<p>{isSearchEmpty ? '没有匹配的帖子' : '暂无帖子'}</p> <p>
{noBoards
? '暂无板块'
: isSearchEmpty
? '没有匹配的帖子'
: '暂无帖子'}
</p>
<p className="empty-feed-hint"> <p className="empty-feed-hint">
{isSearchEmpty {noBoards
? '试试更短的关键词,或浏览标签云 / 板块' ? (isAdmin ? '创建第一个板块后即可开始发帖' : '管理员创建板块后即可参与讨论')
: boardName : isSearchEmpty
? `${boardName}」还没有内容,来发第一篇吧` ? '试试更短的关键词,或浏览标签云 / 板块'
: '换个板块看看,或发第一篇内容'} : boardName
? `${boardName}」还没有内容,来发第一篇吧`
: '换个板块看看,或发第一篇内容'}
</p> </p>
{emptyActions} {emptyActions}
</div> </div>

View File

@@ -283,11 +283,12 @@ export default function MainLayout() {
keyword: outletKeyword, keyword: outletKeyword,
setBoardId, setBoardId,
boards, boards,
boardsLoading,
stats, stats,
refreshBoards, refreshBoards,
isMobile, isMobile,
setPostOutline: setPostOutlineSafe, setPostOutline: setPostOutlineSafe,
}), [boardId, outletKeyword, boards, stats, refreshBoards, isMobile, setPostOutlineSafe]); }), [boardId, outletKeyword, boards, boardsLoading, stats, refreshBoards, isMobile, setPostOutlineSafe]);
const selectBoardChip = (id: number) => { const selectBoardChip = (id: number) => {
setBoardId(id); setBoardId(id);
@@ -696,6 +697,7 @@ export type LayoutCtx = {
keyword: string; keyword: string;
setBoardId: (id: number) => void; setBoardId: (id: number) => void;
boards: Board[]; boards: Board[];
boardsLoading: boolean;
stats: ForumStats | null; stats: ForumStats | null;
refreshBoards: () => void; refreshBoards: () => void;
isMobile: boolean; isMobile: boolean;

View File

@@ -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 { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
import { ArrowLeft, Send, Pencil } from 'lucide-react'; import { ArrowLeft, Send, Pencil } from 'lucide-react';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
@@ -17,12 +17,6 @@ import type { LayoutCtx } from '../layouts/MainLayout';
import { loginPath } from '../utils/authRedirect'; import { loginPath } from '../utils/authRedirect';
import { useNoIndexSEO } from '../hooks/usePageSEO'; import { useNoIndexSEO } from '../hooks/usePageSEO';
import { parsePermalinkID, postPath } from '../utils/permalink'; import { parsePermalinkID, postPath } from '../utils/permalink';
import {
loadComposeDraft,
saveComposeDraft,
clearComposeDraft,
draftHasContent,
} from '../utils/composeDraft';
interface ComposeBaseline { interface ComposeBaseline {
title: string; title: string;
@@ -77,9 +71,6 @@ export default function ComposePage() {
); );
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null); const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
const [editWindowHint, setEditWindowHint] = useState(''); const [editWindowHint, setEditWindowHint] = useState('');
const [draftHint, setDraftHint] = useState('');
const draftReadyRef = useRef(false);
const draftTimerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => { useEffect(() => {
if (authLoading) return; if (authLoading) return;
@@ -90,7 +81,6 @@ export default function ComposePage() {
if (isEdit) { if (isEdit) {
setLoading(true); setLoading(true);
draftReadyRef.current = false;
const cached = resolveBoards(layoutCtx?.boards); const cached = resolveBoards(layoutCtx?.boards);
const boardsPromise = cached.length > 0 const boardsPromise = cached.length > 0
? Promise.resolve({ boards: cached }) ? Promise.resolve({ boards: cached })
@@ -120,6 +110,9 @@ export default function ComposePage() {
}; };
setBoardId(loadedBoardId); setBoardId(loadedBoardId);
setBaseline(serverBaseline); setBaseline(serverBaseline);
setTitle(serverBaseline.title);
setTags(serverBaseline.tags);
setContent(serverBaseline.content);
const windowHours = postData.post_edit_window_hours ?? 0; const windowHours = postData.post_edit_window_hours ?? 0;
if (user.role !== 'admin' && windowHours > 0) { if (user.role !== 'admin' && windowHours > 0) {
@@ -127,28 +120,6 @@ export default function ComposePage() {
} else { } else {
setEditWindowHint(''); 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) => { .catch((e: unknown) => {
notify.error(e instanceof Error ? e.message : '加载帖子失败'); notify.error(e instanceof Error ? e.message : '加载帖子失败');
@@ -158,39 +129,17 @@ export default function ComposePage() {
return; return;
} }
draftReadyRef.current = false;
const applyNewBaseline = (list: Board[], initialBoardId: string) => { const applyNewBaseline = (list: Board[], initialBoardId: string) => {
setBoards(list); setBoards(list);
if (!defaultBoard) setBoardId(initialBoardId); if (!defaultBoard) setBoardId(initialBoardId);
const boardForBaseline = defaultBoard || initialBoardId; const boardForBaseline = defaultBoard || initialBoardId;
setBoardId(prev => prev || boardForBaseline); setBoardId(prev => prev || boardForBaseline);
setBaseline({
const draft = loadComposeDraft(null); title: '',
if (draft && draftHasContent(draft)) { tags: '',
setTitle(draft.title); content: '',
setTags(draft.tags); boardId: boardForBaseline,
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;
}; };
const list = resolveBoards(layoutCtx?.boards); const list = resolveBoards(layoutCtx?.boards);
@@ -209,27 +158,11 @@ export default function ComposePage() {
}).catch(() => { }).catch(() => {
setBoards([]); setBoards([]);
}).finally(() => setBoardsReady(true)); }).finally(() => setBoardsReady(true));
}, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards]); }, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards, limits]);
// 防抖自动保存草稿
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]);
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
// 无可发帖板块时不拦截导航(空态页本身没有可保存内容)
if (!isEdit && boards.length === 0) return false;
if (!baseline) return false; if (!baseline) return false;
return ( return (
title !== baseline.title title !== baseline.title
@@ -237,7 +170,7 @@ export default function ComposePage() {
|| content !== baseline.content || content !== baseline.content
|| boardId !== baseline.boardId || boardId !== baseline.boardId
); );
}, [baseline, title, tags, content, boardId]); }, [baseline, title, tags, content, boardId, isEdit, boards.length]);
const { const {
dialogOpen, dialogOpen,
@@ -247,6 +180,11 @@ export default function ComposePage() {
markSaved, markSaved,
} = useUnsavedChangesGuard({ isDirty }); } = useUnsavedChangesGuard({ isDirty });
const leaveTo = (path: string) => {
markSaved();
nav(path);
};
if (authLoading) { if (authLoading) {
return ( return (
<div className="compose-page compose-page--empty"> <div className="compose-page compose-page--empty">
@@ -267,24 +205,32 @@ export default function ComposePage() {
if (!isEdit && boards.length === 0) { if (!isEdit && boards.length === 0) {
return ( return (
<div className="compose-page compose-page--empty"> <>
<div className="compose-empty-card"> <div className="compose-page compose-page--empty">
<div className="compose-empty-icon" aria-hidden> <div className="compose-empty-card">
<Pencil size={28} strokeWidth={1.5} /> <div className="compose-empty-icon" aria-hidden>
<Pencil size={28} strokeWidth={1.5} />
</div>
<h2></h2>
<p></p>
{user.role === 'admin' ? (
<button type="button" className="compose-primary-btn" onClick={() => leaveTo('/admin/boards')}>
</button>
) : (
<button type="button" className="compose-ghost-btn" onClick={() => leaveTo('/')}>
</button>
)}
</div> </div>
<h2></h2>
<p></p>
{user.role === 'admin' ? (
<button type="button" className="compose-primary-btn" onClick={() => nav('/admin/boards')}>
</button>
) : (
<button type="button" className="compose-ghost-btn" onClick={() => nav('/')}>
</button>
)}
</div> </div>
</div> <UnsavedChangesDialog
open={dialogOpen}
onStay={stayOnPage}
onLeave={discardAndLeave}
isEdit={isEdit}
/>
</>
); );
} }
@@ -305,13 +251,11 @@ export default function ComposePage() {
if (isEdit) { if (isEdit) {
await api.updatePost(editId!, payload); await api.updatePost(editId!, payload);
notify.success(user?.role === 'admin' ? '帖子已更新' : '已更新并重新提交审核'); notify.success(user?.role === 'admin' ? '帖子已更新' : '已更新并重新提交审核');
clearComposeDraft(editId);
markSaved(); markSaved();
nav(postPath(editId!, limits)); nav(postPath(editId!, limits));
} else { } else {
const res = await api.createPost(payload); const res = await api.createPost(payload);
notify.success(res.message || (res.status === 'pending' ? '已提交审核' : '发帖成功')); notify.success(res.message || (res.status === 'pending' ? '已提交审核' : '发帖成功'));
clearComposeDraft(null);
markSaved(); markSaved();
nav(postPath(res.post_id, limits)); nav(postPath(res.post_id, limits));
} }
@@ -340,9 +284,9 @@ export default function ComposePage() {
<span></span> <span></span>
</button> </button>
<h1 className="compose-header-title">{isEdit ? '编辑帖子' : '写新帖'}</h1> <h1 className="compose-header-title">{isEdit ? '编辑帖子' : '写新帖'}</h1>
{(draftHint || editWindowHint) && ( {editWindowHint && (
<span className="compose-draft-hint" title={editWindowHint || draftHint}> <span className="compose-draft-hint" title={editWindowHint}>
{editWindowHint || draftHint} {editWindowHint}
</span> </span>
)} )}
</div> </div>

View File

@@ -258,6 +258,7 @@ export default function HomePage() {
keyword={keyword} keyword={keyword}
boardId={boardId} boardId={boardId}
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''} boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
/> />
</div> </div>
</div> </div>

View File

@@ -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<ComposeDraft, 'savedAt'>): 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(),
);
}

View File

@@ -44,6 +44,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc) authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
userSvc := service.NewUserService(filter, settingsSvc) userSvc := service.NewUserService(filter, settingsSvc)
boardSvc := service.NewBoardService() boardSvc := service.NewBoardService()
boardSvc.EnsureDefaultBoard()
postSvc := service.NewPostService(filter, settingsSvc) postSvc := service.NewPostService(filter, settingsSvc)
commentSvc := service.NewCommentService(filter, settingsSvc) commentSvc := service.NewCommentService(filter, settingsSvc)
messageSvc := service.NewMessageService(filter, settingsSvc) messageSvc := service.NewMessageService(filter, settingsSvc)

View File

@@ -76,3 +76,12 @@ func (s *BoardService) Delete(id uint) error {
} }
return model.DB.Delete(&model.Board{}, id).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)
}