支持 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

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Plus } from 'lucide-react';
import { Plus, FolderKanban } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
@@ -132,7 +132,7 @@ export default function BoardsManagePage() {
return (
<div className="admin-page">
<div className="admin-page-head">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div className="admin-page-head-row">
<div>
<h1></h1>
<p></p>
@@ -209,6 +209,7 @@ export default function BoardsManagePage() {
</Table>
{boards.length === 0 && (
<div className="empty-state">
<FolderKanban className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
</div>
)}

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">

View File

@@ -1,23 +1,19 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, Star } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { formatTime } from '../utils/content';
import PostListItem from '../components/PostListItem';
interface FavItem {
id: number;
post_id: number;
created_at: string;
post?: {
id: number;
title: string;
board?: { name: string };
user?: { nickname: string };
};
post?: PostItem;
}
export default function FavoritesPage() {
@@ -30,7 +26,7 @@ export default function FavoritesPage() {
if (authLoading) return;
if (!user) { nav('/login'); return; }
api.favorites()
.then(d => setList(Array.isArray(d.favorites) ? d.favorites : []))
.then(d => setList(Array.isArray(d.favorites) ? d.favorites as FavItem[] : []))
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
}, [user, authLoading, nav]);
@@ -51,26 +47,31 @@ export default function FavoritesPage() {
{list.length === 0 ? (
<div className="empty-state">
<Star className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<Button onClick={() => nav('/')}></Button>
</div>
) : (
<div className="content-surface">
{list.map(fav => (
<div
key={fav.id}
className="post-row"
onClick={() => nav(`/post/${fav.post_id}`)}
>
<div className="post-body">
<div className="post-title">{fav.post?.title || '帖子已删除'}</div>
<div className="post-meta">
{fav.post?.board?.name && <span>{fav.post.board.name}</span>}
{fav.post?.user?.nickname && <span>{fav.post.user.nickname}</span>}
<span> {formatTime(fav.created_at)}</span>
fav.post ? (
<PostListItem
key={fav.id}
post={fav.post}
onClick={() => nav(`/post/${fav.post_id}`)}
/>
) : (
<button
key={fav.id}
type="button"
className="post-row"
onClick={() => nav(`/post/${fav.post_id}`)}
>
<div className="post-body">
<div className="post-title"></div>
</div>
</div>
</div>
</button>
)
))}
</div>
)}

View File

@@ -79,7 +79,7 @@ export default function LoginPage() {
</Button>
</form>
</Form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: 'var(--color-text-3)' }}>
<p className="auth-footer">
<Link to="/register"></Link>
</p>
</div>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock } from 'lucide-react';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion } from 'lucide-react';
import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -46,40 +46,51 @@ export default function PostDetailPage() {
useGlobalWheelScroll(pageRef, !loading && !!post);
const fetchComments = useCallback(async () => {
const myIds = user ? [] : loadMyCommentIds();
const comm = await api.comments(postId, myIds);
return Array.isArray(comm.comments) ? comm.comments : [];
}, [postId, user]);
const load = async () => {
if (!postId) return;
setLoading(true);
try {
const [detail, commList] = await Promise.all([
api.post(postId),
fetchComments(),
]);
setPost(detail.post);
setLiked(detail.liked);
setFavorited(detail.favorited);
setCanEdit(detail.can_edit ?? false);
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
setEditBlockReason(detail.edit_block_reason ?? '');
setComments(commList);
await refresh();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
};
const loadSeq = useRef(0);
useEffect(() => {
if (!postId) return;
setReplyTo(null);
load();
const seq = ++loadSeq.current;
setLoading(true);
setPost(null);
(async () => {
try {
// 游客评论归属:仅在进入该帖时读取,不把 user 放进依赖以免 refresh 触发重载循环
const myIds = user ? [] : loadMyCommentIds();
const [detail, comm] = await Promise.all([
api.post(postId),
api.comments(postId, myIds),
]);
if (seq !== loadSeq.current) return;
setPost(detail.post);
setLiked(detail.liked);
setFavorited(detail.favorited);
setCanEdit(detail.can_edit ?? false);
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
setEditBlockReason(detail.edit_block_reason ?? '');
setComments(Array.isArray(comm.comments) ? comm.comments : []);
// 会话刷新与正文展示解耦;勿作为 effect 依赖
void refresh();
} catch (e: unknown) {
if (seq !== loadSeq.current) return;
setPost(null);
notify.error(e instanceof Error ? e.message : '加载失败');
} finally {
if (seq === loadSeq.current) setLoading(false);
}
})();
// 仅 postId 变化时加载user/refresh 变化不得重跑
// eslint-disable-next-line react-hooks/exhaustive-deps -- 见上
}, [postId]);
// 发评后局部刷新评论列表(不整页重载)
const reloadComments = useCallback(async () => {
const myIds = user ? [] : loadMyCommentIds();
const comm = await api.comments(postId, myIds);
setComments(Array.isArray(comm.comments) ? comm.comments : []);
}, [postId, user]);
const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`);
if (!el) return;
@@ -145,7 +156,7 @@ export default function PostDetailPage() {
setReplyTo(null);
setSubmitCount(c => c + 1);
notify.success('评论成功');
setComments(await fetchComments());
await reloadComments();
setTimeout(() => jumpToFloor(r.floor), 100);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '评论失败');
@@ -165,6 +176,7 @@ export default function PostDetailPage() {
if (loading) return <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>;
if (!post) return (
<div className="empty-state">
<FileQuestion className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<Button variant="outline" onClick={() => nav('/')}></Button>
</div>
@@ -226,7 +238,7 @@ export default function PostDetailPage() {
</h1>
<div className="post-detail-author-row">
<div className="post-avatar post-avatar-lg">
{post.user?.avatar ? <img src={post.user.avatar} alt="" /> : authorInitial}
{post.user?.avatar ? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" /> : authorInitial}
</div>
<div className="post-detail-author-info">
<span className="post-detail-author-name">{post.user?.nickname}</span>
@@ -318,7 +330,7 @@ export default function PostDetailPage() {
<div className="comment-list-area">
{comments.length === 0 && !replyTo ? (
<div className="comment-empty">
<div className="comment-empty-icon">💬</div>
<MessageSquare className="comment-empty-icon" aria-hidden size={32} strokeWidth={1.5} />
<p></p>
</div>
) : (

View File

@@ -207,7 +207,7 @@ export default function ProfilePage() {
return (
<div className="page-wrap">
<div className="page-inner-wide" style={{ maxWidth: 640 }}>
<div className="page-inner-wide page-inner-wide--profile">
<Button variant="ghost" className="mb-3" onClick={() => nav(-1)}>
<ArrowLeft />
@@ -244,7 +244,7 @@ export default function ProfilePage() {
>
<div className={`profile-avatar-lg${pendingAvatar ? ' profile-avatar-lg--pending' : ''}`}>
{displayAvatar
? <img src={displayAvatar} alt="" />
? <img src={displayAvatar} alt="" loading="lazy" decoding="async" />
: user.nickname[0]}
<span className="profile-avatar-overlay">
{avatarLoading
@@ -291,7 +291,7 @@ export default function ProfilePage() {
{user.role === 'admin' && (
<div className="section-card admin-entry-card">
<div className="section-card-title"></div>
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
<p className="admin-entry-desc">
</p>
<div className="flex flex-wrap gap-2">
@@ -364,7 +364,7 @@ export default function ProfilePage() {
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="至少 6 位" {...field} />
<Input type="password" placeholder={`至少 ${limits.password_min_len}`} {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -95,7 +95,7 @@ export default function RegisterPage() {
</Button>
</form>
</Form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: 'var(--color-text-3)' }}>
<p className="auth-footer">
<Link to="/login"></Link>
</p>
</div>