Files
jiang13-forum/frontend/src/pages/ComposePage.tsx
freefire 6b0a1d4281 增加用户认证、等级、徽章与积分体系,并优化管理后台体验。
覆盖站长调账与积分解锁内容;后台按审核优先分组导航,仪表盘展示待办,用户管理改为成员目录式布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 02:42:18 +08:00

398 lines
14 KiB
TypeScript

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';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
import { isHtmlEmpty } from '../utils/postContent';
import { useForumLimits } from '../hooks/useForumLimits';
import { useUnsavedChangesGuard } from '../hooks/useUnsavedChangesGuard';
import ArticleEditor from '../components/ArticleEditor';
import UnsavedChangesDialog from '../components/UnsavedChangesDialog';
import TagInput, { serializeTags, parseTags } from '../components/TagInput';
import { Spinner } from '@/components/ui/spinner';
import { getCachedBoards } from '../utils/layoutCache';
import type { LayoutCtx } from '../layouts/MainLayout';
import { loginPath } from '../utils/authRedirect';
import { useNoIndexSEO } from '../hooks/usePageSEO';
import { parsePermalinkID, postPath } from '../utils/permalink';
import { skipsModeration } from '../utils/userMeta';
interface ComposeBaseline {
title: string;
tags: string;
content: string;
boardId: string;
postType: 'normal' | 'question';
}
function resolveBoards(ctxBoards?: Board[]): Board[] {
if (ctxBoards && ctxBoards.length > 0) return ctxBoards;
return getCachedBoards();
}
/** 格式化剩余可编辑时间 */
function formatEditRemaining(createdAt: string, windowHours: number): string {
if (windowHours <= 0) return '';
const deadline = new Date(createdAt).getTime() + windowHours * 3600_000;
const ms = deadline - Date.now();
if (ms <= 0) return '可编辑时限已到';
const hours = Math.floor(ms / 3600_000);
const mins = Math.floor((ms % 3600_000) / 60_000);
if (hours >= 24) {
const days = Math.floor(hours / 24);
return `还可编辑约 ${days}`;
}
if (hours > 0) return `还可编辑约 ${hours} 小时 ${mins}`;
return `还可编辑约 ${mins} 分钟`;
}
export default function ComposePage() {
const nav = useNavigate();
const { id: editIdParam } = useParams();
const editId = editIdParam ? parsePermalinkID(editIdParam) : null;
const isEdit = editId !== null && !Number.isNaN(editId);
const [params] = useSearchParams();
const defaultBoard = params.get('board') || '';
const { user, loading: authLoading } = useAuth();
const { limits } = useForumLimits();
useNoIndexSEO(isEdit ? '编辑帖子' : '发帖');
const layoutCtx = useOutletContext<LayoutCtx | undefined>();
const [boards, setBoards] = useState<Board[]>(() => resolveBoards(layoutCtx?.boards));
const [boardId, setBoardId] = useState(defaultBoard);
const [title, setTitle] = useState('');
const [tags, setTags] = useState('');
const [content, setContent] = useState('');
const [postType, setPostType] = useState<'normal' | 'question'>('normal');
const [publishing, setPublishing] = useState(false);
const [loading, setLoading] = useState(isEdit);
/** 新建帖:板块列表是否已就绪(避免请求中误显空态) */
const [boardsReady, setBoardsReady] = useState(
() => isEdit || resolveBoards(layoutCtx?.boards).length > 0,
);
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
const [editWindowHint, setEditWindowHint] = useState('');
useEffect(() => {
if (authLoading) return;
if (!user) {
nav(loginPath(isEdit ? `/post/${editId}/edit` : '/compose'));
return;
}
if (isEdit) {
setLoading(true);
const cached = resolveBoards(layoutCtx?.boards);
const boardsPromise = cached.length > 0
? Promise.resolve({ boards: cached })
: api.boards();
Promise.all([boardsPromise, api.post(editId!, { skipView: true })])
.then(([boardsData, postData]) => {
const list = boardsData.boards ?? [];
setBoards(list);
const post = postData.post;
const isOwnerOrAdmin = user.role === 'admin' || post.user_id === user.id;
if (!isOwnerOrAdmin) {
notify.error('无权编辑此帖子');
nav(postPath(editId!, limits));
return;
}
if (!postData.can_edit) {
notify.error(postData.edit_block_reason || '当前无法编辑此帖子');
nav(postPath(editId!, limits));
return;
}
const loadedBoardId = String(post.board_id);
const loadedType = post.post_type === 'question' ? 'question' : 'normal';
const serverBaseline: ComposeBaseline = {
title: post.title,
tags: post.tags ?? '',
content: post.content ?? '',
boardId: loadedBoardId,
postType: loadedType,
};
setBoardId(loadedBoardId);
setBaseline(serverBaseline);
setTitle(serverBaseline.title);
setTags(serverBaseline.tags);
setContent(serverBaseline.content);
setPostType(loadedType);
const windowHours = postData.post_edit_window_hours ?? 0;
if (user.role !== 'admin' && windowHours > 0) {
setEditWindowHint(formatEditRemaining(post.created_at, windowHours));
} else {
setEditWindowHint('');
}
})
.catch((e: unknown) => {
notify.error(e instanceof Error ? e.message : '加载帖子失败');
nav('/');
})
.finally(() => setLoading(false));
return;
}
const applyNewBaseline = (list: Board[], initialBoardId: string) => {
setBoards(list);
if (!defaultBoard) setBoardId(initialBoardId);
const boardForBaseline = defaultBoard || initialBoardId;
setBoardId(prev => prev || boardForBaseline);
setBaseline({
title: '',
tags: '',
content: '',
boardId: boardForBaseline,
postType: 'normal',
});
};
const list = resolveBoards(layoutCtx?.boards);
if (list.length > 0) {
const initialBoardId = defaultBoard || String(list[0].id);
applyNewBaseline(list, initialBoardId);
setBoardsReady(true);
return;
}
setBoardsReady(false);
api.boards().then(d => {
const next = d.boards ?? [];
const initialBoardId = defaultBoard || (next.length > 0 ? String(next[0].id) : '');
applyNewBaseline(next, initialBoardId);
}).catch(() => {
setBoards([]);
}).finally(() => setBoardsReady(true));
}, [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
|| serializeTags(parseTags(tags)) !== serializeTags(parseTags(baseline.tags))
|| content !== baseline.content
|| boardId !== baseline.boardId
|| postType !== baseline.postType
);
}, [baseline, title, tags, content, boardId, postType, isEdit, boards.length]);
const {
dialogOpen,
stayOnPage,
discardAndLeave,
requestLeave,
markSaved,
} = useUnsavedChangesGuard({ isDirty });
const leaveTo = (path: string) => {
markSaved();
nav(path);
};
if (authLoading) {
return (
<div className="compose-page compose-page--empty">
<Spinner size="lg" />
</div>
);
}
if (!user) return null;
if (loading || (!isEdit && !boardsReady)) {
return (
<div className="compose-page compose-page--empty">
<Spinner size="lg" />
</div>
);
}
if (!isEdit && boards.length === 0) {
return (
<>
<div className="compose-page compose-page--empty">
<div className="compose-empty-card">
<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>
<UnsavedChangesDialog
open={dialogOpen}
onStay={stayOnPage}
onLeave={discardAndLeave}
isEdit={isEdit}
/>
</>
);
}
const handleSubmit = async () => {
const trimmedTitle = title.trim();
if (!boardId) { notify.warning('请选择板块'); return; }
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
if (isHtmlEmpty(content)) { notify.warning('请输入正文内容'); return; }
setPublishing(true);
try {
const payload = {
title: trimmedTitle,
content: content.trim(),
tags: serializeTags(parseTags(tags)),
board_id: boardId,
post_type: postType,
};
if (isEdit) {
await api.updatePost(editId!, payload);
notify.success(skipsModeration(user) ? '帖子已更新' : '已更新并重新提交审核');
markSaved();
nav(postPath(editId!, limits));
} else {
const res = await api.createPost(payload);
notify.success(res.message || (res.status === 'pending' ? '已提交审核' : '发帖成功'));
markSaved();
nav(postPath(res.post_id, limits));
}
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : isEdit ? '保存失败' : '发帖失败');
} finally {
setPublishing(false);
}
};
return (
<div className="compose-page">
<div className="compose-canvas">
<div className="compose-shell">
<header className="compose-header">
<div className="compose-header-left">
<button
type="button"
className="compose-back"
onClick={() => requestLeave(() => {
if (isEdit) nav(postPath(editId!, limits));
else nav(-1);
})}
>
<ArrowLeft size={16} />
<span></span>
</button>
<h1 className="compose-header-title">{isEdit ? '编辑帖子' : '写新帖'}</h1>
{editWindowHint && (
<span className="compose-draft-hint" title={editWindowHint}>
{editWindowHint}
</span>
)}
</div>
<div className="compose-header-actions">
<button
type="button"
className="compose-publish-btn"
disabled={publishing}
onClick={handleSubmit}
>
<Send size={16} />
{publishing ? (isEdit ? '保存中…' : '发布中…') : (isEdit ? '保存修改' : '发布')}
</button>
</div>
</header>
<div className="compose-shell-body">
<section className="compose-context" aria-label="发布设置">
<div className="compose-context-row">
<span className="compose-context-label"></span>
<div className="compose-type-pills" role="radiogroup" aria-label="帖子类型">
<button
type="button"
role="radio"
aria-checked={postType === 'normal'}
className={`compose-type-pill${postType === 'normal' ? ' active' : ''}`}
onClick={() => setPostType('normal')}
>
</button>
<button
type="button"
role="radio"
aria-checked={postType === 'question'}
className={`compose-type-pill${postType === 'question' ? ' active' : ''}`}
onClick={() => setPostType('question')}
>
</button>
</div>
{postType === 'question' && (
<span className="compose-type-hint"> / </span>
)}
</div>
<div className="compose-context-row">
<span className="compose-context-label"></span>
<div className="compose-board-pills" role="listbox" aria-label={isEdit ? '修改板块' : '选择板块'}>
{boards.map(b => (
<button
key={b.id}
type="button"
role="option"
aria-selected={String(b.id) === boardId}
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
onClick={() => setBoardId(String(b.id))}
>
{b.name}
</button>
))}
</div>
</div>
<div className="compose-context-row compose-context-row--tags">
<span className="compose-context-label"></span>
<TagInput
value={tags}
onChange={setTags}
placeholder="添加标签,回车确认"
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
/>
</div>
</section>
<div className="compose-document">
<input
className="compose-title"
type="text"
placeholder={postType === 'question' ? '用一句话描述你的问题…' : '输入文章标题…'}
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
/>
<ArticleEditor
value={content}
onChange={setContent}
placeholder="开始写作。按回车分段,选中文字后用工具栏设置格式。"
/>
</div>
</div>
</div>
</div>
<UnsavedChangesDialog
open={dialogOpen}
onStay={stayOnPage}
onLeave={discardAndLeave}
isEdit={isEdit}
/>
</div>
);
}