feat(frontend): add posting compose component suite

新增了发帖相关的完整组件模块:包括顶部导航栏ComposeHeader、正文编辑区ComposeDocument,以及发布设置栏ComposeContextBar,同时添加了vite依赖缓存配置文件。
This commit is contained in:
2026-08-07 17:45:50 +08:00
parent 94f6a9666b
commit 383440ed5b
12 changed files with 725 additions and 181 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
import { ArrowLeft, Send, Pencil } from 'lucide-react';
import { Pencil } from 'lucide-react';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
@@ -8,10 +8,12 @@ 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 { serializeTags, parseTags } from '../components/TagInput';
import { Spinner } from '@/components/ui/spinner';
import ComposeHeader from '../components/compose/ComposeHeader';
import ComposeContextBar, { type PostType } from '../components/compose/ComposeContextBar';
import ComposeDocument from '../components/compose/ComposeDocument';
import { getCachedBoards } from '../utils/layoutCache';
import type { LayoutCtx } from '../layouts/MainLayout';
import { loginPath } from '../utils/authRedirect';
@@ -30,7 +32,7 @@ interface ComposeBaseline {
tags: string;
content: string;
boardId: string;
postType: 'normal' | 'question';
postType: PostType;
}
function resolveBoards(ctxBoards?: Board[]): Board[] {
@@ -71,7 +73,7 @@ export default function ComposePage() {
const [title, setTitle] = useState('');
const [tags, setTags] = useState('');
const [content, setContent] = useState('');
const [postType, setPostType] = useState<'normal' | 'question'>('normal');
const [postType, setPostType] = useState<PostType>('normal');
const [publishing, setPublishing] = useState(false);
const [loading, setLoading] = useState(isEdit);
/** 新建帖:板块列表是否已就绪(避免请求中误显空态) */
@@ -338,115 +340,39 @@ export default function ComposePage() {
<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>
)}
{!isEdit && draftHint && (
<span className="compose-draft-hint" title={draftHint}>
{draftHint}
</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>
<ComposeHeader
isEdit={isEdit}
publishing={publishing}
editWindowHint={editWindowHint}
draftHint={draftHint}
onBack={() => requestLeave(() => {
if (isEdit) nav(postPath(editId!, limits));
else nav(-1);
})}
onPublish={handleSubmit}
/>
<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}
<ComposeDocument
postType={postType}
title={title}
onTitleChange={setTitle}
content={content}
onContentChange={setContent}
limits={limits}
>
<ComposeContextBar
isEdit={isEdit}
postType={postType}
onPostTypeChange={setPostType}
boards={boards}
boardId={boardId}
onBoardChange={setBoardId}
tags={tags}
onTagsChange={setTags}
limits={limits}
/>
</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>
</ComposeDocument>
</div>
</div>
</div>