移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,6 +15,8 @@ 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 {
|
||||
loadComposeDraft,
|
||||
saveComposeDraft,
|
||||
@@ -53,12 +55,13 @@ function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||
export default function ComposePage() {
|
||||
const nav = useNavigate();
|
||||
const { id: editIdParam } = useParams();
|
||||
const editId = editIdParam ? Number(editIdParam) : null;
|
||||
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));
|
||||
@@ -100,12 +103,12 @@ export default function ComposePage() {
|
||||
const isOwnerOrAdmin = user.role === 'admin' || post.user_id === user.id;
|
||||
if (!isOwnerOrAdmin) {
|
||||
notify.error('无权编辑此帖子');
|
||||
nav(`/post/${editId}`);
|
||||
nav(postPath(editId!, limits));
|
||||
return;
|
||||
}
|
||||
if (!postData.can_edit) {
|
||||
notify.error(postData.edit_block_reason || '当前无法编辑此帖子');
|
||||
nav(`/post/${editId}`);
|
||||
nav(postPath(editId!, limits));
|
||||
return;
|
||||
}
|
||||
const loadedBoardId = String(post.board_id);
|
||||
@@ -232,9 +235,9 @@ export default function ComposePage() {
|
||||
title !== baseline.title
|
||||
|| serializeTags(parseTags(tags)) !== serializeTags(parseTags(baseline.tags))
|
||||
|| content !== baseline.content
|
||||
|| (!isEdit && boardId !== baseline.boardId)
|
||||
|| boardId !== baseline.boardId
|
||||
);
|
||||
}, [baseline, title, tags, content, boardId, isEdit]);
|
||||
}, [baseline, title, tags, content, boardId]);
|
||||
|
||||
const {
|
||||
dialogOpen,
|
||||
@@ -287,7 +290,7 @@ export default function ComposePage() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!isEdit && !boardId) { notify.warning('请选择板块'); return; }
|
||||
if (!boardId) { notify.warning('请选择板块'); return; }
|
||||
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
|
||||
if (isHtmlEmpty(content)) { notify.warning('请输入正文内容'); return; }
|
||||
|
||||
@@ -297,19 +300,20 @@ export default function ComposePage() {
|
||||
title: trimmedTitle,
|
||||
content: content.trim(),
|
||||
tags: serializeTags(parseTags(tags)),
|
||||
board_id: boardId,
|
||||
};
|
||||
if (isEdit) {
|
||||
await api.updatePost(editId!, payload);
|
||||
notify.success('帖子已更新');
|
||||
notify.success(user?.role === 'admin' ? '帖子已更新' : '已更新并重新提交审核');
|
||||
clearComposeDraft(editId);
|
||||
markSaved();
|
||||
nav(`/post/${editId}`);
|
||||
nav(postPath(editId!, limits));
|
||||
} else {
|
||||
const res = await api.createPost({ board_id: boardId, ...payload });
|
||||
notify.success('发帖成功');
|
||||
const res = await api.createPost(payload);
|
||||
notify.success(res.message || (res.status === 'pending' ? '已提交审核' : '发帖成功'));
|
||||
clearComposeDraft(null);
|
||||
markSaved();
|
||||
nav(`/post/${res.post_id}`);
|
||||
nav(postPath(res.post_id, limits));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : isEdit ? '保存失败' : '发帖失败');
|
||||
@@ -318,8 +322,6 @@ export default function ComposePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const currentBoard = boards.find(b => String(b.id) === boardId);
|
||||
|
||||
return (
|
||||
<div className="compose-page">
|
||||
<div className="compose-canvas">
|
||||
@@ -330,7 +332,7 @@ export default function ComposePage() {
|
||||
type="button"
|
||||
className="compose-back"
|
||||
onClick={() => requestLeave(() => {
|
||||
if (isEdit) nav(`/post/${editId}`);
|
||||
if (isEdit) nav(postPath(editId!, limits));
|
||||
else nav(-1);
|
||||
})}
|
||||
>
|
||||
@@ -360,26 +362,20 @@ export default function ComposePage() {
|
||||
<section className="compose-context" aria-label="发布设置">
|
||||
<div className="compose-context-row">
|
||||
<span className="compose-context-label">板块</span>
|
||||
{!isEdit ? (
|
||||
<div className="compose-board-pills" role="listbox" aria-label="选择板块">
|
||||
{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>
|
||||
) : currentBoard ? (
|
||||
<div className="compose-board-pills">
|
||||
<span className="compose-board-pill active">{currentBoard.name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
@@ -11,6 +11,8 @@ import PostListItem from '../components/PostListItem';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
|
||||
interface FavItem {
|
||||
id: number;
|
||||
@@ -23,6 +25,7 @@ export default function FavoritesPage() {
|
||||
const nav = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
useNoIndexSEO('我的收藏');
|
||||
const [list, setList] = useState<FavItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -80,6 +83,7 @@ export default function FavoritesPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,18 +18,34 @@ import {
|
||||
type FeedNavState,
|
||||
} from '../utils/feedCache';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
||||
|
||||
export default function HomePage() {
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const { branding } = useSiteBranding();
|
||||
const { limits, loading: limitsLoading } = useForumLimits();
|
||||
const pageSize = Math.max(1, limits.page_size_default);
|
||||
|
||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||
const keyword = params.get('keyword') || '';
|
||||
const sort = parseFeedSort(params.get('sort'));
|
||||
const board = (ctx?.boards ?? []).find(b => b.id === boardId);
|
||||
const isSiteHome = !boardId && !keyword;
|
||||
const siteIntro = siteMetaDescription(branding);
|
||||
const feedTitle = keyword
|
||||
? `搜索:${keyword}`
|
||||
: (boardId && board ? board.name : '');
|
||||
usePageSEO({
|
||||
title: feedTitle || undefined,
|
||||
description: board?.description?.trim() || siteIntro,
|
||||
keywords: joinSEOKeywords(board?.name, branding.keywords),
|
||||
canonicalPath: boardId ? `/?board=${boardId}` : '/',
|
||||
ogType: 'website',
|
||||
});
|
||||
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [postTotal, setPostTotal] = useState(0);
|
||||
@@ -43,6 +59,8 @@ export default function HomePage() {
|
||||
const loadingRef = useRef(false);
|
||||
const pageRef = useRef(1);
|
||||
pageRef.current = page;
|
||||
// 与当前筛选一致的列表快照(供卸载/切换筛选时写入缓存)
|
||||
const feedSnapRef = useRef({ boardId, keyword, sort, posts, postTotal, page });
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||
const showPagination = totalPages > 1 && posts.length > 0;
|
||||
@@ -143,18 +161,31 @@ export default function HomePage() {
|
||||
beginFeedRefresh,
|
||||
]);
|
||||
|
||||
// 离开当前筛选条件时写入内存缓存
|
||||
// 筛选未变时同步列表快照;变筛选的那一帧先保留旧快照供 cleanup 写入
|
||||
if (
|
||||
feedSnapRef.current.boardId === boardId
|
||||
&& feedSnapRef.current.keyword === keyword
|
||||
&& feedSnapRef.current.sort === sort
|
||||
) {
|
||||
feedSnapRef.current = { boardId, keyword, sort, posts, postTotal, page };
|
||||
}
|
||||
|
||||
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps(否则会用旧列表污染新 keyword)
|
||||
useEffect(() => {
|
||||
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
|
||||
feedSnapRef.current = { boardId, keyword, sort, posts: [], postTotal: 0, page: 1 };
|
||||
return () => {
|
||||
if (skipCacheSaveRef.current || posts.length === 0) return;
|
||||
setFeedCache(boardId, keyword, sort, {
|
||||
posts,
|
||||
postTotal,
|
||||
page,
|
||||
if (skipCacheSaveRef.current) return;
|
||||
const snap = feedSnapRef.current;
|
||||
if (snap.posts.length === 0) return;
|
||||
setFeedCache(snap.boardId, snap.keyword, snap.sort, {
|
||||
posts: snap.posts,
|
||||
postTotal: snap.postTotal,
|
||||
page: snap.page,
|
||||
scrollTop: scrollTopRef.current,
|
||||
});
|
||||
};
|
||||
}, [boardId, keyword, sort, posts, postTotal, page]);
|
||||
}, [boardId, keyword, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
|
||||
@@ -195,16 +226,19 @@ export default function HomePage() {
|
||||
<div className="page-wrap page-wrap--feed">
|
||||
<div className="feed-panel">
|
||||
<div className="feed-top">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
boards={ctx?.boards ?? []}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
/>
|
||||
{showSortBar && (
|
||||
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
|
||||
)}
|
||||
<div className="feed-top__bar">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
boards={ctx?.boards ?? []}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
titleAs={isSiteHome ? 'h2' : 'h1'}
|
||||
/>
|
||||
{showSortBar && (
|
||||
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<VirtualPostList
|
||||
posts={posts}
|
||||
@@ -221,6 +255,9 @@ export default function HomePage() {
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
keyword={keyword}
|
||||
boardId={boardId}
|
||||
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,17 @@ import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import AuthPasswordInput from '@/components/AuthPasswordInput';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { resolveAuthRedirect, registerPath, navigateAfterAuth } from '../utils/authRedirect';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
|
||||
const schema = z.object({
|
||||
@@ -25,6 +28,7 @@ export default function LoginPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
const { branding } = useSiteBranding();
|
||||
useNoIndexSEO('登录');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const redirectTo = resolveAuthRedirect(searchParams);
|
||||
const form = useForm<FormValues>({
|
||||
@@ -49,7 +53,9 @@ export default function LoginPage() {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
<Link to="/" className="auth-brand-link" aria-label={`返回${branding.name}`}>
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
</Link>
|
||||
<h1>登录{branding.name}</h1>
|
||||
<p className="subtitle">{branding.slogan || '欢迎回来'}</p>
|
||||
<Form {...form}>
|
||||
@@ -74,7 +80,7 @@ export default function LoginPage() {
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="密码" autoComplete="current-password" {...field} />
|
||||
<AuthPasswordInput placeholder="密码" autoComplete="current-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -88,6 +94,10 @@ export default function LoginPage() {
|
||||
<p className="auth-footer">
|
||||
没有账号?<Link to={registerPath(redirectTo === '/' ? undefined : redirectTo)}>注册</Link>
|
||||
</p>
|
||||
<Link to="/" className="auth-back">
|
||||
<ArrowLeft size={16} aria-hidden />
|
||||
返回论坛
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
465
frontend/src/pages/MessagesPage.tsx
Normal file
465
frontend/src/pages/MessagesPage.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Bell, CheckCheck, Inbox, Send } 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 { MessageConversation, PrivateMessage, User } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { postPath } from '../utils/permalink';
|
||||
import { userPath } from '../utils/userPath';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function kindLabel(kind: string) {
|
||||
switch (kind) {
|
||||
case 'reject': return '拒帖通知';
|
||||
case 'report_result': return '举报结果';
|
||||
case 'system': return '系统通知';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function peerTitle(conv: MessageConversation | null, peerUser: User | null | undefined, peerId: number) {
|
||||
if (peerId === 0 || conv?.is_system) return '系统通知';
|
||||
return peerUser?.nickname || conv?.peer_user?.nickname || `用户 #${peerId}`;
|
||||
}
|
||||
|
||||
function peerInitial(name: string) {
|
||||
return name.trim().charAt(0) || '?';
|
||||
}
|
||||
|
||||
function previewText(msg?: PrivateMessage) {
|
||||
if (!msg) return '暂无消息';
|
||||
const text = (msg.content || msg.subject || '').replace(/\s+/g, ' ').trim();
|
||||
return text || msg.subject || '暂无消息';
|
||||
}
|
||||
|
||||
function AvatarBubble({
|
||||
name,
|
||||
avatar,
|
||||
system,
|
||||
}: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
system?: boolean;
|
||||
}) {
|
||||
if (system) {
|
||||
return (
|
||||
<span className="pm-avatar pm-avatar--system" aria-hidden>
|
||||
<Bell size={16} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (avatar) {
|
||||
return <img src={avatar} alt="" className="pm-avatar" loading="lazy" decoding="async" />;
|
||||
}
|
||||
return <span className="pm-avatar pm-avatar--fallback">{peerInitial(name)}</span>;
|
||||
}
|
||||
|
||||
export default function MessagesPage() {
|
||||
const nav = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
useNoIndexSEO('站内私信');
|
||||
|
||||
const peerParam = params.get('peer');
|
||||
const selectedPeer = peerParam === null || peerParam === ''
|
||||
? null
|
||||
: Number(peerParam);
|
||||
const peerSelected = selectedPeer !== null && !Number.isNaN(selectedPeer);
|
||||
|
||||
const [conversations, setConversations] = useState<MessageConversation[]>([]);
|
||||
const [convTotal, setConvTotal] = useState(0);
|
||||
const [convPage, setConvPage] = useState(1);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
|
||||
const [messages, setMessages] = useState<PrivateMessage[]>([]);
|
||||
const [msgTotal, setMsgTotal] = useState(0);
|
||||
const [threadLoading, setThreadLoading] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [peerUser, setPeerUser] = useState<User | null>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const threadEndRef = useRef<HTMLDivElement>(null);
|
||||
const threadScrollRef = useRef<HTMLDivElement>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
|
||||
const loadConversations = useCallback(async (page = 1, append = false) => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
const r = await api.messageConversations({ page, size: 30 });
|
||||
const next = r.conversations || [];
|
||||
setConversations((prev) => (append ? [...prev, ...next] : next));
|
||||
setConvTotal(r.total || 0);
|
||||
setConvPage(r.page || page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
nav(loginPath('/messages'));
|
||||
return;
|
||||
}
|
||||
loadConversations(1);
|
||||
}, [user, authLoading, nav, loadConversations]);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
requestAnimationFrame(() => {
|
||||
threadEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !peerSelected || selectedPeer === null) {
|
||||
setMessages([]);
|
||||
setPeerUser(null);
|
||||
setMsgTotal(0);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setThreadLoading(true);
|
||||
stickToBottomRef.current = true;
|
||||
api.conversationMessages(selectedPeer, { size: 50 })
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setMessages(r.messages || []);
|
||||
setMsgTotal(r.total || 0);
|
||||
setPeerUser(r.peer_user || null);
|
||||
setConversations((prev) => prev.map((c) => (
|
||||
c.peer_user_id === selectedPeer ? { ...c, unread_count: 0 } : c
|
||||
)));
|
||||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载会话失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setThreadLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [user, peerSelected, selectedPeer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadLoading && stickToBottomRef.current) {
|
||||
scrollToBottom(false);
|
||||
}
|
||||
}, [messages, threadLoading, scrollToBottom]);
|
||||
|
||||
const openPeer = (peerId: number) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set('peer', String(peerId));
|
||||
setParams(p, { replace: true });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const closeThread = () => {
|
||||
setParams(new URLSearchParams(), { replace: true });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const markAll = async () => {
|
||||
try {
|
||||
await api.markAllMessagesRead();
|
||||
notify.success('已全部标为已读');
|
||||
setConversations((prev) => prev.map((c) => ({ ...c, unread_count: 0 })));
|
||||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadOlder = async () => {
|
||||
if (!peerSelected || selectedPeer === null || messages.length === 0) return;
|
||||
const oldest = messages[0]?.id;
|
||||
if (!oldest) return;
|
||||
setLoadingOlder(true);
|
||||
const el = threadScrollRef.current;
|
||||
const prevHeight = el?.scrollHeight ?? 0;
|
||||
try {
|
||||
const r = await api.conversationMessages(selectedPeer, { size: 40, before: oldest });
|
||||
const older = r.messages || [];
|
||||
if (older.length === 0) return;
|
||||
stickToBottomRef.current = false;
|
||||
setMessages((prev) => [...older, ...prev]);
|
||||
requestAnimationFrame(() => {
|
||||
if (el) el.scrollTop = el.scrollHeight - prevHeight;
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (!peerSelected || selectedPeer === null || selectedPeer === 0) return;
|
||||
const content = draft.trim();
|
||||
if (!content) {
|
||||
notify.warning('请填写内容');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
const r = await api.sendMessage({ to_user_id: selectedPeer, content });
|
||||
stickToBottomRef.current = true;
|
||||
setMessages((prev) => [...prev, r.message]);
|
||||
setMsgTotal((n) => n + 1);
|
||||
setDraft('');
|
||||
setConversations((prev) => {
|
||||
const rest = prev.filter((c) => c.peer_user_id !== selectedPeer);
|
||||
const existing = prev.find((c) => c.peer_user_id === selectedPeer);
|
||||
const next: MessageConversation = {
|
||||
peer_user_id: selectedPeer,
|
||||
peer_user: peerUser || existing?.peer_user,
|
||||
is_system: false,
|
||||
last_message: r.message,
|
||||
unread_count: 0,
|
||||
updated_at: r.message.created_at,
|
||||
};
|
||||
return [next, ...rest];
|
||||
});
|
||||
scrollToBottom(true);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || (listLoading && conversations.length === 0 && !peerSelected)) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
if (!user) return null;
|
||||
|
||||
const activeConv = peerSelected && selectedPeer !== null
|
||||
? conversations.find((c) => c.peer_user_id === selectedPeer) || null
|
||||
: null;
|
||||
const title = peerSelected && selectedPeer !== null
|
||||
? peerTitle(activeConv, peerUser, selectedPeer)
|
||||
: '';
|
||||
const canCompose = peerSelected && selectedPeer !== null && selectedPeer > 0;
|
||||
const unreadTotal = conversations.reduce((n, c) => n + (c.unread_count || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide">
|
||||
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
|
||||
<div className="pm-page-head">
|
||||
<div>
|
||||
<h1 className="page-title">站内私信</h1>
|
||||
<p className="page-desc">按会话查看,与用户即时沟通,并接收系统通知</p>
|
||||
</div>
|
||||
{unreadTotal > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={markAll}>
|
||||
<CheckCheck size={14} />
|
||||
全部已读
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn('pm-layout content-surface', peerSelected && 'pm-layout--thread')}>
|
||||
<aside className="pm-list" aria-label="会话列表">
|
||||
{listLoading && conversations.length === 0 ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="pm-empty">
|
||||
<Inbox size={28} strokeWidth={1.5} aria-hidden />
|
||||
<p>还没有会话</p>
|
||||
<span>在用户主页点击「发私信」开始对话</span>
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((c) => {
|
||||
const name = peerTitle(c, c.peer_user, c.peer_user_id);
|
||||
const active = peerSelected && selectedPeer === c.peer_user_id;
|
||||
return (
|
||||
<button
|
||||
key={c.peer_user_id}
|
||||
type="button"
|
||||
className={cn('pm-conv-item', active && 'active', c.unread_count > 0 && 'unread')}
|
||||
onClick={() => openPeer(c.peer_user_id)}
|
||||
>
|
||||
<AvatarBubble
|
||||
name={name}
|
||||
avatar={c.peer_user?.avatar}
|
||||
system={c.is_system || c.peer_user_id === 0}
|
||||
/>
|
||||
<div className="pm-conv-item__body">
|
||||
<div className="pm-conv-item__top">
|
||||
<span className="pm-conv-item__name">{name}</span>
|
||||
<span className="pm-conv-item__time">
|
||||
{formatTime(c.last_message?.created_at || c.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pm-conv-item__preview">
|
||||
<span>{previewText(c.last_message)}</span>
|
||||
{c.unread_count > 0 && (
|
||||
<span className="pm-conv-item__badge">
|
||||
{c.unread_count > 99 ? '99+' : c.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{convTotal > conversations.length && (
|
||||
<div className="pm-list-more">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={listLoading}
|
||||
onClick={() => loadConversations(convPage + 1, true)}
|
||||
>
|
||||
加载更多会话
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="pm-thread" aria-label="会话内容">
|
||||
{!peerSelected || selectedPeer === null ? (
|
||||
<div className="pm-empty pm-empty--thread">
|
||||
<Send size={32} strokeWidth={1.4} aria-hidden />
|
||||
<p>选择左侧会话开始聊天</p>
|
||||
<span>系统通知也会出现在会话列表中</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<header className="pm-thread-head">
|
||||
<button type="button" className="pm-thread-back" onClick={closeThread} aria-label="返回会话列表">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<AvatarBubble
|
||||
name={title}
|
||||
avatar={peerUser?.avatar || activeConv?.peer_user?.avatar}
|
||||
system={selectedPeer === 0}
|
||||
/>
|
||||
<div className="pm-thread-head__meta">
|
||||
{selectedPeer > 0 ? (
|
||||
<Link to={userPath(selectedPeer)} className="pm-thread-head__name">{title}</Link>
|
||||
) : (
|
||||
<span className="pm-thread-head__name">{title}</span>
|
||||
)}
|
||||
<span className="pm-thread-head__sub">
|
||||
{selectedPeer === 0 ? '审核与系统消息' : '私信对话'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="pm-thread-scroll"
|
||||
ref={threadScrollRef}
|
||||
onScroll={(e) => {
|
||||
const t = e.currentTarget;
|
||||
stickToBottomRef.current = t.scrollHeight - t.scrollTop - t.clientHeight < 80;
|
||||
}}
|
||||
>
|
||||
{threadLoading ? (
|
||||
<div className="flex justify-center py-16"><Spinner /></div>
|
||||
) : (
|
||||
<>
|
||||
{msgTotal > messages.length && (
|
||||
<div className="pm-thread-older">
|
||||
<Button variant="ghost" size="sm" loading={loadingOlder} onClick={loadOlder}>
|
||||
查看更早消息
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{messages.length === 0 ? (
|
||||
<div className="pm-empty">还没有消息,打个招呼吧</div>
|
||||
) : (
|
||||
messages.map((m) => {
|
||||
const mine = m.from_user_id === user.id;
|
||||
const system = m.from_user_id === 0 || m.kind !== 'user';
|
||||
const label = kindLabel(m.kind);
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cn(
|
||||
'pm-bubble-row',
|
||||
mine && 'pm-bubble-row--mine',
|
||||
system && !mine && 'pm-bubble-row--system',
|
||||
)}
|
||||
>
|
||||
<div className={cn('pm-bubble', mine && 'pm-bubble--mine', system && !mine && 'pm-bubble--system')}>
|
||||
{label && !mine && (
|
||||
<span className="pm-bubble__kind">{label}</span>
|
||||
)}
|
||||
{m.subject && m.kind !== 'user' && (
|
||||
<div className="pm-bubble__subject">{m.subject}</div>
|
||||
)}
|
||||
<div className="pm-bubble__text">{m.content}</div>
|
||||
{m.related_post_id ? (
|
||||
<Link className="pm-bubble__link" to={postPath(m.related_post_id)}>
|
||||
查看相关帖子 #{m.related_post_id}
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="pm-bubble__meta">
|
||||
<time>{formatTime(m.created_at)}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={threadEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCompose ? (
|
||||
<footer className="pm-composer">
|
||||
<textarea
|
||||
className="pm-composer__input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={4000}
|
||||
placeholder={`发送给 ${title}…`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="pm-composer__send"
|
||||
loading={sending}
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<Send size={16} />
|
||||
发送
|
||||
</Button>
|
||||
</footer>
|
||||
) : (
|
||||
<footer className="pm-composer pm-composer--readonly">
|
||||
系统通知不可回复;如需联系管理员,请从用户主页发私信。
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
frontend/src/pages/NotFoundPage.tsx
Normal file
59
frontend/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FileQuestion, Home } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePageSEO } from '../hooks/usePageSEO';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
|
||||
interface Props {
|
||||
/** 独立全屏(无 MainLayout 时) */
|
||||
standalone?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 统一 404 页面 */
|
||||
export default function NotFoundPage({
|
||||
standalone = false,
|
||||
title = '页面不存在',
|
||||
description = '您访问的页面不存在,或内容已被删除。',
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
usePageSEO({
|
||||
title,
|
||||
description,
|
||||
robots: 'noindex,follow',
|
||||
});
|
||||
|
||||
const body = (
|
||||
<div className="error-page">
|
||||
<div className="error-page__code" aria-hidden>404</div>
|
||||
<FileQuestion className="error-page__icon" aria-hidden size={40} strokeWidth={1.5} />
|
||||
<h1 className="error-page__title">{title}</h1>
|
||||
<p className="error-page__desc">{description}</p>
|
||||
<div className="error-page__actions">
|
||||
<Button onClick={() => nav('/')}>
|
||||
<Home />
|
||||
返回首页
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => nav('/projects')}>
|
||||
浏览项目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (standalone) {
|
||||
return (
|
||||
<div className="error-page-shell">
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
{body}
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion, Trash2 } from 'lucide-react';
|
||||
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban } from 'lucide-react';
|
||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -18,22 +19,38 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem, Comment } from '../api/types';
|
||||
import type { PostItem, Comment, ReportReason } from '../api/types';
|
||||
import { REPORT_REASON_OPTIONS } from '../utils/report';
|
||||
import CommentThreadList from '../components/CommentThreadList';
|
||||
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
|
||||
import PostContent from '../components/PostContent';
|
||||
import PostRevisionPanel from '../components/PostRevisionPanel';
|
||||
import ArticleOutline from '../components/ArticleOutline';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
||||
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
|
||||
import { loadMyCommentIds } from '../utils/guest';
|
||||
import { clearAllFeedCache } from '../utils/feedCache';
|
||||
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
|
||||
/** 格式化剩余可编辑时间 */
|
||||
function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||
@@ -50,9 +67,11 @@ function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||
|
||||
export default function PostDetailPage() {
|
||||
const { id } = useParams();
|
||||
const postId = Number(id);
|
||||
const postId = parsePermalinkID(id);
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, refresh } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
|
||||
|
||||
const [post, setPost] = useState<PostItem | null>(null);
|
||||
@@ -72,6 +91,13 @@ export default function PostDetailPage() {
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
const [deletingPost, setDeletingPost] = useState(false);
|
||||
const [headings, setHeadings] = useState<PostHeading[]>([]);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportReason, setReportReason] = useState<ReportReason>('spam');
|
||||
const [reportDetail, setReportDetail] = useState('');
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
@@ -80,6 +106,38 @@ export default function PostDetailPage() {
|
||||
|
||||
useGlobalWheelScroll(pageRef, !loading && !!post);
|
||||
|
||||
// SPA 内跳转时纠正非规范伪静态路径
|
||||
useEffect(() => {
|
||||
if (!postId || Number.isNaN(postId)) return;
|
||||
const target = canonicalRedirectPath('post', postId, location.pathname, limits);
|
||||
if (target) nav(target + location.search + location.hash, { replace: true });
|
||||
}, [postId, location.pathname, location.search, location.hash, limits, nav]);
|
||||
|
||||
const brand = getCachedSiteBranding();
|
||||
const postContent = post?.content ?? '';
|
||||
const postSEO = post ? {
|
||||
title: post.title,
|
||||
description: excerptFromHTML(postContent),
|
||||
keywords: joinSEOKeywords(post.board?.name, brand.keywords),
|
||||
canonicalPath: postPath(post.id, limits),
|
||||
ogType: 'article',
|
||||
ogImage: firstImageFromHTML(postContent) || post.user?.avatar || brand.og_image || '',
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'DiscussionForumPosting',
|
||||
headline: post.title,
|
||||
description: excerptFromHTML(postContent),
|
||||
datePublished: post.created_at,
|
||||
dateModified: post.updated_at || post.created_at,
|
||||
url: postPath(post.id, limits),
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: post.user?.nickname || post.user?.username || '',
|
||||
},
|
||||
},
|
||||
} : null;
|
||||
usePageSEO(postSEO);
|
||||
|
||||
const handleHeadingsChange = useCallback((next: PostHeading[]) => {
|
||||
setHeadings(next);
|
||||
}, []);
|
||||
@@ -93,15 +151,22 @@ export default function PostDetailPage() {
|
||||
headings,
|
||||
scrollRoot: pageRef.current,
|
||||
title: '文章目录',
|
||||
author: post.user ?? null,
|
||||
publishedAt: post.created_at,
|
||||
viewCount: post.view_count,
|
||||
});
|
||||
return () => setPostOutline(null);
|
||||
}, [headings, loading, post, setPostOutline]);
|
||||
|
||||
const loadSeq = useRef(0);
|
||||
const postPath = `/post/${postId}`;
|
||||
const detailPath = postPath(postId, limits);
|
||||
|
||||
useEffect(() => {
|
||||
if (!postId) return;
|
||||
if (!postId || Number.isNaN(postId)) {
|
||||
setPost(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setReplyTo(null);
|
||||
setEditingCommentId(null);
|
||||
setHeadings([]);
|
||||
@@ -126,10 +191,9 @@ export default function PostDetailPage() {
|
||||
setEditWindowHours(detail.post_edit_window_hours ?? 0);
|
||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||
void refresh();
|
||||
} catch (e: unknown) {
|
||||
} catch {
|
||||
if (seq !== loadSeq.current) return;
|
||||
setPost(null);
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
@@ -152,12 +216,27 @@ export default function PostDetailPage() {
|
||||
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||
}, []);
|
||||
|
||||
// 从 #floor-N 定位到对应评论(右栏最新评论等入口)
|
||||
useEffect(() => {
|
||||
if (loading || !post) return;
|
||||
const m = location.hash.match(/^#floor-(\d+)$/);
|
||||
if (!m) return;
|
||||
const floor = Number(m[1]);
|
||||
if (!floor) return;
|
||||
const t = window.setTimeout(() => jumpToFloor(floor), 80);
|
||||
return () => clearTimeout(t);
|
||||
}, [loading, post, comments, location.hash, jumpToFloor]);
|
||||
|
||||
const requireLogin = (actionLabel: string) => {
|
||||
notify.warning(`登录后即可${actionLabel}`);
|
||||
nav(loginPath(postPath));
|
||||
nav(loginPath(detailPath));
|
||||
};
|
||||
|
||||
const handleReplyTo = (comment: Comment) => {
|
||||
if (!user) {
|
||||
requireLogin('回复');
|
||||
return;
|
||||
}
|
||||
setEditingCommentId(null);
|
||||
if (replyTo?.id === comment.id) {
|
||||
setReplyTo(null);
|
||||
@@ -199,20 +278,20 @@ export default function PostDetailPage() {
|
||||
};
|
||||
|
||||
const handleSubmitComment = async (data: CommentSubmitData) => {
|
||||
if (!user) {
|
||||
requireLogin('评论');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const r = await api.addComment(postId, {
|
||||
content: data.content,
|
||||
replyTo: replyTo?.id,
|
||||
guestNick: data.guestNick,
|
||||
guestEmail: data.guestEmail,
|
||||
guestUrl: data.guestUrl,
|
||||
isPrivate: data.isPrivate,
|
||||
});
|
||||
if (!user) addMyCommentId(r.id);
|
||||
setReplyTo(null);
|
||||
setSubmitCount(c => c + 1);
|
||||
notify.success('评论成功');
|
||||
notify.success(r.message || (r.status === 'pending' ? '评论已提交审核' : '评论成功'));
|
||||
await reloadComments();
|
||||
setTimeout(() => jumpToFloor(r.floor), 100);
|
||||
} catch (e: unknown) {
|
||||
@@ -227,11 +306,16 @@ export default function PostDetailPage() {
|
||||
const r = await api.updateComment(comment.id, content);
|
||||
setComments(list => list.map(c => (
|
||||
c.id === comment.id
|
||||
? { ...c, content: r.content || content, updated_at: new Date().toISOString() }
|
||||
? {
|
||||
...c,
|
||||
content: r.content || content,
|
||||
updated_at: new Date().toISOString(),
|
||||
status: r.status || c.status,
|
||||
}
|
||||
: c
|
||||
)));
|
||||
setEditingCommentId(null);
|
||||
notify.success('评论已更新');
|
||||
notify.success(r.message || '评论已更新');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
throw e;
|
||||
@@ -251,6 +335,19 @@ export default function PostDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveComment = async (comment: Comment) => {
|
||||
try {
|
||||
const r = await api.adminApproveComment(comment.id);
|
||||
setComments(list => list.map(c => (
|
||||
c.id === comment.id ? { ...c, status: r.status } : c
|
||||
)));
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '审核失败');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePost = async () => {
|
||||
setDeletingPost(true);
|
||||
try {
|
||||
@@ -275,13 +372,14 @@ 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>
|
||||
);
|
||||
if (!post) {
|
||||
return (
|
||||
<NotFoundPage
|
||||
title="帖子不存在"
|
||||
description="该帖子不存在,或已被删除。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const authorInitial = post.user?.nickname?.[0] || '?';
|
||||
const tags = post.tags?.split(/[,,]/).map(t => t.trim()).filter(Boolean) ?? [];
|
||||
@@ -305,6 +403,74 @@ export default function PostDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeature = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const r = await api.adminFeaturePost(postId, !post.featured);
|
||||
setPost(p => p ? { ...p, featured: r.featured } : p);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const r = await api.adminApprovePost(postId);
|
||||
setPost(p => p ? { ...p, status: r.status } : p);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReport = async () => {
|
||||
if (!user) {
|
||||
requireLogin('举报');
|
||||
return;
|
||||
}
|
||||
setReporting(true);
|
||||
try {
|
||||
const r = await api.reportPost(postId, {
|
||||
reason: reportReason,
|
||||
detail: reportDetail.trim() || undefined,
|
||||
});
|
||||
notify.success(r.message);
|
||||
setReportOpen(false);
|
||||
setReportDetail('');
|
||||
setReportReason('spam');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '举报失败');
|
||||
} finally {
|
||||
setReporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!rejectReason.trim()) {
|
||||
notify.warning('请填写拒绝原因');
|
||||
return;
|
||||
}
|
||||
setRejecting(true);
|
||||
try {
|
||||
const r = await api.adminRejectPost(postId, rejectReason.trim());
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
setRejectOpen(false);
|
||||
nav('/');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setRejecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLock = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
@@ -322,8 +488,12 @@ export default function PostDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const jumpToComments = () => {
|
||||
commentSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-wrap post-detail-page" ref={pageRef}>
|
||||
<article className="page-wrap post-detail-page" ref={pageRef}>
|
||||
<div className="post-detail-header">
|
||||
<div className="post-detail-nav">
|
||||
<Button variant="ghost" size="sm" onClick={() => nav(-1)}>
|
||||
@@ -335,8 +505,22 @@ export default function PostDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{post.status === 'pending' && (
|
||||
<div className="post-moderation-banner post-moderation-banner--pending">
|
||||
该帖子审核中,仅你与管理员可见;通过后将公开显示。
|
||||
</div>
|
||||
)}
|
||||
{post.status === 'rejected' && (
|
||||
<div className="post-moderation-banner post-moderation-banner--rejected">
|
||||
该帖子未通过审核,仅你与管理员可见。可修改后重新提交,或查看站内私信中的拒绝原因。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="post-detail-head">
|
||||
<h1 className="post-detail-title">
|
||||
{post.status === 'pending' && <Badge variant="orange" className="mr-2 align-middle">审核中</Badge>}
|
||||
{post.status === 'rejected' && <Badge variant="destructive" className="mr-2 align-middle">未通过</Badge>}
|
||||
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
|
||||
{post.pinned && <PinnedIcon className="mr-2" size={18} />}
|
||||
{post.title}
|
||||
</h1>
|
||||
@@ -397,22 +581,38 @@ export default function PostDetailPage() {
|
||||
variant={liked ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleLike}
|
||||
title={!user ? '登录后可点赞' : undefined}
|
||||
title={!user ? '登录后即可点赞' : undefined}
|
||||
className={!user ? 'post-action-guest' : undefined}
|
||||
>
|
||||
<ThumbsUp />
|
||||
点赞 {post.like_count}
|
||||
{!user ? '登录后点赞' : `点赞 ${post.like_count}`}
|
||||
</Button>
|
||||
<Button
|
||||
variant={favorited ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleFavorite}
|
||||
title={!user ? '登录后可收藏' : undefined}
|
||||
title={!user ? '登录后即可收藏' : undefined}
|
||||
className={!user ? 'post-action-guest' : undefined}
|
||||
>
|
||||
<Star />
|
||||
{favorited ? '已收藏' : '收藏'}
|
||||
{!user ? '登录后收藏' : (favorited ? '已收藏' : '收藏')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={jumpToComments}>
|
||||
<MessageSquare />
|
||||
评论 {comments.length}
|
||||
</Button>
|
||||
{user && user.id !== post.user_id && (
|
||||
<Button variant="outline" size="sm" onClick={() => setReportOpen(true)}>
|
||||
<Flag />
|
||||
举报
|
||||
</Button>
|
||||
)}
|
||||
{!user && (
|
||||
<Button variant="outline" size="sm" onClick={() => requireLogin('举报')}>
|
||||
<Flag />
|
||||
举报
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button variant="outline" size="sm" onClick={() => nav(`/post/${postId}/edit`)}>
|
||||
<Pencil />
|
||||
@@ -425,7 +625,7 @@ export default function PostDetailPage() {
|
||||
编辑历史
|
||||
</Button>
|
||||
)}
|
||||
{isOwnerOrAdmin && (
|
||||
{isAdmin && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={deletingPost}>
|
||||
@@ -436,7 +636,9 @@ export default function PostDetailPage() {
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该帖子?</AlertDialogTitle>
|
||||
<AlertDialogDescription>相关评论也将一并删除,不可恢复。</AlertDialogDescription>
|
||||
<AlertDialogDescription>
|
||||
帖子与评论将移入回收站,可在后台恢复或永久删除。普通用户不可自行删除内容。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
@@ -455,6 +657,15 @@ export default function PostDetailPage() {
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
{(post.status === 'pending' || post.status === 'rejected') && (
|
||||
<Button variant="default" size="sm" onClick={handleApprove}>
|
||||
通过审核
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleFeature}>
|
||||
<Sparkles />
|
||||
{post.featured ? '取消精华' : '设为精华'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handlePin}>
|
||||
<Pin />
|
||||
{post.pinned ? '取消置顶' : '置顶'}
|
||||
@@ -463,11 +674,80 @@ export default function PostDetailPage() {
|
||||
<Lock />
|
||||
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
|
||||
</Button>
|
||||
{post.status !== 'rejected' && (
|
||||
<Button variant="outline" size="sm" onClick={() => setRejectOpen(true)}>
|
||||
<Ban />
|
||||
拒绝并通知
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>举报帖子</DialogTitle>
|
||||
<DialogDescription>请选择原因,管理员将尽快处理。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
<label className="pm-field">
|
||||
<span>举报原因</span>
|
||||
<select
|
||||
value={reportReason}
|
||||
onChange={(e) => setReportReason(e.target.value as ReportReason)}
|
||||
>
|
||||
{REPORT_REASON_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="pm-field">
|
||||
<span>补充说明(可选)</span>
|
||||
<textarea
|
||||
value={reportDetail}
|
||||
onChange={(e) => setReportDetail(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
placeholder="补充更多细节…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReportOpen(false)}>取消</Button>
|
||||
<Button loading={reporting} onClick={handleReport}>提交举报</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>拒绝帖子并通知作者</DialogTitle>
|
||||
<DialogDescription>
|
||||
帖子将移入回收站,拒绝原因会通过站内私信发送给作者。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
<label className="pm-field">
|
||||
<span>拒绝原因</span>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={5}
|
||||
maxLength={1000}
|
||||
placeholder="请说明未通过的原因…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRejectOpen(false)}>取消</Button>
|
||||
<Button variant="destructive" loading={rejecting} onClick={handleReject}>确认拒绝</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PostRevisionPanel
|
||||
postId={postId}
|
||||
currentPost={{ title: post.title, content: post.content ?? '', tags: post.tags ?? '' }}
|
||||
@@ -492,7 +772,7 @@ export default function PostDetailPage() {
|
||||
{comments.length === 0 && !replyTo ? (
|
||||
<div className="comment-empty">
|
||||
<MessageSquare className="comment-empty-icon" aria-hidden size={32} strokeWidth={1.5} />
|
||||
<p>暂无评论,来抢沙发吧</p>
|
||||
<p>{user ? '暂无评论,来抢沙发吧' : '暂无评论,登录后来抢沙发吧'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<CommentThreadList
|
||||
@@ -510,6 +790,7 @@ export default function PostDetailPage() {
|
||||
onCancelEdit={() => setEditingCommentId(null)}
|
||||
onSaveEdit={handleSaveComment}
|
||||
onDelete={handleDeleteComment}
|
||||
onApprove={user?.role === 'admin' ? handleApproveComment : undefined}
|
||||
renderReplyBox={(c) => (
|
||||
<CommentBox
|
||||
key={c.id}
|
||||
@@ -522,6 +803,7 @@ export default function PostDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem, UserActivityStats } from '../api/types';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
@@ -37,6 +38,7 @@ import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { formatDateTime } from '../utils/content';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
import { userPath } from '../utils/userPath';
|
||||
|
||||
const nickSchema = z.object({
|
||||
@@ -71,6 +73,7 @@ export default function ProfilePage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab = parseTab(params.get('tab'));
|
||||
const { user, loading: authLoading, refresh } = useAuth();
|
||||
useNoIndexSEO('个人中心');
|
||||
const [nickLoading, setNickLoading] = useState(false);
|
||||
const [sigLoading, setSigLoading] = useState(false);
|
||||
const [pwdLoading, setPwdLoading] = useState(false);
|
||||
@@ -596,7 +599,7 @@ export default function ProfilePage() {
|
||||
/>
|
||||
<div className="profile-form-footer">
|
||||
<span className="profile-form-hint">
|
||||
用户名与 ID 不可修改;头像支持 JPG / PNG / GIF / WebP,裁剪后不超过 {limits.avatar_max_mb}MB
|
||||
用户名与 ID 不可修改;头像支持 JPG / PNG / GIF / WebP,服务端保留原图并生成 WebP,裁剪后不超过 {limits.avatar_max_mb}MB
|
||||
</span>
|
||||
<Button type="submit" loading={nickLoading}>保存昵称</Button>
|
||||
</div>
|
||||
@@ -690,6 +693,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { GiteaProject } from '../api/types';
|
||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
|
||||
function formatRemoteTime(raw?: string | null): string {
|
||||
if (!raw) return '';
|
||||
@@ -27,6 +30,12 @@ export default function ProjectsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageSEO({
|
||||
title: '项目',
|
||||
description: '公开项目列表',
|
||||
keywords: joinSEOKeywords('项目', getCachedSiteBranding().keywords),
|
||||
canonicalPath: '/projects',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -114,6 +123,7 @@ export default function ProjectsPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import AuthPasswordInput from '@/components/AuthPasswordInput';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
@@ -13,6 +15,7 @@ import { useAuth } from '../hooks/useAuth';
|
||||
import { resolveAuthRedirect, loginPath, navigateAfterAuth } from '../utils/authRedirect';
|
||||
import type { RegisterConfig } from '../api/types';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
|
||||
const schema = (minLen: number) => z.object({
|
||||
@@ -28,6 +31,7 @@ type FormValues = z.infer<ReturnType<typeof schema>>;
|
||||
export default function RegisterPage() {
|
||||
const { limits } = useForumLimits();
|
||||
const { branding } = useSiteBranding();
|
||||
useNoIndexSEO('注册');
|
||||
const nav = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
@@ -37,6 +41,9 @@ export default function RegisterPage() {
|
||||
const [regConfig, setRegConfig] = useState<RegisterConfig | null>(null);
|
||||
const redirectTo = resolveAuthRedirect(searchParams);
|
||||
const requireCode = !!regConfig?.require_email_code;
|
||||
const codeLen = regConfig?.email_code_len && regConfig.email_code_len > 0
|
||||
? regConfig.email_code_len
|
||||
: 6;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema(limits.password_min_len)),
|
||||
@@ -84,9 +91,12 @@ export default function RegisterPage() {
|
||||
notify.error('论坛暂未开放注册,请联系管理员配置邮件服务');
|
||||
return;
|
||||
}
|
||||
if (requireCode && !values.email_code?.trim()) {
|
||||
form.setError('email_code', { message: '请输入邮箱验证码' });
|
||||
return;
|
||||
if (requireCode) {
|
||||
const code = (values.email_code || '').trim();
|
||||
if (!new RegExp(`^\\d{${codeLen}}$`).test(code)) {
|
||||
form.setError('email_code', { message: `请输入 ${codeLen} 位数字验证码` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -117,7 +127,9 @@ export default function RegisterPage() {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
<Link to="/" className="auth-brand-link" aria-label={`返回${branding.name}`}>
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
</Link>
|
||||
<h1>注册账号</h1>
|
||||
<p className="subtitle">{subtitle}</p>
|
||||
{regConfig && !regConfig.register_open ? (
|
||||
@@ -174,7 +186,11 @@ export default function RegisterPage() {
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder={`至少 ${limits.password_min_len} 位`} autoComplete="new-password" {...field} />
|
||||
<AuthPasswordInput
|
||||
placeholder={`至少 ${limits.password_min_len} 位`}
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -190,10 +206,17 @@ export default function RegisterPage() {
|
||||
<div className="auth-captcha-row">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="6 位数字验证码"
|
||||
placeholder={`${codeLen} 位数字`}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
pattern={`\\d{${codeLen}}`}
|
||||
maxLength={codeLen}
|
||||
className="auth-email-code-input"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const digits = e.target.value.replace(/\D/g, '').slice(0, codeLen);
|
||||
field.onChange(digits);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
@@ -207,6 +230,7 @@ export default function RegisterPage() {
|
||||
{countdown > 0 ? `${countdown}s` : '发送验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="auth-hint">请填写邮件中的 {codeLen} 位数字验证码,有效期 10 分钟</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -225,6 +249,10 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<Link to="/" className="auth-back">
|
||||
<ArrowLeft size={16} aria-hidden />
|
||||
返回论坛
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Hash,
|
||||
Heart,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
PenLine,
|
||||
Settings,
|
||||
@@ -20,20 +21,29 @@ import { useAuth } from '../hooks/useAuth';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import PostListItem from '../components/PostListItem';
|
||||
import FeedPagination from '../components/FeedPagination';
|
||||
import ComposeMessageDialog from '../components/ComposeMessageDialog';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { formatDateTime } from '../utils/content';
|
||||
import { usePageSEO } from '../hooks/usePageSEO';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { canonicalRedirectPath, parsePermalinkID, userPath } from '../utils/permalink';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { id: idParam } = useParams();
|
||||
const userId = Number(idParam);
|
||||
const userId = parsePermalinkID(idParam);
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user: me } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
|
||||
|
||||
const [profile, setProfile] = useState<UserPublic | null>(null);
|
||||
const [stats, setStats] = useState<UserActivityStats | null>(null);
|
||||
const [msgOpen, setMsgOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [postsLoading, setPostsLoading] = useState(false);
|
||||
const [postPage, setPostPage] = useState(1);
|
||||
@@ -44,23 +54,25 @@ export default function UserProfilePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || Number.isNaN(userId)) {
|
||||
notify.error('无效用户');
|
||||
nav('/');
|
||||
setNotFound(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setNotFound(false);
|
||||
setPostPage(1);
|
||||
api.userProfile(userId)
|
||||
.then(d => {
|
||||
setProfile(d.user);
|
||||
setStats(d.stats);
|
||||
})
|
||||
.catch(e => {
|
||||
notify.error(e instanceof Error ? e.message : '用户不存在');
|
||||
nav('/');
|
||||
.catch(() => {
|
||||
setProfile(null);
|
||||
setStats(null);
|
||||
setNotFound(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [userId, nav]);
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || Number.isNaN(userId) || !profile) return;
|
||||
@@ -81,10 +93,40 @@ export default function UserProfilePage() {
|
||||
return () => { cancelled = true; };
|
||||
}, [userId, profile, postPage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || Number.isNaN(userId)) return;
|
||||
const target = canonicalRedirectPath('user', userId, location.pathname, limits);
|
||||
if (target) nav(target + location.search + location.hash, { replace: true });
|
||||
}, [userId, location.pathname, location.search, location.hash, limits, nav]);
|
||||
|
||||
usePageSEO(profile ? {
|
||||
title: `${profile.nickname} 的主页`,
|
||||
description: profile.signature?.trim() || `${profile.nickname} 的主页`,
|
||||
canonicalPath: userPath(profile.id, limits),
|
||||
ogType: 'profile',
|
||||
ogImage: profile.avatar || '',
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ProfilePage',
|
||||
mainEntity: {
|
||||
'@type': 'Person',
|
||||
name: profile.nickname,
|
||||
description: profile.signature?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
} : null);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
if (!profile) return null;
|
||||
if (notFound || !profile) {
|
||||
return (
|
||||
<NotFoundPage
|
||||
title="用户不存在"
|
||||
description="该用户不存在,或账号不可访问。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const joinedAt = profile.created_at ? formatDateTime(profile.created_at) : '';
|
||||
const signature = profile.signature?.trim() || '';
|
||||
@@ -132,14 +174,29 @@ export default function UserProfilePage() {
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
{isSelf && (
|
||||
<div className="profile-avatar-actions">
|
||||
<div className="profile-avatar-actions">
|
||||
{isSelf ? (
|
||||
<Button size="sm" variant="outline" onClick={() => nav('/profile?tab=settings')}>
|
||||
<Settings size={14} />
|
||||
编辑资料
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!me) {
|
||||
nav(loginPath(userPath(profile.id)));
|
||||
return;
|
||||
}
|
||||
setMsgOpen(true);
|
||||
}}
|
||||
>
|
||||
<Mail size={14} />
|
||||
发私信
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-stat-grid" aria-label="活动统计">
|
||||
@@ -206,6 +263,17 @@ export default function UserProfilePage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
|
||||
{!isSelf && profile && me && (
|
||||
<ComposeMessageDialog
|
||||
open={msgOpen}
|
||||
onOpenChange={setMsgOpen}
|
||||
toUserId={profile.id}
|
||||
toNickname={profile.nickname}
|
||||
onSent={() => nav(`/messages?peer=${profile.id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,33 +9,74 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { Comment } from '../../api/types';
|
||||
import CommentRevisionDialog from '../../components/CommentRevisionDialog';
|
||||
import { isTimeDiffSignificant } from '../../utils/content';
|
||||
|
||||
type Tab = 'pending' | 'all';
|
||||
|
||||
function statusLabel(status?: string) {
|
||||
switch (status) {
|
||||
case 'pending': return '待审核';
|
||||
case 'rejected': return '未通过';
|
||||
case 'published': return '已公开';
|
||||
default: return status || '—';
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminCommentsPage() {
|
||||
const nav = useNavigate();
|
||||
const { ready } = useAdminGuard();
|
||||
const [tab, setTab] = useState<Tab>('pending');
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [revComment, setRevComment] = useState<Comment | null>(null);
|
||||
|
||||
const load = (p = page) => {
|
||||
const load = (p = page, st: Tab = tab) => {
|
||||
setLoading(true);
|
||||
api.adminComments(p)
|
||||
api.adminComments({ page: p, status: st === 'pending' ? 'pending' : 'all' })
|
||||
.then(d => {
|
||||
setComments(d.comments ?? []);
|
||||
setPage(d.page);
|
||||
setTotalPages(d.total_pages);
|
||||
setPendingCount(d.pending_count ?? 0);
|
||||
})
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) load(1);
|
||||
}, [ready]);
|
||||
if (ready) load(1, tab);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, tab]);
|
||||
|
||||
const approve = async (id: number) => {
|
||||
try {
|
||||
const r = await api.adminApproveComment(id);
|
||||
notify.success(r.message);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const reject = async (c: Comment) => {
|
||||
const reason = window.prompt('拒绝原因(将私信通知作者):', '不符合社区规范');
|
||||
if (reason == null) return;
|
||||
try {
|
||||
const r = await api.adminRejectComment(c.id, reason.trim() || undefined);
|
||||
notify.success(r.message);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
@@ -53,7 +94,24 @@ export default function AdminCommentsPage() {
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>评论管理</h1>
|
||||
<p>查看与删除楼层评论</p>
|
||||
<p>审核普通用户评论;通过后公开,拒绝后仅作者可见并私信通知。</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('admin-tab', tab === 'pending' && 'active')}
|
||||
onClick={() => setTab('pending')}
|
||||
>
|
||||
待审核{pendingCount > 0 ? ` (${pendingCount})` : ''}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('admin-tab', tab === 'all' && 'active')}
|
||||
onClick={() => setTab('all')}
|
||||
>
|
||||
全部评论
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
@@ -69,6 +127,7 @@ export default function AdminCommentsPage() {
|
||||
<th>帖子</th>
|
||||
<th>作者</th>
|
||||
<th>内容</th>
|
||||
<th>状态</th>
|
||||
<th>私密</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
@@ -92,24 +151,40 @@ export default function AdminCommentsPage() {
|
||||
) : (c.guest_nick || '游客')}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate">{c.content}</td>
|
||||
<td>
|
||||
<Badge variant={c.status === 'pending' ? 'orange' : c.status === 'rejected' ? 'destructive' : 'green'}>
|
||||
{statusLabel(c.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>{c.is_private ? <Badge variant="secondary">是</Badge> : '—'}</td>
|
||||
<td>{new Date(c.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">删除</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||
<AlertDialogDescription>此操作不可恢复。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => remove(c.id)}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(c.status === 'pending' || c.status === 'rejected') && (
|
||||
<Button size="sm" onClick={() => approve(c.id)}>通过</Button>
|
||||
)}
|
||||
{c.status === 'pending' && (
|
||||
<Button size="sm" variant="outline" onClick={() => reject(c)}>拒绝</Button>
|
||||
)}
|
||||
{c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at) && (
|
||||
<Button size="sm" variant="outline" onClick={() => setRevComment(c)}>编辑记录</Button>
|
||||
)}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">删除</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||
<AlertDialogDescription>删除后不可恢复。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => remove(c.id)}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -119,13 +194,19 @@ export default function AdminCommentsPage() {
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<span>{page} / {totalPages}</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CommentRevisionDialog
|
||||
open={!!revComment}
|
||||
onOpenChange={(open) => { if (!open) setRevComment(null); }}
|
||||
comment={revComment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function AdminDashboardPage() {
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>作者</th>
|
||||
<th>置顶</th>
|
||||
<th>标记</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -80,7 +80,11 @@ export default function AdminDashboardPage() {
|
||||
</button>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td>{p.pinned ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||
<td className="space-x-1">
|
||||
{p.featured ? <Badge variant="orange">精华</Badge> : null}
|
||||
{p.pinned ? <Badge variant="green">置顶</Badge> : null}
|
||||
{!p.featured && !p.pinned ? '—' : null}
|
||||
</td>
|
||||
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
321
frontend/src/pages/admin/AdminMediaPage.tsx
Normal file
321
frontend/src/pages/admin/AdminMediaPage.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Trash2, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { MediaItem } from '../../api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type CategoryTab = 'all' | 'avatars' | 'posts' | 'site';
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
avatars: '头像',
|
||||
posts: '帖子图',
|
||||
site: '站点资源',
|
||||
};
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return '—';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export default function AdminMediaPage() {
|
||||
const { ready } = useAdminGuard();
|
||||
const [category, setCategory] = useState<CategoryTab>('all');
|
||||
const [q, setQ] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [files, setFiles] = useState<MediaItem[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [storageType, setStorageType] = useState<'local' | 's3'>('local');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingUrls, setPendingUrls] = useState<string[]>([]);
|
||||
|
||||
const load = useCallback(async (p = 1, cat: CategoryTab = category, query = keyword) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.adminMedia({
|
||||
category: cat,
|
||||
page: p,
|
||||
size: 24,
|
||||
q: query || undefined,
|
||||
});
|
||||
setFiles(r.files ?? []);
|
||||
setCounts(r.category_counts ?? {});
|
||||
setStorageType(r.storage_type || 'local');
|
||||
setPage(r.page || p);
|
||||
setTotalPages(r.total_pages || 1);
|
||||
setTotal(r.total || 0);
|
||||
setSelected(new Set());
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [category, keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) load(1, category, keyword);
|
||||
}, [ready, category, keyword, load]);
|
||||
|
||||
const allSelected = useMemo(
|
||||
() => files.length > 0 && files.every(f => selected.has(f.url)),
|
||||
[files, selected],
|
||||
);
|
||||
|
||||
const toggleOne = (url: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(url)) next.delete(url);
|
||||
else next.add(url);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
setSelected(new Set());
|
||||
return;
|
||||
}
|
||||
setSelected(new Set(files.map(f => f.url)));
|
||||
};
|
||||
|
||||
const askDelete = (urls: string[]) => {
|
||||
if (urls.length === 0) {
|
||||
notify.warning('请先选择文件');
|
||||
return;
|
||||
}
|
||||
setPendingUrls(urls);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (pendingUrls.length === 0) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const r = await api.adminDeleteMedia(pendingUrls);
|
||||
notify.success(r.message);
|
||||
setConfirmOpen(false);
|
||||
setPendingUrls([]);
|
||||
await load(page, category, keyword);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyURL = async (url: string) => {
|
||||
try {
|
||||
const abs = url.startsWith('http') ? url : `${window.location.origin}${url}`;
|
||||
await navigator.clipboard.writeText(abs);
|
||||
notify.success('已复制链接');
|
||||
} catch {
|
||||
notify.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
const tabs: { key: CategoryTab; label: string }[] = [
|
||||
{ key: 'all', label: `全部 (${Object.values(counts).reduce((a, b) => a + (b || 0), 0)})` },
|
||||
{ key: 'avatars', label: `头像 (${counts.avatars || 0})` },
|
||||
{ key: 'posts', label: `帖子图 (${counts.posts || 0})` },
|
||||
{ key: 'site', label: `站点 (${counts.site || 0})` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>媒体库</h1>
|
||||
<p>
|
||||
浏览并管理上传资源(头像 / 帖子图 / 站点品牌图)。当前存储:
|
||||
{storageType === 's3' ? 'S3 兼容' : '本地磁盘'}
|
||||
。列表来自数据库索引;删除会同时清理伴生原图/WebP。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className={cn('admin-tab', category === t.key && 'active')}
|
||||
onClick={() => setCategory(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="admin-media-toolbar">
|
||||
<form
|
||||
className="admin-media-search"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
setKeyword(q.trim());
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
placeholder="按文件名搜索…"
|
||||
aria-label="搜索媒体"
|
||||
/>
|
||||
<Button type="submit" variant="outline">搜索</Button>
|
||||
{keyword && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setQ('');
|
||||
setKeyword('');
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
<div className="admin-media-toolbar-actions">
|
||||
<Button size="sm" variant="outline" onClick={toggleAll} disabled={files.length === 0}>
|
||||
{allSelected ? '取消全选' : '全选本页'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={selected.size === 0 || deleting}
|
||||
onClick={() => askDelete([...selected])}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
删除所选 ({selected.size})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="admin-empty">暂无媒体文件</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-media-grid">
|
||||
{files.map(f => (
|
||||
<article
|
||||
key={f.url}
|
||||
className={cn('admin-media-card', selected.has(f.url) && 'is-selected')}
|
||||
>
|
||||
<label className="admin-media-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(f.url)}
|
||||
onChange={() => toggleOne(f.url)}
|
||||
aria-label={`选择 ${f.name}`}
|
||||
/>
|
||||
</label>
|
||||
<a
|
||||
className="admin-media-thumb"
|
||||
href={f.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={f.name}
|
||||
>
|
||||
<img src={f.url} alt="" loading="lazy" decoding="async" />
|
||||
</a>
|
||||
<div className="admin-media-meta">
|
||||
<div className="admin-media-name" title={f.name}>{f.name}</div>
|
||||
<div className="admin-media-sub">
|
||||
<Badge variant="secondary">{CATEGORY_LABEL[f.category] || f.category}</Badge>
|
||||
<span>{formatBytes(f.size)}</span>
|
||||
</div>
|
||||
<div className="admin-media-time">
|
||||
{f.modified_at ? new Date(f.modified_at).toLocaleString('zh-CN') : '—'}
|
||||
</div>
|
||||
<div className="admin-media-actions">
|
||||
<Button size="sm" variant="outline" onClick={() => copyURL(f.url)}>
|
||||
<Copy size={13} aria-hidden />
|
||||
复制
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={f.url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink size={13} aria-hidden />
|
||||
打开
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => askDelete([f.url])}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="admin-pagination">
|
||||
<span>共 {total} 个文件</span>
|
||||
{totalPages > 1 && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除媒体?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将删除 {pendingUrls.length} 个文件;若存在同名原图/WebP 伴生文件也会一并清理。此操作不可恢复,且不会自动改写帖子正文中的引用。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleting}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
void doDelete();
|
||||
}}
|
||||
>
|
||||
{deleting ? '删除中…' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Lock, LockOpen } from 'lucide-react';
|
||||
import { Search, Lock, LockOpen, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -11,12 +11,16 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { PostItem } from '../../api/types';
|
||||
import { clearAllFeedCache } from '../../utils/feedCache';
|
||||
import { isTimeDiffSignificant } from '../../utils/content';
|
||||
|
||||
type Tab = 'pending' | 'active' | 'trash';
|
||||
type TrashPost = PostItem & { deleted_at: string };
|
||||
|
||||
function formatAdminTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
@@ -29,28 +33,71 @@ function formatAdminTime(iso: string) {
|
||||
export default function AdminPostsPage() {
|
||||
const nav = useNavigate();
|
||||
const { ready } = useAdminGuard();
|
||||
const [tab, setTab] = useState<Tab>('pending');
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [trash, setTrash] = useState<TrashPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const load = (p = page, kw = search) => {
|
||||
const loadActive = (p = page, kw = search, status = tab === 'pending' ? 'pending' : 'all') => {
|
||||
setLoading(true);
|
||||
api.adminPosts({ page: p, keyword: kw })
|
||||
api.adminPosts({ page: p, keyword: kw, status })
|
||||
.then(d => {
|
||||
setPosts(d.posts ?? []);
|
||||
setPage(d.page);
|
||||
setTotalPages(d.total_pages);
|
||||
setPendingCount(d.pending_count ?? 0);
|
||||
})
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const loadTrash = (p = page, kw = search) => {
|
||||
setLoading(true);
|
||||
api.adminTrashPosts({ page: p, keyword: kw })
|
||||
.then(d => {
|
||||
setTrash(d.posts ?? []);
|
||||
setPage(d.page);
|
||||
setTotalPages(d.total_pages);
|
||||
})
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const load = (p = 1, kw = search) => {
|
||||
if (tab === 'trash') loadTrash(p, kw);
|
||||
else loadActive(p, kw, tab === 'pending' ? 'pending' : 'all');
|
||||
};
|
||||
|
||||
const approvePost = async (post: PostItem) => {
|
||||
try {
|
||||
const r = await api.adminApprovePost(post.id);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) load(1, search);
|
||||
}, [ready, search]);
|
||||
if (!ready) return;
|
||||
setPage(1);
|
||||
load(1, search);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅随 tab/search/ready 刷新
|
||||
}, [ready, search, tab]);
|
||||
|
||||
const switchTab = (next: Tab) => {
|
||||
if (next === tab) return;
|
||||
setTab(next);
|
||||
setKeyword('');
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const togglePin = async (post: PostItem) => {
|
||||
try {
|
||||
@@ -58,7 +105,37 @@ export default function AdminPostsPage() {
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
load();
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFeature = async (post: PostItem) => {
|
||||
try {
|
||||
const r = await api.adminFeaturePost(post.id, !post.featured);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const rejectPost = async (post: PostItem) => {
|
||||
const reason = window.prompt(`拒绝《${post.title}》并私信通知作者,请填写原因:`);
|
||||
if (reason == null) return;
|
||||
if (!reason.trim()) {
|
||||
notify.warning('请填写拒绝原因');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await api.adminRejectPost(post.id, reason.trim());
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
@@ -68,7 +145,7 @@ export default function AdminPostsPage() {
|
||||
try {
|
||||
const r = await api.adminLockPost(post.id, !post.edit_locked);
|
||||
notify.success(r.message);
|
||||
load();
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
@@ -77,20 +154,81 @@ export default function AdminPostsPage() {
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await api.adminDeletePost(id);
|
||||
notify.success('帖子已删除');
|
||||
load();
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success('帖子已移入回收站');
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const restore = async (id: number) => {
|
||||
try {
|
||||
await api.adminRestorePost(id);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success('帖子已恢复');
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '恢复失败');
|
||||
}
|
||||
};
|
||||
|
||||
const purge = async (id: number) => {
|
||||
try {
|
||||
await api.adminPurgePost(id);
|
||||
notify.success('帖子已永久删除');
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '彻底删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>帖子管理</h1>
|
||||
<p>置顶、锁定编辑、删除帖子;支持按标题、标签或正文关键词搜索</p>
|
||||
<p>
|
||||
{tab === 'trash'
|
||||
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
|
||||
: tab === 'pending'
|
||||
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
|
||||
: '精华、置顶、锁定编辑、删除(移入回收站);支持按标题、标签或正文搜索'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs" role="tablist" aria-label="帖子视图">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'pending'}
|
||||
className={cn('admin-tab', tab === 'pending' && 'active')}
|
||||
onClick={() => switchTab('pending')}
|
||||
>
|
||||
待审核{pendingCount > 0 ? ` (${pendingCount})` : ''}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'active'}
|
||||
className={cn('admin-tab', tab === 'active' && 'active')}
|
||||
onClick={() => switchTab('active')}
|
||||
>
|
||||
全部帖子
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'trash'}
|
||||
className={cn('admin-tab', tab === 'trash' && 'active')}
|
||||
onClick={() => switchTab('trash')}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
回收站
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
@@ -113,6 +251,59 @@ export default function AdminPostsPage() {
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : tab === 'trash' ? (
|
||||
<>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>板块</th>
|
||||
<th>作者</th>
|
||||
<th>评论</th>
|
||||
<th>删除时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trash.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.id}</td>
|
||||
<td className="max-w-[220px] truncate">{p.title}</td>
|
||||
<td>{p.board?.name ?? '—'}</td>
|
||||
<td>{p.user?.nickname ?? '—'}</td>
|
||||
<td>{p.comment_count ?? 0}</td>
|
||||
<td className="text-sm whitespace-nowrap">{formatAdminTime(p.deleted_at)}</td>
|
||||
<td>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" onClick={() => restore(p.id)}>
|
||||
<RotateCcw size={14} /> 恢复
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">永久删除</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>永久删除该帖子?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将彻底清除帖子、评论、点赞、收藏与修订历史,此操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => purge(p.id)}>永久删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{trash.length === 0 && <div className="admin-empty">回收站为空</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<table className="admin-table">
|
||||
@@ -124,6 +315,7 @@ export default function AdminPostsPage() {
|
||||
<th>作者</th>
|
||||
<th>标签</th>
|
||||
<th>评论</th>
|
||||
<th>精华</th>
|
||||
<th>置顶</th>
|
||||
<th>锁定</th>
|
||||
<th>点赞</th>
|
||||
@@ -154,7 +346,8 @@ export default function AdminPostsPage() {
|
||||
</td>
|
||||
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
|
||||
<td>{p.comment_count ?? 0}</td>
|
||||
<td>{p.pinned ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||
<td>{p.featured ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||
<td>{p.pinned ? <Badge variant="green">是</Badge> : '—'}</td>
|
||||
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||
<td>{p.like_count}</td>
|
||||
<td>{p.view_count}</td>
|
||||
@@ -167,7 +360,18 @@ export default function AdminPostsPage() {
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(p.status === 'pending' || p.status === 'rejected') && (
|
||||
<Button size="sm" onClick={() => approvePost(p)}>通过</Button>
|
||||
)}
|
||||
{p.status !== 'rejected' && (
|
||||
<Button size="sm" variant="outline" onClick={() => rejectPost(p)}>
|
||||
拒绝并通知
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => toggleFeature(p)}>
|
||||
{p.featured ? '取消精华' : '精华'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
|
||||
{p.pinned ? '取消置顶' : '置顶'}
|
||||
</Button>
|
||||
@@ -180,12 +384,14 @@ export default function AdminPostsPage() {
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该帖子?</AlertDialogTitle>
|
||||
<AlertDialogDescription>相关评论也将一并删除,不可恢复。</AlertDialogDescription>
|
||||
<AlertDialogTitle>移入回收站?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
帖子与评论将移入回收站,可随时恢复;永久删除请到回收站操作。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => remove(p.id)}>删除</AlertDialogAction>
|
||||
<AlertDialogAction onClick={() => remove(p.id)}>移入回收站</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
@@ -197,15 +403,15 @@ export default function AdminPostsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
{posts.length === 0 && <div className="admin-empty">没有找到帖子</div>}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{totalPages > 1 && !loading && (
|
||||
<div className="admin-pagination">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
232
frontend/src/pages/admin/AdminReportsPage.tsx
Normal file
232
frontend/src/pages/admin/AdminReportsPage.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
import type { PostReport } from '../../api/types';
|
||||
import { formatTime } from '../../utils/content';
|
||||
import { reportReasonLabel, reportStatusLabel } from '../../utils/report';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type StatusTab = 'pending' | 'resolved' | 'dismissed' | 'all';
|
||||
|
||||
export default function AdminReportsPage() {
|
||||
const nav = useNavigate();
|
||||
const [status, setStatus] = useState<StatusTab>('pending');
|
||||
const [list, setList] = useState<PostReport[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [active, setActive] = useState<PostReport | null>(null);
|
||||
const [action, setAction] = useState<'dismiss' | 'resolve' | 'reject_post' | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async (p = 1, st: StatusTab = status) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.adminReports({ page: p, status: st });
|
||||
setList(r.reports || []);
|
||||
setTotal(r.total || 0);
|
||||
setPendingCount(r.pending_count || 0);
|
||||
setPage(r.page || p);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
load(1, status);
|
||||
}, [status, load]);
|
||||
|
||||
const openHandle = (rep: PostReport, act: 'dismiss' | 'resolve' | 'reject_post') => {
|
||||
setActive(rep);
|
||||
setAction(act);
|
||||
setNote('');
|
||||
setRejectReason('');
|
||||
};
|
||||
|
||||
const submitHandle = async () => {
|
||||
if (!active || !action) return;
|
||||
if (action === 'reject_post' && !rejectReason.trim()) {
|
||||
notify.warning('请填写拒绝原因(将私信通知作者)');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const r = await api.adminHandleReport(active.id, {
|
||||
action,
|
||||
handle_note: note.trim() || undefined,
|
||||
reject_reason: action === 'reject_post' ? rejectReason.trim() : undefined,
|
||||
});
|
||||
notify.success(r.message);
|
||||
setActive(null);
|
||||
setAction(null);
|
||||
load(page, status);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '处理失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { key: StatusTab; label: string }[] = [
|
||||
{ key: 'pending', label: `待处理${pendingCount ? ` (${pendingCount})` : ''}` },
|
||||
{ key: 'resolved', label: '已处理' },
|
||||
{ key: 'dismissed', label: '已忽略' },
|
||||
{ key: 'all', label: '全部' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<h1 className="admin-page-title">举报管理</h1>
|
||||
<p className="admin-page-desc">处理用户举报;拒绝帖子时将通过站内私信通知作者。</p>
|
||||
|
||||
<div className="admin-tabs">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className={cn('admin-tab', status === t.key && 'active')}
|
||||
onClick={() => setStatus(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : (
|
||||
<>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>帖子</th>
|
||||
<th>原因</th>
|
||||
<th>举报人</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.id}</td>
|
||||
<td className="max-w-[220px]">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-text-link truncate block max-w-full text-left"
|
||||
onClick={() => nav(`/post/${r.post_id}`)}
|
||||
>
|
||||
{r.post?.title || `帖子 #${r.post_id}`}
|
||||
</button>
|
||||
{r.detail && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{r.detail}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{reportReasonLabel(r.reason)}</td>
|
||||
<td>{r.reporter?.nickname || `#${r.reporter_id}`}</td>
|
||||
<td>
|
||||
<Badge variant={r.status === 'pending' ? 'orange' : r.status === 'resolved' ? 'green' : 'secondary'}>
|
||||
{reportStatusLabel(r.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-sm whitespace-nowrap">{formatTime(r.created_at)}</td>
|
||||
<td>
|
||||
{r.status === 'pending' ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={() => openHandle(r, 'dismiss')}>忽略</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openHandle(r, 'resolve')}>标记已处理</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => openHandle(r, 'reject_post')}>拒绝并通知</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{r.handle_note || '—'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length === 0 && <div className="admin-empty">暂无举报</div>}
|
||||
{total > 20 && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span className="text-sm text-muted-foreground self-center">第 {page} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={list.length < 20} onClick={() => load(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={!!action && !!active} onOpenChange={(o) => { if (!o) { setAction(null); setActive(null); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{action === 'dismiss' && '忽略举报'}
|
||||
{action === 'resolve' && '标记已处理'}
|
||||
{action === 'reject_post' && '拒绝帖子并通知作者'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{action === 'reject_post'
|
||||
? '帖子将移入回收站,拒绝原因会通过站内私信发给作者;举报人也会收到处理结果通知。'
|
||||
: '举报人将收到处理结果的站内私信通知。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
{action === 'reject_post' && (
|
||||
<label className="pm-field">
|
||||
<span>拒绝原因(发给作者)</span>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
placeholder="请说明未通过的原因…"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="pm-field">
|
||||
<span>处理备注(可选,发给举报人)</span>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
placeholder="补充说明…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setAction(null); setActive(null); }}>取消</Button>
|
||||
<Button
|
||||
variant={action === 'reject_post' ? 'destructive' : 'default'}
|
||||
loading={submitting}
|
||||
onClick={submitHandle}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Database, Mail, Shield, Server, SlidersHorizontal, KeyRound, FolderGit2, Palette } from 'lucide-react';
|
||||
import { Database, Mail, Shield, Server, SlidersHorizontal, KeyRound, FolderGit2, Palette, HardDrive } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -10,9 +10,9 @@ import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
||||
import { DEFAULT_BRANDING, seedSiteBrandingCache } from '../../hooks/useSiteBranding';
|
||||
import { clearAllFeedCache } from '../../utils/feedCache';
|
||||
import type { AdminSettings, ForumLimits, MailConfig, OIDCConfig, OAuthClient, GiteaSyncConfig, SiteBranding } from '../../api/types';
|
||||
import type { AdminSettings, ForumLimits, MailConfig, OIDCConfig, OAuthClient, GiteaSyncConfig, StorageConfig, SiteBranding, FriendLink } from '../../api/types';
|
||||
|
||||
type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | 'filter' | 'system';
|
||||
type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | 'storage' | 'filter' | 'system';
|
||||
|
||||
type NumberLimitKey = {
|
||||
[K in keyof ForumLimits]: ForumLimits[K] extends number ? K : never;
|
||||
@@ -37,9 +37,10 @@ const SETTING_SECTIONS: SettingSection[] = [
|
||||
{
|
||||
id: 'rule',
|
||||
title: '编辑规则',
|
||||
summary: '控制普通用户修改自己帖子的时限',
|
||||
summary: '控制普通用户修改自己帖子 / 评论的时限(0 = 不限)',
|
||||
rows: [
|
||||
{ key: 'post_edit_window_hours', label: '可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
|
||||
{ key: 'post_edit_window_hours', label: '帖子可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
|
||||
{ key: 'comment_edit_window_hours', label: '评论可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -103,11 +104,12 @@ const NAV_TOGGLES: { key: BoolLimitKey; label: string; hint: string }[] = [
|
||||
];
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: typeof SlidersHorizontal }[] = [
|
||||
{ id: 'branding', label: '站点品牌', icon: Palette },
|
||||
{ id: 'branding', label: '站点与呈现', icon: Palette },
|
||||
{ id: 'limits', label: '论坛限制', icon: SlidersHorizontal },
|
||||
{ id: 'mail', label: '邮件服务', icon: Mail },
|
||||
{ id: 'oidc', label: 'OIDC / SSO', icon: KeyRound },
|
||||
{ id: 'gitea', label: 'Gitea 同步', icon: FolderGit2 },
|
||||
{ id: 'storage', label: '对象存储', icon: HardDrive },
|
||||
{ id: 'filter', label: '敏感词', icon: Shield },
|
||||
{ id: 'system', label: '系统维护', icon: Server },
|
||||
];
|
||||
@@ -154,6 +156,20 @@ const EMPTY_GITEA: GiteaSyncConfig = {
|
||||
repo_count: 0,
|
||||
};
|
||||
|
||||
const EMPTY_STORAGE: StorageConfig = {
|
||||
type: 'local',
|
||||
endpoint: '',
|
||||
region: 'us-east-1',
|
||||
bucket: '',
|
||||
access_key: '',
|
||||
public_base_url: '',
|
||||
prefix: '',
|
||||
force_path_style: true,
|
||||
has_secret_key: false,
|
||||
ready: true,
|
||||
image_delivery: 'webp',
|
||||
};
|
||||
|
||||
function giteaStatusLabel(gitea: GiteaSyncConfig): string {
|
||||
if (gitea.ready) return `已就绪 · ${gitea.repo_count} 个仓库`;
|
||||
if (!gitea.enabled) return '未启用';
|
||||
@@ -164,6 +180,19 @@ function giteaStatusLabel(gitea: GiteaSyncConfig): string {
|
||||
return `未就绪(需${reasons.join('、')})`;
|
||||
}
|
||||
|
||||
function storageStatusLabel(storage: StorageConfig): string {
|
||||
if (storage.type === 'local') return '本地磁盘';
|
||||
if (storage.ready) return 'S3 已就绪';
|
||||
const reasons: string[] = [];
|
||||
if (!storage.endpoint.trim()) reasons.push('Endpoint');
|
||||
if (!storage.bucket.trim()) reasons.push('Bucket');
|
||||
if (!storage.access_key.trim()) reasons.push('Access Key');
|
||||
if (!storage.has_secret_key) reasons.push('Secret Key');
|
||||
if (!storage.public_base_url.trim()) reasons.push('公开访问地址');
|
||||
if (reasons.length === 0) reasons.push('保存后生效');
|
||||
return `未就绪(需${reasons.join('、')})`;
|
||||
}
|
||||
|
||||
function SettingTable({
|
||||
sections,
|
||||
limits,
|
||||
@@ -216,6 +245,7 @@ export default function AdminSettingsPage() {
|
||||
const [mail, setMail] = useState<MailConfig>(EMPTY_MAIL);
|
||||
const [oidc, setOidc] = useState<OIDCConfig>(EMPTY_OIDC);
|
||||
const [gitea, setGitea] = useState<GiteaSyncConfig>(EMPTY_GITEA);
|
||||
const [storage, setStorage] = useState<StorageConfig>(EMPTY_STORAGE);
|
||||
const [oauthClients, setOauthClients] = useState<OAuthClient[]>([]);
|
||||
const [clientForm, setClientForm] = useState({
|
||||
client_id: 'gitea',
|
||||
@@ -231,11 +261,12 @@ export default function AdminSettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [backing, setBacking] = useState(false);
|
||||
const [savingBranding, setSavingBranding] = useState(false);
|
||||
const [uploadingBrand, setUploadingBrand] = useState<'logo' | 'favicon' | null>(null);
|
||||
const [uploadingBrand, setUploadingBrand] = useState<'logo' | 'favicon' | 'og_image' | null>(null);
|
||||
const [savingForum, setSavingForum] = useState(false);
|
||||
const [savingMail, setSavingMail] = useState(false);
|
||||
const [savingOidc, setSavingOidc] = useState(false);
|
||||
const [savingGitea, setSavingGitea] = useState(false);
|
||||
const [savingStorage, setSavingStorage] = useState(false);
|
||||
const [syncingGitea, setSyncingGitea] = useState(false);
|
||||
const [savingClient, setSavingClient] = useState(false);
|
||||
const [testingMail, setTestingMail] = useState(false);
|
||||
@@ -249,12 +280,15 @@ export default function AdminSettingsPage() {
|
||||
setLimits({
|
||||
open_posts_in_new_tab: true,
|
||||
open_content_links_in_new_tab: true,
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
...s.limits,
|
||||
});
|
||||
setBranding({ ...DEFAULT_BRANDING, ...(s.branding ?? {}) });
|
||||
setMail({ ...EMPTY_MAIL, ...s.mail, password: '' });
|
||||
setOidc({ ...EMPTY_OIDC, ...(s.oidc ?? {}) });
|
||||
setGitea({ ...EMPTY_GITEA, ...(s.gitea ?? {}), token: '' });
|
||||
setStorage({ ...EMPTY_STORAGE, ...(s.storage ?? {}), secret_key: '' });
|
||||
setOauthClients(s.oauth_clients ?? []);
|
||||
setFilterWords(s.filter_words);
|
||||
if (s.mail?.from) setTestTo(s.mail.from);
|
||||
@@ -279,11 +313,23 @@ export default function AdminSettingsPage() {
|
||||
};
|
||||
|
||||
const handleSaveBranding = async () => {
|
||||
if (!limits) return;
|
||||
const links = (branding.friend_links ?? [])
|
||||
.map(l => ({ name: l.name.trim(), url: l.url.trim() }))
|
||||
.filter(l => l.name || l.url);
|
||||
if (links.some(l => !l.name || !l.url)) {
|
||||
notify.warning('友情链接需同时填写名称与完整 URL');
|
||||
return;
|
||||
}
|
||||
setSavingBranding(true);
|
||||
try {
|
||||
const r = await api.adminUpdateBranding(branding);
|
||||
notify.success(r.message);
|
||||
const r = await api.adminUpdateBranding({ ...branding, friend_links: links });
|
||||
applyBranding(r.branding);
|
||||
// 伪静态与品牌同属站点呈现,一并保存
|
||||
const forum = await api.adminUpdateForumSettings(limits);
|
||||
setLimits(forum.limits);
|
||||
invalidateForumLimitsCache();
|
||||
notify.success('站点设置已保存');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
@@ -291,7 +337,7 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadBrandAsset = async (kind: 'logo' | 'favicon', file: File | undefined) => {
|
||||
const handleUploadBrandAsset = async (kind: 'logo' | 'favicon' | 'og_image', file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setUploadingBrand(kind);
|
||||
try {
|
||||
@@ -305,7 +351,7 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearBrandAsset = async (kind: 'logo' | 'favicon') => {
|
||||
const handleClearBrandAsset = async (kind: 'logo' | 'favicon' | 'og_image') => {
|
||||
setUploadingBrand(kind);
|
||||
try {
|
||||
const r = await api.adminClearBrandingAsset(kind);
|
||||
@@ -385,6 +431,24 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveStorageSettings = async () => {
|
||||
setSavingStorage(true);
|
||||
try {
|
||||
const payload: StorageConfig = {
|
||||
...storage,
|
||||
secret_key: storage.secret_key?.trim() ? storage.secret_key : undefined,
|
||||
};
|
||||
const r = await api.adminUpdateStorageSettings(payload);
|
||||
notify.success(r.message);
|
||||
setStorage({ ...EMPTY_STORAGE, ...r.storage, secret_key: '' });
|
||||
setSettings(s => s ? { ...s, storage: r.storage } : s);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSavingStorage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncGitea = async () => {
|
||||
setSyncingGitea(true);
|
||||
try {
|
||||
@@ -558,14 +622,14 @@ export default function AdminSettingsPage() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{activeTab === 'branding' && (
|
||||
{activeTab === 'branding' && limits && (
|
||||
<div className="admin-settings-panel admin-mail-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">
|
||||
<span>站点品牌</span>
|
||||
<span>站点与呈现</span>
|
||||
<span className="admin-settings-card-badge">{branding.name}</span>
|
||||
</div>
|
||||
<div className="admin-card-body admin-mail-body">
|
||||
<div className="admin-card-body admin-mail-body admin-brand-sections">
|
||||
<div className="admin-brand-preview">
|
||||
{branding.logo ? (
|
||||
<img src={branding.logo} alt="" className="admin-brand-preview-logo" />
|
||||
@@ -574,119 +638,339 @@ export default function AdminSettingsPage() {
|
||||
)}
|
||||
<div>
|
||||
<strong>{branding.name}</strong>
|
||||
{branding.name_en && <div className="admin-mail-field-hint">{branding.name_en}</div>}
|
||||
{branding.slogan && <p className="admin-mail-field-hint" style={{ marginTop: 4 }}>{branding.slogan}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-name">论坛名称</label>
|
||||
<Input
|
||||
id="brand-name"
|
||||
value={branding.name}
|
||||
onChange={e => setBranding(b => ({ ...b, name: e.target.value }))}
|
||||
placeholder="姜十三论坛"
|
||||
maxLength={64}
|
||||
/>
|
||||
<section className="admin-settings-section" id="settings-brand-identity">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>品牌标识</h3>
|
||||
<p>名称、标语、简介与字标;简介用于首页展示与搜索引擎 description</p>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-name-en">英文名称</label>
|
||||
<Input
|
||||
id="brand-name-en"
|
||||
value={branding.name_en}
|
||||
onChange={e => setBranding(b => ({ ...b, name_en: e.target.value }))}
|
||||
placeholder="Jiang13 Forum"
|
||||
maxLength={64}
|
||||
/>
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-name">论坛名称</label>
|
||||
<Input
|
||||
id="brand-name"
|
||||
value={branding.name}
|
||||
onChange={e => setBranding(b => ({ ...b, name: e.target.value }))}
|
||||
placeholder="姜十三论坛"
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-mark">字标(无 Logo 时显示)</label>
|
||||
<Input
|
||||
id="brand-mark"
|
||||
value={branding.logo_mark}
|
||||
onChange={e => setBranding(b => ({ ...b, logo_mark: e.target.value.slice(0, 2) }))}
|
||||
placeholder="姜"
|
||||
maxLength={2}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">建议 1 个汉字或字母</span>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="brand-slogan">标语</label>
|
||||
<Input
|
||||
id="brand-slogan"
|
||||
value={branding.slogan}
|
||||
onChange={e => setBranding(b => ({ ...b, slogan: e.target.value }))}
|
||||
placeholder="拾三一隅,自在交流"
|
||||
maxLength={200}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">短句,出现在浏览器标题与页脚</span>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="brand-description">站点简介</label>
|
||||
<Textarea
|
||||
id="brand-description"
|
||||
value={branding.description ?? ''}
|
||||
onChange={e => setBranding(b => ({ ...b, description: e.target.value }))}
|
||||
placeholder="一两段话介绍本站定位与内容,便于搜索引擎与访客理解"
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">
|
||||
用于右侧栏介绍与 SEO description;未填写时回退到标语(建议 80–160 字)
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="brand-keywords">SEO 关键词</label>
|
||||
<Input
|
||||
id="brand-keywords"
|
||||
value={branding.keywords ?? ''}
|
||||
onChange={e => setBranding(b => ({ ...b, keywords: e.target.value }))}
|
||||
placeholder="论坛,社区,技术交流"
|
||||
maxLength={200}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">
|
||||
用于 meta keywords;用逗号分隔,最多 20 个(支持中文逗号)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="brand-slogan">标语 / Slogan</label>
|
||||
<Input
|
||||
id="brand-slogan"
|
||||
value={branding.slogan}
|
||||
onChange={e => setBranding(b => ({ ...b, slogan: e.target.value }))}
|
||||
placeholder="拾三一隅,自在交流"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-mark">字标(无 Logo 时显示)</label>
|
||||
<Input
|
||||
id="brand-mark"
|
||||
value={branding.logo_mark}
|
||||
onChange={e => setBranding(b => ({ ...b, logo_mark: e.target.value.slice(0, 2) }))}
|
||||
placeholder="姜"
|
||||
maxLength={2}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">建议 1 个汉字或字母</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="admin-mail-grid" style={{ marginTop: 8 }}>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-logo-file">站点 Logo</label>
|
||||
<div className="admin-brand-upload-row">
|
||||
<Input
|
||||
id="brand-logo-file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
void handleUploadBrandAsset('logo', f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{branding.logo && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={uploadingBrand === 'logo'}
|
||||
onClick={() => void handleClearBrandAsset('logo')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
<section className="admin-settings-section" id="settings-brand-assets">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>视觉资源</h3>
|
||||
<p>站点 Logo、浏览器标签图标与默认社交分享图</p>
|
||||
</div>
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-logo-file">站点 Logo</label>
|
||||
<div className="admin-brand-upload-row">
|
||||
<Input
|
||||
id="brand-logo-file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
void handleUploadBrandAsset('logo', f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{branding.logo && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={uploadingBrand === 'logo'}
|
||||
onClick={() => void handleClearBrandAsset('logo')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="admin-mail-field-hint">
|
||||
{uploadingBrand === 'logo' ? '上传中…' : '保留原图并生成 WebP,最大 2MB'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="admin-mail-field-hint">
|
||||
{uploadingBrand === 'logo' ? '上传中…' : 'jpg/png/gif/webp,最大 2MB'}
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-favicon-file">Favicon</label>
|
||||
<div className="admin-brand-upload-row">
|
||||
<Input
|
||||
id="brand-favicon-file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
void handleUploadBrandAsset('favicon', f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{branding.favicon && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={uploadingBrand === 'favicon'}
|
||||
onClick={() => void handleClearBrandAsset('favicon')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="admin-mail-field-hint">
|
||||
{branding.favicon ? `当前:${branding.favicon}` : '浏览器标签图标'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="brand-og-image-file">默认社交分享图(OG Image)</label>
|
||||
<div className="admin-brand-upload-row">
|
||||
<Input
|
||||
id="brand-og-image-file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
void handleUploadBrandAsset('og_image', f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{branding.og_image && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={uploadingBrand === 'og_image'}
|
||||
onClick={() => void handleClearBrandAsset('og_image')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="admin-mail-field-hint">
|
||||
{uploadingBrand === 'og_image'
|
||||
? '上传中…'
|
||||
: branding.og_image
|
||||
? `当前:${branding.og_image};建议 1200×630,用于微信/社交预览;未设置时回退 Logo`
|
||||
: '建议 1200×630,用于微信/社交预览;未设置时回退 Logo'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-settings-section" id="settings-brand-footer">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>页脚信息</h3>
|
||||
<p>备案号与友情链接,显示在站点底部</p>
|
||||
</div>
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-icp">ICP 备案号</label>
|
||||
<Input
|
||||
id="brand-icp"
|
||||
value={branding.icp_beian ?? ''}
|
||||
onChange={e => setBranding(b => ({ ...b, icp_beian: e.target.value }))}
|
||||
placeholder="京ICP备xxxxxxxx号"
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-icp-url">ICP 跳转链接</label>
|
||||
<Input
|
||||
id="brand-icp-url"
|
||||
value={branding.icp_beian_url ?? ''}
|
||||
onChange={e => setBranding(b => ({ ...b, icp_beian_url: e.target.value }))}
|
||||
placeholder="https://beian.miit.gov.cn/"
|
||||
maxLength={512}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">留空则用工信部默认页</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-friend-links" style={{ marginTop: 12 }}>
|
||||
<div className="admin-friend-links-list">
|
||||
{(branding.friend_links ?? []).map((link, idx) => (
|
||||
<div key={idx} className="admin-friend-links-row">
|
||||
<Input
|
||||
value={link.name}
|
||||
placeholder="友链名称"
|
||||
maxLength={32}
|
||||
onChange={e => {
|
||||
const name = e.target.value;
|
||||
setBranding(b => {
|
||||
const next = [...(b.friend_links ?? [])];
|
||||
next[idx] = { ...next[idx], name };
|
||||
return { ...b, friend_links: next };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={link.url}
|
||||
placeholder="https://example.com"
|
||||
maxLength={512}
|
||||
onChange={e => {
|
||||
const url = e.target.value;
|
||||
setBranding(b => {
|
||||
const next = [...(b.friend_links ?? [])];
|
||||
next[idx] = { ...next[idx], url };
|
||||
return { ...b, friend_links: next };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBranding(b => ({
|
||||
...b,
|
||||
friend_links: (b.friend_links ?? []).filter((_, i) => i !== idx),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={(branding.friend_links?.length ?? 0) >= 20}
|
||||
onClick={() => {
|
||||
setBranding(b => ({
|
||||
...b,
|
||||
friend_links: [...(b.friend_links ?? []), { name: '', url: '' } as FriendLink],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
添加友链
|
||||
</Button>
|
||||
<span className="admin-mail-field-hint" style={{ display: 'block', marginTop: 8 }}>
|
||||
最多 20 条,需填写完整 http(s) 地址
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-favicon-file">Favicon</label>
|
||||
<div className="admin-brand-upload-row">
|
||||
<Input
|
||||
id="brand-favicon-file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
void handleUploadBrandAsset('favicon', f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{branding.favicon && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={uploadingBrand === 'favicon'}
|
||||
onClick={() => void handleClearBrandAsset('favicon')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="admin-mail-field-hint">
|
||||
{branding.favicon ? `当前:${branding.favicon}` : '浏览器标签图标'}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className="admin-settings-section" id="settings-permalink">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>伪静态 URL</h3>
|
||||
<p>为帖子 / 用户生成带后缀的规范链接,非规范路径会 301 跳转</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-settings-table" role="group" aria-label="伪静态">
|
||||
<div className="admin-settings-row">
|
||||
<span className="admin-settings-row-label" id="limit-label-permalink_enabled">
|
||||
启用伪静态
|
||||
</span>
|
||||
<div className="admin-settings-row-input">
|
||||
<button
|
||||
type="button"
|
||||
id="limit-permalink_enabled"
|
||||
role="switch"
|
||||
aria-checked={!!limits.permalink_enabled}
|
||||
aria-labelledby="limit-label-permalink_enabled"
|
||||
className={`admin-settings-switch${limits.permalink_enabled ? ' is-on' : ''}`}
|
||||
onClick={() => setLimits(prev => prev ? { ...prev, permalink_enabled: !prev.permalink_enabled } : prev)}
|
||||
>
|
||||
<span className="admin-settings-switch-ui" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<span className="admin-settings-row-hint">关闭:/post/123 · 开启:/post/123.后缀</span>
|
||||
</div>
|
||||
<div className="admin-settings-row">
|
||||
<span className="admin-settings-row-label" id="limit-label-permalink_ext">
|
||||
URL 后缀
|
||||
</span>
|
||||
<div className="admin-settings-row-input admin-settings-row-input--stack">
|
||||
<div className="admin-permalink-presets">
|
||||
{(['html', 'htm', 'shtml'] as const).map(ext => (
|
||||
<button
|
||||
key={ext}
|
||||
type="button"
|
||||
className={`admin-permalink-chip${limits.permalink_ext === ext ? ' is-active' : ''}`}
|
||||
disabled={!limits.permalink_enabled}
|
||||
onClick={() => setLimits(prev => prev ? { ...prev, permalink_ext: ext } : prev)}
|
||||
>
|
||||
.{ext}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
id="limit-permalink_ext"
|
||||
value={limits.permalink_ext}
|
||||
disabled={!limits.permalink_enabled}
|
||||
placeholder="html"
|
||||
aria-labelledby="limit-label-permalink_ext"
|
||||
onChange={e => {
|
||||
const v = e.target.value.replace(/^\./, '').toLowerCase();
|
||||
setLimits(prev => prev ? { ...prev, permalink_ext: v } : prev);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="admin-settings-row-hint">
|
||||
预览:
|
||||
<code className="admin-permalink-preview">
|
||||
/post/123{limits.permalink_enabled ? `.${(limits.permalink_ext || 'html').replace(/^\./, '')}` : ''}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-settings-bar">
|
||||
<p>保存后立即影响顶栏、登录页、浏览器标题与右栏介绍</p>
|
||||
<p>保存后立即影响顶栏、页脚、伪静态链接与浏览器标题</p>
|
||||
<Button onClick={handleSaveBranding} loading={savingBranding}>
|
||||
保存品牌设置
|
||||
保存站点设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1163,6 +1447,161 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'storage' && (
|
||||
<div className="admin-settings-panel admin-mail-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">
|
||||
<span>上传对象存储</span>
|
||||
<span className={`admin-mail-status${storage.ready ? ' is-on' : ''}`}>
|
||||
<span className="admin-mail-status-dot" aria-hidden />
|
||||
{storageStatusLabel(storage)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-card-body admin-mail-body">
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-type">存储类型</label>
|
||||
<select
|
||||
id="storage-type"
|
||||
className="admin-mail-select"
|
||||
value={storage.type}
|
||||
onChange={e => setStorage(s => ({
|
||||
...s,
|
||||
type: e.target.value === 's3' ? 's3' : 'local',
|
||||
}))}
|
||||
>
|
||||
<option value="local">本地磁盘(data/uploads)</option>
|
||||
<option value="s3">S3 兼容(MinIO / OSS / 七牛等)</option>
|
||||
</select>
|
||||
<span className="admin-mail-field-hint">头像、帖子图、站点品牌图均走此后端</span>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-image-delivery">图片展示方案</label>
|
||||
<select
|
||||
id="storage-image-delivery"
|
||||
className="admin-mail-select"
|
||||
value={storage.image_delivery || 'webp'}
|
||||
onChange={e => setStorage(s => ({
|
||||
...s,
|
||||
image_delivery: e.target.value === 'original' ? 'original' : 'webp',
|
||||
}))}
|
||||
>
|
||||
<option value="webp">使用 WebP(省流量,仍保留原图)</option>
|
||||
<option value="original">使用原图(体积更大)</option>
|
||||
</select>
|
||||
<span className="admin-mail-field-hint">
|
||||
静态图上传后同时保存原图与 WebP;此项只影响新上传写入正文/头像的 URL。动图 GIF 始终保留原文件
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{storage.type === 's3' && (
|
||||
<>
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="storage-endpoint">Endpoint</label>
|
||||
<Input
|
||||
id="storage-endpoint"
|
||||
value={storage.endpoint}
|
||||
onChange={e => setStorage(s => ({ ...s, endpoint: e.target.value }))}
|
||||
placeholder="https://s3.example.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-region">Region</label>
|
||||
<Input
|
||||
id="storage-region"
|
||||
value={storage.region}
|
||||
onChange={e => setStorage(s => ({ ...s, region: e.target.value }))}
|
||||
placeholder="us-east-1"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-bucket">Bucket</label>
|
||||
<Input
|
||||
id="storage-bucket"
|
||||
value={storage.bucket}
|
||||
onChange={e => setStorage(s => ({ ...s, bucket: e.target.value }))}
|
||||
placeholder="jiang13"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-access-key">Access Key</label>
|
||||
<Input
|
||||
id="storage-access-key"
|
||||
value={storage.access_key}
|
||||
onChange={e => setStorage(s => ({ ...s, access_key: e.target.value }))}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-secret-key">Secret Key</label>
|
||||
<Input
|
||||
id="storage-secret-key"
|
||||
type="password"
|
||||
value={storage.secret_key ?? ''}
|
||||
onChange={e => setStorage(s => ({ ...s, secret_key: e.target.value }))}
|
||||
placeholder={storage.has_secret_key ? '已配置,留空则保持不变' : 'Secret Key'}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="storage-public-base">公开访问地址</label>
|
||||
<Input
|
||||
id="storage-public-base"
|
||||
value={storage.public_base_url}
|
||||
onChange={e => setStorage(s => ({ ...s, public_base_url: e.target.value }))}
|
||||
placeholder="https://cdn.example.com/forum"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<span className="admin-mail-field-hint">无尾斜杠;上传后返回此前缀下的绝对 URL</span>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-prefix">对象前缀(可选)</label>
|
||||
<Input
|
||||
id="storage-prefix"
|
||||
value={storage.prefix}
|
||||
onChange={e => setStorage(s => ({ ...s, prefix: e.target.value }))}
|
||||
placeholder="forum/"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label className="admin-mail-switch" htmlFor="storage-path-style" style={{ marginTop: 22 }}>
|
||||
<input
|
||||
id="storage-path-style"
|
||||
type="checkbox"
|
||||
checked={storage.force_path_style}
|
||||
onChange={e => setStorage(s => ({ ...s, force_path_style: e.target.checked }))}
|
||||
/>
|
||||
<span className="admin-mail-switch-ui" aria-hidden />
|
||||
<span className="admin-mail-switch-copy">
|
||||
<strong>Path-Style</strong>
|
||||
<small>MinIO 等多为开启;AWS S3 官方多为关闭</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="admin-mail-field-hint" style={{ marginTop: 8 }}>
|
||||
请确保 Bucket 已配置公开读或 CDN 回源可读;本程序不代设 ACL。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-settings-bar">
|
||||
<p>保存后立即生效,无需重启;切换存储后端或展示方案不会改写历史帖子里的图片 URL</p>
|
||||
<Button onClick={handleSaveStorageSettings} loading={savingStorage}>
|
||||
保存存储设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'filter' && (
|
||||
<div className="admin-settings-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
|
||||
Reference in New Issue
Block a user