支持 app.ini 配置与系统服务安装,并优化前端布局与无障碍体验。
引入类 Gitea 的 app.ini、Windows Service/systemd 控制;前端增加侧栏抽屉、回到顶部、标签输入与浮层 a11y。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user