支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。

引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-29 14:09:58 +08:00
parent 962bb15298
commit 9487c8ab02
37 changed files with 2025 additions and 403 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { useNavigate, useSearchParams, useParams } from 'react-router-dom';
import { ArrowLeft, Send, Tag } from 'lucide-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';
@@ -10,7 +10,10 @@ 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';
interface ComposeBaseline {
title: string;
@@ -19,6 +22,11 @@ interface ComposeBaseline {
boardId: string;
}
function resolveBoards(ctxBoards?: Board[]): Board[] {
if (ctxBoards && ctxBoards.length > 0) return ctxBoards;
return getCachedBoards();
}
export default function ComposePage() {
const nav = useNavigate();
const { id: editIdParam } = useParams();
@@ -28,14 +36,19 @@ export default function ComposePage() {
const defaultBoard = params.get('board') || '';
const { user, loading: authLoading } = useAuth();
const { limits } = useForumLimits();
const layoutCtx = useOutletContext<LayoutCtx | undefined>();
const [boards, setBoards] = useState<Board[]>([]);
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 [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);
useEffect(() => {
@@ -44,7 +57,11 @@ export default function ComposePage() {
if (isEdit) {
setLoading(true);
Promise.all([api.boards(), api.post(editId!, { skipView: 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);
@@ -80,11 +97,27 @@ export default function ComposePage() {
return;
}
api.boards().then(d => {
const list = d.boards ?? [];
const list = resolveBoards(layoutCtx?.boards);
if (list.length > 0) {
setBoards(list);
const initialBoardId = defaultBoard || (list.length > 0 ? String(list[0].id) : '');
if (!defaultBoard && list.length > 0) {
setBoardsReady(true);
const initialBoardId = defaultBoard || String(list[0].id);
if (!defaultBoard) setBoardId(initialBoardId);
setBaseline({
title: '',
tags: '',
content: '',
boardId: initialBoardId,
});
return;
}
setBoardsReady(false);
api.boards().then(d => {
const next = d.boards ?? [];
setBoards(next);
const initialBoardId = defaultBoard || (next.length > 0 ? String(next[0].id) : '');
if (!defaultBoard && next.length > 0) {
setBoardId(initialBoardId);
}
setBaseline({
@@ -93,14 +126,16 @@ export default function ComposePage() {
content: '',
boardId: initialBoardId,
});
}).catch(() => {});
}, [user, authLoading, nav, defaultBoard, isEdit, editId]);
}).catch(() => {
setBoards([]);
}).finally(() => setBoardsReady(true));
}, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards]);
const isDirty = useMemo(() => {
if (!baseline) return false;
return (
title !== baseline.title
|| tags !== baseline.tags
|| serializeTags(parseTags(tags)) !== serializeTags(parseTags(baseline.tags))
|| content !== baseline.content
|| (!isEdit && boardId !== baseline.boardId)
);
@@ -124,7 +159,7 @@ export default function ComposePage() {
if (!user) return null;
if (loading) {
if (loading || (!isEdit && !boardsReady)) {
return (
<div className="compose-page compose-page--empty">
<Spinner size="lg" />
@@ -136,7 +171,9 @@ export default function ComposePage() {
return (
<div className="compose-page compose-page--empty">
<div className="compose-empty-card">
<div className="compose-empty-icon"></div>
<div className="compose-empty-icon" aria-hidden>
<Pencil size={28} strokeWidth={1.5} />
</div>
<h2></h2>
<p></p>
{user.role === 'admin' ? (
@@ -164,7 +201,7 @@ export default function ComposePage() {
const payload = {
title: trimmedTitle,
content: content.trim(),
tags: tags.trim(),
tags: serializeTags(parseTags(tags)),
};
if (isEdit) {
await api.updatePost(editId!, payload);
@@ -230,16 +267,12 @@ export default function ComposePage() {
<span className="compose-board-pill active">{currentBoard.name}</span>
</div>
)}
<div className="compose-tags-field">
<Tag className="compose-tags-icon" size={16} />
<input
type="text"
placeholder="添加标签,逗号分隔"
value={tags}
onChange={e => setTags(e.target.value)}
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
/>
</div>
<TagInput
value={tags}
onChange={setTags}
placeholder="输入标签后回车"
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
/>
</div>
<div className="compose-writing">