移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:37:11 +08:00
parent 060b7707cb
commit 48db333272
121 changed files with 11147 additions and 3225 deletions

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="拾三一隅,自在交流" />
<title>姜十三论坛 - 拾三一隅,自在交流</title>
<style>
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
@@ -12,6 +13,7 @@
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
.app-header { height: 56px; flex-shrink: 0; }
.site-footer { flex-shrink: 0; }
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
.sidebar { width: 210px; flex-shrink: 0; }

View File

@@ -13,6 +13,7 @@ import MainLayout from './layouts/MainLayout';
import AdminLayout from './layouts/AdminLayout';
import ErrorBoundary from './components/ErrorBoundary';
import PageLoader from './components/PageLoader';
import AuthPageFallback from './components/AuthPageFallback';
import { Toaster } from './components/ui/sonner';
const HomePage = lazy(() => import('./pages/HomePage'));
@@ -24,18 +25,22 @@ const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
const MessagesPage = lazy(() => import('./pages/MessagesPage'));
const ProjectsPage = lazy(() => import('./pages/ProjectsPage'));
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
const AdminPostsPage = lazy(() => import('./pages/admin/AdminPostsPage'));
const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
const AdminReportsPage = lazy(() => import('./pages/admin/AdminReportsPage'));
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage'));
const AdminMediaPage = lazy(() => import('./pages/admin/AdminMediaPage'));
const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
const router = createBrowserRouter(
createRoutesFromElements(
<>
<Route path="/login" element={<Suspense fallback={<PageLoader fullScreen />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<PageLoader fullScreen />}><RegisterPage /></Suspense>} />
<Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} />
<Route path="/register" element={<Suspense fallback={<AuthPageFallback />}><RegisterPage /></Suspense>} />
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
@@ -43,19 +48,26 @@ const router = createBrowserRouter(
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
<Route path="reports" element={<Suspense fallback={<PageLoader />}><AdminReportsPage /></Suspense>} />
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
<Route path="media" element={<Suspense fallback={<PageLoader />}><AdminMediaPage /></Suspense>} />
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage title="后台页面不存在" /></Suspense>} />
</Route>
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
{/* :id 可为 123 或 123.html伪静态后缀由后台配置 */}
<Route path="/post/:id" element={<PostDetailPage />} />
<Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/compose" element={<ComposePage />} />
<Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/user/:id" element={<UserProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} />
<Route path="/messages" element={<MessagesPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
</Route>
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
</>,
),
);

View File

@@ -1,4 +1,4 @@
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, SiteBranding, RegisterConfig } from './types';
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus } from './types';
const BASE = '';
@@ -60,23 +60,60 @@ export const api = {
// 管理后台 API
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
adminSettings: () => request<AdminSettings>('/api/admin/settings'),
adminPosts: (params: { page?: number; keyword?: string }) => {
adminPosts: (params: { page?: number; keyword?: string; status?: string }) => {
const q = new URLSearchParams();
if (params.page) q.set('page', String(params.page));
if (params.keyword) q.set('keyword', params.keyword);
if (params.status) q.set('status', params.status);
const qs = q.toString();
return request<{ posts: PostItem[]; total: number; page: number; total_pages: number }>(
`/api/admin/posts${qs ? `?${qs}` : ''}`,
);
return request<{
posts: PostItem[];
total: number;
page: number;
total_pages: number;
pending_count?: number;
status?: string;
}>(`/api/admin/posts${qs ? `?${qs}` : ''}`);
},
adminApprovePost: (id: number) =>
request<{ message: string; status: string }>(`/api/admin/posts/${id}/approve`, { method: 'POST' }),
adminPinPost: (id: number, pinned: boolean) =>
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
method: 'POST', body: JSON.stringify({ pinned }),
}),
adminFeaturePost: (id: number, featured: boolean) =>
request<{ message: string; featured: boolean }>(`/api/admin/posts/${id}/feature`, {
method: 'POST', body: JSON.stringify({ featured }),
}),
adminLockPost: (id: number, locked: boolean) =>
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
method: 'POST', body: JSON.stringify({ locked }),
}),
adminRejectPost: (id: number, reason: string) =>
request<{ message: string; notified: boolean }>(`/api/admin/posts/${id}/reject`, {
method: 'POST', body: JSON.stringify({ reason }),
}),
adminReports: (params?: { page?: number; status?: ReportStatus | 'all' | string }) => {
const q = new URLSearchParams();
if (params?.page) q.set('page', String(params.page));
if (params?.status) q.set('status', params.status);
const qs = q.toString();
return request<{
reports: PostReport[];
total: number;
page: number;
pending_count: number;
status: string;
}>(`/api/admin/reports${qs ? `?${qs}` : ''}`);
},
adminHandleReport: (id: number, body: {
action: 'dismiss' | 'resolve' | 'reject_post';
handle_note?: string;
reject_reason?: string;
}) =>
request<{ message: string; report: PostReport }>(`/api/admin/reports/${id}/handle`, {
method: 'POST', body: JSON.stringify(body),
}),
adminUpdateForumSettings: (body: ForumLimits) =>
request<{ message: string; limits: ForumLimits }>('/api/admin/settings/forum', {
method: 'PUT', body: JSON.stringify(body),
@@ -97,11 +134,15 @@ export const api = {
request<{ message: string; count: number; gitea: GiteaSyncConfig }>('/api/admin/settings/gitea/sync', {
method: 'POST',
}),
adminUpdateStorageSettings: (body: StorageConfig) =>
request<{ message: string; storage: StorageConfig }>('/api/admin/settings/storage', {
method: 'PUT', body: JSON.stringify(body),
}),
adminUpdateBranding: (body: SiteBranding) =>
request<{ message: string; branding: SiteBranding }>('/api/admin/settings/branding', {
method: 'PUT', body: JSON.stringify(body),
}),
adminUploadBrandingAsset: (kind: 'logo' | 'favicon', file: File) => {
adminUploadBrandingAsset: (kind: 'logo' | 'favicon' | 'og_image', file: File) => {
const fd = new FormData();
fd.append('kind', kind);
fd.append('file', file);
@@ -110,7 +151,7 @@ export const api = {
{ method: 'POST', body: fd, headers: {} },
);
},
adminClearBrandingAsset: (kind: 'logo' | 'favicon') =>
adminClearBrandingAsset: (kind: 'logo' | 'favicon' | 'og_image') =>
request<{ message: string; branding: SiteBranding }>('/api/admin/settings/branding/clear', {
method: 'POST', body: JSON.stringify({ kind }),
}),
@@ -141,11 +182,41 @@ export const api = {
postRevision: (id: number, revId: number) =>
request<{ revision: PostRevision }>(`/api/posts/${id}/revisions/${revId}`),
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
adminComments: (page = 1) =>
request<{ comments: Comment[]; total: number; page: number; total_pages: number }>(
`/api/admin/comments?page=${page}`,
),
adminTrashPosts: (params: { page?: number; keyword?: string }) => {
const q = new URLSearchParams();
if (params.page) q.set('page', String(params.page));
if (params.keyword) q.set('keyword', params.keyword);
const qs = q.toString();
return request<{ posts: (PostItem & { deleted_at: string })[]; total: number; page: number; total_pages: number }>(
`/api/admin/posts/trash${qs ? `?${qs}` : ''}`,
);
},
adminRestorePost: (id: number) =>
request<{ message: string }>(`/api/admin/posts/${id}/restore`, { method: 'POST' }),
adminPurgePost: (id: number) =>
request<{ message: string }>(`/api/admin/posts/${id}/purge`, { method: 'DELETE' }),
adminComments: (params?: { page?: number; status?: string }) => {
const q = new URLSearchParams();
if (params?.page) q.set('page', String(params.page));
if (params?.status) q.set('status', params.status);
const qs = q.toString();
return request<{
comments: Comment[];
total: number;
page: number;
total_pages: number;
pending_count?: number;
}>(`/api/admin/comments${qs ? `?${qs}` : ''}`);
},
adminApproveComment: (id: number) =>
request<{ message: string; status: string }>(`/api/admin/comments/${id}/approve`, { method: 'POST' }),
adminRejectComment: (id: number, reason?: string) =>
request<{ message: string; status: string }>(`/api/admin/comments/${id}/reject`, {
method: 'POST', body: JSON.stringify({ reason: reason || '' }),
}),
adminDeleteComment: (id: number) => request(`/api/admin/comments/${id}`, { method: 'DELETE' }),
adminCommentRevisions: (id: number) =>
request<{ revisions: CommentRevision[] }>(`/api/admin/comments/${id}/revisions`),
adminUsers: (page = 1) =>
request<{ users: User[]; total: number; page: number; total_pages: number }>(
`/api/admin/users?page=${page}`,
@@ -154,6 +225,19 @@ export const api = {
request<{ message: string; banned: boolean }>(`/api/admin/users/${id}/ban`, {
method: 'POST', body: JSON.stringify({ banned }),
}),
adminMedia: (params?: { category?: string; page?: number; size?: number; q?: string }) => {
const sp = new URLSearchParams();
if (params?.category) sp.set('category', params.category);
if (params?.page) sp.set('page', String(params.page));
if (params?.size) sp.set('size', String(params.size));
if (params?.q) sp.set('q', params.q);
const qs = sp.toString();
return request<MediaListResult>(`/api/admin/media${qs ? `?${qs}` : ''}`);
},
adminDeleteMedia: (urls: string[]) =>
request<{ message: string; deleted: number }>('/api/admin/media/delete', {
method: 'POST', body: JSON.stringify({ urls }),
}),
adminBackup: () =>
request<{ message: string; filename: string; download: string }>('/api/admin/backup', { method: 'POST' }),
profileStats: () => request<{ stats: UserActivityStats }>('/api/profile/stats'),
@@ -193,13 +277,16 @@ export const api = {
fd.append('title', data.title);
fd.append('content', data.content);
fd.append('tags', data.tags || '');
return request<{ post_id: number }>('/api/posts', { method: 'POST', body: fd, headers: {} });
return request<{ post_id: number; message?: string; status?: string }>('/api/posts', { method: 'POST', body: fd, headers: {} });
},
updatePost: (id: number, data: { title: string; content: string; tags?: string }) => {
updatePost: (id: number, data: { title: string; content: string; tags?: string; board_id?: string | number }) => {
const fd = new FormData();
fd.append('title', data.title);
fd.append('content', data.content);
fd.append('tags', data.tags || '');
if (data.board_id != null && data.board_id !== '') {
fd.append('board_id', String(data.board_id));
}
return request<{ message: string }>(`/api/posts/${id}`, { method: 'PUT', body: fd, headers: {} });
},
deletePost: (id: number) => request<{ message: string }>(`/api/posts/${id}`, { method: 'DELETE' }),
@@ -234,27 +321,56 @@ export const api = {
logout: () => request('/api/logout', { method: 'POST' }),
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
favorite: (id: number) => request<{ favorited: boolean }>(`/api/posts/${id}/favorite`, { method: 'POST' }),
reportPost: (id: number, body: { reason: ReportReason; detail?: string }) =>
request<{ message: string; report: PostReport }>(`/api/posts/${id}/report`, {
method: 'POST', body: JSON.stringify(body),
}),
messageConversations: (params?: { page?: number; size?: number }) => {
const q = new URLSearchParams();
if (params?.page) q.set('page', String(params.page));
if (params?.size) q.set('size', String(params.size));
const qs = q.toString();
return request<{ conversations: MessageConversation[]; total: number; page: number }>(
`/api/messages/conversations${qs ? `?${qs}` : ''}`,
);
},
conversationMessages: (peerId: number, params?: { size?: number; before?: number }) => {
const q = new URLSearchParams();
if (params?.size) q.set('size', String(params.size));
if (params?.before) q.set('before', String(params.before));
const qs = q.toString();
return request<{
messages: PrivateMessage[];
total: number;
peer_user_id: number;
peer_user?: User;
is_system: boolean;
}>(`/api/messages/conversations/${peerId}${qs ? `?${qs}` : ''}`);
},
markConversationRead: (peerId: number) =>
request<{ message: string }>(`/api/messages/conversations/${peerId}/read`, { method: 'POST' }),
messageUnreadCount: () => request<{ count: number }>('/api/messages/unread-count'),
sendMessage: (body: { to_user_id: number; subject?: string; content: string }) =>
request<{ message: PrivateMessage }>('/api/messages', {
method: 'POST', body: JSON.stringify(body),
}),
markAllMessagesRead: () =>
request<{ message: string }>('/api/messages/read-all', { method: 'POST' }),
addComment: (postId: number, data: {
content: string;
replyTo?: number;
guestNick?: string;
guestEmail?: string;
guestUrl?: string;
isPrivate?: boolean;
}) => {
const fd = new FormData();
fd.append('content', data.content);
if (data.replyTo) fd.append('reply_to', String(data.replyTo));
if (data.guestNick) fd.append('guest_nick', data.guestNick);
if (data.guestEmail) fd.append('guest_email', data.guestEmail);
if (data.guestUrl) fd.append('guest_url', data.guestUrl);
if (data.isPrivate) fd.append('is_private', '1');
return request<{ message: string; floor: number; id: number }>(`/api/posts/${postId}/comments`, { method: 'POST', body: fd, headers: {} });
return request<{ message: string; floor: number; id: number; status?: string }>(`/api/posts/${postId}/comments`, { method: 'POST', body: fd, headers: {} });
},
updateComment: (id: number, content: string) => {
const fd = new FormData();
fd.append('content', content);
return request<{ message: string; content: string }>(`/api/comments/${id}`, { method: 'PUT', body: fd, headers: {} });
return request<{ message: string; content: string; status?: string }>(`/api/comments/${id}`, { method: 'PUT', body: fd, headers: {} });
},
deleteComment: (id: number) => request<{ message: string }>(`/api/comments/${id}`, { method: 'DELETE' }),
};

View File

@@ -65,7 +65,9 @@ export interface PostItem {
content?: string;
tags: string;
pinned: boolean;
featured?: boolean;
edit_locked?: boolean;
status?: 'pending' | 'published' | 'rejected' | string;
like_count: number;
view_count: number;
comment_count: number;
@@ -87,6 +89,15 @@ export interface PostRevision {
editor?: User;
}
export interface CommentRevision {
id: number;
comment_id: number;
editor_id: number;
content: string;
created_at: string;
editor?: User;
}
export interface PostDetailResponse {
post: PostItem;
comment_count: number;
@@ -105,10 +116,13 @@ export interface Comment {
floor: number;
content: string;
reply_to?: number;
/** 嵌套展示父评论(父评论不可见时可能回挂到祖先) */
thread_parent_id?: number;
guest_nick?: string;
guest_email?: string;
guest_url?: string;
is_private?: boolean;
status?: 'pending' | 'published' | 'rejected' | string;
content_hidden?: boolean;
created_at: string;
updated_at?: string;
@@ -127,6 +141,7 @@ export interface AdminDashboard {
export interface ForumLimits {
post_edit_window_hours: number;
comment_edit_window_hours: number;
rate_limit_post: number;
rate_limit_comment: number;
rate_limit_register: number;
@@ -144,6 +159,10 @@ export interface ForumLimits {
signature_max: number;
open_posts_in_new_tab: boolean;
open_content_links_in_new_tab: boolean;
/** 伪静态(固定链接)开关 */
permalink_enabled: boolean;
/** 伪静态后缀,不含点,如 html / htm */
permalink_ext: string;
}
export interface ForumLimitsPublic {
@@ -151,6 +170,7 @@ export interface ForumLimitsPublic {
post_tags_max: number;
post_content_max: number;
comment_max: number;
comment_edit_window_hours: number;
search_keyword_min: number;
search_keyword_max: number;
page_size_default: number;
@@ -159,15 +179,33 @@ export interface ForumLimitsPublic {
signature_max: number;
open_posts_in_new_tab: boolean;
open_content_links_in_new_tab: boolean;
permalink_enabled: boolean;
permalink_ext: string;
}
export interface FriendLink {
name: string;
url: string;
}
export interface SiteBranding {
name: string;
name_en: string;
slogan: string;
/** 站点简介(首页可见 + SEO description */
description?: string;
/** SEO keywords逗号分隔 */
keywords?: string;
logo_mark: string;
logo: string;
favicon: string;
/** 默认社交分享图Open Graph */
og_image?: string;
/** ICP 备案号(可选) */
icp_beian?: string;
/** ICP 备案跳转链接(可选,默认工信部查询页) */
icp_beian_url?: string;
/** 页脚友情链接 */
friend_links?: FriendLink[];
}
export interface AdminSettings {
@@ -180,11 +218,48 @@ export interface AdminSettings {
oidc: OIDCConfig;
oauth_clients: OAuthClient[];
gitea?: GiteaSyncConfig;
storage?: StorageConfig;
branding?: SiteBranding;
filter_words: string;
filter_word_count: number;
}
export interface StorageConfig {
type: 'local' | 's3';
endpoint: string;
region: string;
bucket: string;
access_key: string;
secret_key?: string;
public_base_url: string;
prefix: string;
force_path_style: boolean;
has_secret_key: boolean;
ready: boolean;
/** 展示方案webp默认| original上传始终保留原图 */
image_delivery: 'webp' | 'original';
}
export type MediaCategory = 'avatars' | 'posts' | 'site';
export interface MediaItem {
category: MediaCategory;
name: string;
url: string;
size: number;
modified_at: string;
content_type: string;
}
export interface MediaListResult {
files: MediaItem[];
total: number;
page: number;
total_pages: number;
storage_type: 'local' | 's3';
category_counts: Record<string, number>;
}
export interface MailConfig {
enabled: boolean;
host: string;
@@ -259,6 +334,8 @@ export interface RegisterConfig {
mail_ready: boolean;
require_email_code: boolean;
register_open: boolean;
/** 邮箱验证码位数,默认 6 */
email_code_len?: number;
}
export interface Paginated<T> {
@@ -271,6 +348,7 @@ export interface Paginated<T> {
export interface RecentComment {
id: number;
post_id: number;
floor: number;
user_id?: number;
author: string;
avatar: string;
@@ -278,3 +356,49 @@ export interface RecentComment {
post_title: string;
created_at: string;
}
/** 站内私信 */
export interface PrivateMessage {
id: number;
from_user_id: number;
to_user_id: number;
subject: string;
content: string;
kind: 'user' | 'system' | 'reject' | 'report_result' | string;
related_post_id?: number;
related_report_id?: number;
is_read: boolean;
created_at: string;
from_user?: User;
to_user?: User;
}
/** 按对方聚合的私信会话 */
export interface MessageConversation {
peer_user_id: number; // 0 = 系统通知
peer_user?: User;
is_system: boolean;
last_message?: PrivateMessage;
unread_count: number;
updated_at: string;
}
export type ReportReason = 'spam' | 'abuse' | 'illegal' | 'irrelevant' | 'other';
export type ReportStatus = 'pending' | 'resolved' | 'dismissed';
/** 帖子举报 */
export interface PostReport {
id: number;
post_id: number;
reporter_id: number;
reason: ReportReason | string;
detail: string;
status: ReportStatus | string;
handler_id?: number;
handle_note: string;
created_at: string;
handled_at?: string;
post?: PostItem;
reporter?: User;
handler?: User;
}

View File

@@ -0,0 +1,12 @@
import { Spinner } from '@/components/ui/spinner';
/** 登录/注册懒加载占位:保持 auth 页氛围,避免整屏空白转圈 */
export default function AuthPageFallback() {
return (
<div className="auth-page" aria-busy="true" aria-label="加载中">
<div className="auth-box auth-box--loading">
<Spinner size="lg" />
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useState, type ComponentProps } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
type Props = ComponentProps<typeof Input>;
/** 带显示/隐藏切换的密码输入 */
export default function AuthPasswordInput({ className, ...props }: Props) {
const [visible, setVisible] = useState(false);
return (
<div className="auth-password-field">
<Input
{...props}
type={visible ? 'text' : 'password'}
className={cn('auth-password-field__input', className)}
/>
<button
type="button"
className="auth-password-field__toggle"
onClick={() => setVisible(v => !v)}
aria-label={visible ? '隐藏密码' : '显示密码'}
tabIndex={-1}
>
{visible ? <EyeOff size={16} aria-hidden /> : <Eye size={16} aria-hidden />}
</button>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { ArrowUp } from 'lucide-react';
import { ArrowUp, MessageSquare } from 'lucide-react';
/** 滚动超过该距离后显示按钮 */
const SHOW_THRESHOLD = 320;
@@ -32,6 +32,7 @@ export default function BackToTop() {
const loc = useLocation();
const [visible, setVisible] = useState(false);
const scrollElRef = useRef<HTMLElement | null>(null);
const isPostDetail = /^\/post\/\d+/.test(loc.pathname);
const syncVisible = useCallback(() => {
const el = scrollElRef.current;
@@ -110,16 +111,35 @@ export default function BackToTop() {
el.scrollTo({ top: 0, behavior: 'smooth' });
};
const scrollToComments = () => {
const section = document.querySelector<HTMLElement>('.comment-section');
section?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<button
type="button"
className={`back-to-top${visible ? ' back-to-top--visible' : ''}`}
onClick={scrollToTop}
aria-label="回到顶部"
title="回到顶部"
tabIndex={visible ? 0 : -1}
>
<ArrowUp size={20} strokeWidth={2.25} />
</button>
<div className={`back-to-top-stack${visible ? ' back-to-top-stack--visible' : ''}`}>
{isPostDetail && (
<button
type="button"
className="back-to-top back-to-top--comment"
onClick={scrollToComments}
aria-label="前往评论"
title="前往评论"
tabIndex={visible ? 0 : -1}
>
<MessageSquare size={18} strokeWidth={2.25} />
</button>
)}
<button
type="button"
className="back-to-top"
onClick={scrollToTop}
aria-label="回到顶部"
title="回到顶部"
tabIndex={visible ? 0 : -1}
>
<ArrowUp size={20} strokeWidth={2.25} />
</button>
</div>
);
}

View File

@@ -1,16 +1,16 @@
import { useState, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Send } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { notify } from '@/lib/notify';
import type { User, Comment } from '../api/types';
import EmojiPicker from './EmojiPicker';
import { loadGuestInfo, saveGuestInfo } from '../utils/guest';
import { commentNick } from '../utils/comment';
import { loginPath, registerPath } from '../utils/authRedirect';
export interface CommentSubmitData {
content: string;
guestNick?: string;
guestEmail?: string;
guestUrl?: string;
isPrivate: boolean;
}
@@ -24,13 +24,9 @@ interface Props {
onCancelReply?: () => void;
}
/** Waline 风格评论输入框:登录用户 / 游客双模式 */
/** 评论输入框:登录后发表 */
export default function CommentBox({ user, replyTo, inline, submitting, submitCount = 0, onSubmit, onCancelReply }: Props) {
const saved = loadGuestInfo();
const [content, setContent] = useState('');
const [guestNick, setGuestNick] = useState(saved.nick);
const [guestEmail, setGuestEmail] = useState(saved.email);
const [guestUrl, setGuestUrl] = useState(saved.url);
const [isPrivate, setIsPrivate] = useState(false);
const [showEmoji, setShowEmoji] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -39,7 +35,6 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
useEffect(() => {
if (inline && replyTo) {
// preventScroll 避免 focus 与页面 scrollIntoView 争抢滚动位置
textareaRef.current?.focus({ preventScroll: true });
}
}, [replyTo?.id, inline]);
@@ -90,21 +85,14 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
};
const handleSubmit = () => {
if (!user) return;
const text = content.trim();
if (!text) return;
if (!user && !guestNick.trim()) return;
if (!user) {
saveGuestInfo({ nick: guestNick.trim(), email: guestEmail.trim(), url: guestUrl.trim() });
if (!text) {
notify.warning('请先写点内容');
textareaRef.current?.focus();
return;
}
onSubmit({
content: text,
guestNick: user ? undefined : guestNick.trim(),
guestEmail: user ? undefined : guestEmail.trim(),
guestUrl: user ? undefined : guestUrl.trim(),
isPrivate,
});
onSubmit({ content: text, isPrivate });
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -114,20 +102,33 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
}
};
const avatarInitial = user?.nickname?.[0] || guestNick?.[0] || '?';
if (!user) {
return (
<div className={`comment-login-gate${inline ? ' comment-login-gate--inline' : ''}`}>
<p className="comment-login-gate__text"></p>
<div className="comment-login-gate__actions">
<Button asChild size="sm">
<Link to={loginPath()}></Link>
</Button>
<Link to={registerPath()} className="comment-login-gate__register">
</Link>
</div>
</div>
);
}
const avatarInitial = user.nickname?.[0] || '?';
const canSend = !!content.trim() && !submitting;
return (
<div className="comment-box" ref={boxRef}>
<div className="comment-box-avatar">
{user?.avatar ? (
{user.avatar ? (
<img src={user.avatar} alt="" className="comment-box-avatar-img" loading="lazy" decoding="async" />
) : (
<div className={`comment-box-avatar-placeholder ${user ? '' : 'guest'}`}>
{user ? avatarInitial : (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" />
</svg>
)}
<div className="comment-box-avatar-placeholder">
{avatarInitial}
</div>
)}
</div>
@@ -155,62 +156,15 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
<button
type="button"
className="comment-box-send"
disabled={submitting || !content.trim() || (!user && !guestNick.trim())}
disabled={!canSend}
onClick={handleSubmit}
aria-label="发送评论"
title="发送"
title="发送Ctrl/⌘ + Enter"
>
<Send size={16} />
</button>
</div>
{!user && (
<div className="comment-box-guest-fields">
<label className="comment-box-guest-field">
<span className="comment-box-guest-label">
<em className="comment-box-guest-required"></em>
</span>
<input
className="comment-box-guest-input"
placeholder="怎么称呼你"
autoComplete="nickname"
value={guestNick}
onChange={(e) => setGuestNick(e.target.value)}
/>
</label>
<label className="comment-box-guest-field">
<span className="comment-box-guest-label">
<em className="comment-box-guest-optional"></em>
</span>
<input
className="comment-box-guest-input"
placeholder="name@example.com"
type="email"
autoComplete="email"
value={guestEmail}
onChange={(e) => setGuestEmail(e.target.value)}
/>
</label>
<label className="comment-box-guest-field">
<span className="comment-box-guest-label">
<em className="comment-box-guest-optional"></em>
</span>
<input
className="comment-box-guest-input"
placeholder="https://example.com"
type="url"
autoComplete="url"
value={guestUrl}
onChange={(e) => setGuestUrl(e.target.value)}
/>
</label>
<p className="comment-box-guest-hint"></p>
</div>
)}
<div className="comment-box-toolbar">
<button
ref={owoRef}
@@ -223,10 +177,11 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
>
OwO
</button>
<label className="comment-box-private">
<label className="comment-box-private" title="仅作者与管理员可见">
<Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
<span></span>
</label>
<span className="comment-box-private-hint"></span>
</div>
{showEmoji && <EmojiPicker id="comment-emoji-picker" onSelect={insertEmoji} />}

View File

@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { Comment, CommentRevision } from '../api/types';
import { formatTime } from '../utils/content';
import { countLineChanges, diffTextLines } from '../utils/revisionDiff';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
comment: Comment | null;
}
function DiffBlock({ before, after }: { before: string; after: string }) {
const parts = diffTextLines(before, after);
const { added, removed } = countLineChanges(parts);
if (before === after) {
return <p className="revision-diff-unchanged"></p>;
}
return (
<div className="revision-diff-lines">
<div className="revision-diff-stats">
{removed > 0 && <span className="revision-diff-stat revision-diff-stat--del"> {removed} </span>}
{added > 0 && <span className="revision-diff-stat revision-diff-stat--add"> {added} </span>}
</div>
<pre className="revision-diff-pre">
{parts.map((part, i) => {
const lines = part.value.split('\n');
return lines.map((line, j) => {
if (j === lines.length - 1 && line === '') return null;
const cls = part.added
? 'revision-diff-line revision-diff-line--add'
: part.removed
? 'revision-diff-line revision-diff-line--del'
: 'revision-diff-line revision-diff-line--same';
const prefix = part.added ? '+' : part.removed ? '' : ' ';
return (
<div key={`${i}-${j}`} className={cls}>
<span className="revision-diff-gutter" aria-hidden="true">{prefix}</span>
<span className="revision-diff-text">{line || ' '}</span>
</div>
);
});
})}
</pre>
</div>
);
}
/** 管理员查看评论编辑历史 */
export default function CommentRevisionDialog({ open, onOpenChange, comment }: Props) {
const [revisions, setRevisions] = useState<CommentRevision[]>([]);
const [loading, setLoading] = useState(false);
const [activeId, setActiveId] = useState<number | null>(null);
useEffect(() => {
if (!open || !comment) {
setRevisions([]);
setActiveId(null);
return;
}
let cancelled = false;
setLoading(true);
api.adminCommentRevisions(comment.id)
.then((r) => {
if (cancelled) return;
const list = r.revisions ?? [];
setRevisions(list);
setActiveId(list[0]?.id ?? null);
})
.catch((e: unknown) => {
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [open, comment]);
const active = revisions.find((r) => r.id === activeId) || null;
const activeIndex = active ? revisions.findIndex((r) => r.id === active.id) : -1;
const afterContent = activeIndex <= 0
? (comment?.content ?? '')
: (revisions[activeIndex - 1]?.content ?? '');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{comment ? `#${comment.floor} 楼 · 共 ${revisions.length} 次修改前快照` : '评论编辑历史'}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex justify-center py-10"><Spinner /></div>
) : revisions.length === 0 ? (
<div className="admin-empty"></div>
) : (
<div className="comment-rev-layout">
<aside className="comment-rev-list" aria-label="历史版本">
{revisions.map((rev, i) => (
<button
key={rev.id}
type="button"
className={`comment-rev-item${activeId === rev.id ? ' active' : ''}`}
onClick={() => setActiveId(rev.id)}
>
<span className="comment-rev-item__ver"> {revisions.length - i}</span>
<span className="comment-rev-item__meta">
{rev.editor?.nickname || `用户 #${rev.editor_id}`}
{' · '}
{formatTime(rev.created_at)}
</span>
</button>
))}
</aside>
<div className="comment-rev-detail">
{active ? (
<>
<p className="comment-rev-detail__hint">
{activeIndex <= 0 ? '当前正文' : '下一版本'}
</p>
<DiffBlock before={active.content} after={afterContent} />
</>
) : null}
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import { Clock, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
import { Check, Clock, History, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import type { Comment, User } from '../api/types';
import CommentContent from './CommentContent';
import CommentRevisionDialog from './CommentRevisionDialog';
import {
AlertDialog,
AlertDialogAction,
@@ -26,10 +27,18 @@ import { isTimeDiffSignificant } from '../utils/content';
import { useForumLimits } from '../hooks/useForumLimits';
import UserLink from './UserLink';
function canManageComment(c: Comment, user?: User | null): boolean {
function isCommentAuthor(c: Comment, user?: User | null): boolean {
return !!user && c.user_id > 0 && c.user_id === user.id;
}
function canEditComment(c: Comment, user: User | null | undefined, windowHours: number): boolean {
if (!user) return false;
if (user.role === 'admin') return true;
return c.user_id > 0 && c.user_id === user.id;
if (!isCommentAuthor(c, user)) return false;
if (windowHours <= 0) return true;
const created = new Date(c.created_at).getTime();
if (Number.isNaN(created)) return false;
return Date.now() - created <= windowHours * 3600_000;
}
interface ItemProps {
@@ -45,6 +54,7 @@ interface ItemProps {
onCancelEdit: () => void;
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
onDelete: (comment: Comment) => Promise<void>;
onApprove?: (comment: Comment) => Promise<void>;
renderReplyBox?: (comment: Comment) => ReactNode;
}
@@ -62,6 +72,7 @@ function CommentItem({
onCancelEdit,
onSaveEdit,
onDelete,
onApprove,
renderReplyBox,
}: ItemProps) {
const { limits } = useForumLimits();
@@ -72,11 +83,18 @@ function CommentItem({
const hidden = !!c.content_hidden;
const isReplying = replyToId === c.id;
const isEditing = editingId === c.id;
const manageable = canManageComment(c, currentUser);
const isAdmin = currentUser?.role === 'admin';
const canEdit = canEditComment(c, currentUser, limits.comment_edit_window_hours ?? 24);
const canDelete = isAdmin;
const canApprove = isAdmin
&& (c.status === 'pending' || c.status === 'rejected')
&& !!onApprove;
const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at);
const [editText, setEditText] = useState(c.content);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [approving, setApproving] = useState(false);
const [revOpen, setRevOpen] = useState(false);
useEffect(() => {
if (isEditing) setEditText(c.content);
@@ -178,7 +196,27 @@ function CommentItem({
<Clock size={14} />
{formatCommentDate(c.created_at)}
{showEdited && <span className="waline-comment-edited"> · </span>}
{c.status === 'pending' && <span className="waline-comment-status waline-comment-status--pending"> · </span>}
{c.status === 'rejected' && <span className="waline-comment-status waline-comment-status--rejected"> · </span>}
</span>
{!hidden && !isEditing && canApprove && (
<button
type="button"
className="waline-comment-reply-btn waline-comment-approve-btn"
disabled={approving}
onClick={async () => {
setApproving(true);
try {
await onApprove?.(c);
} finally {
setApproving(false);
}
}}
>
<Check size={14} />
{approving ? '通过中…' : '通过'}
</button>
)}
{!hidden && !isEditing && (
isReplying ? (
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
@@ -192,13 +230,19 @@ function CommentItem({
</button>
)
)}
{!hidden && !isEditing && manageable && (
{!hidden && !isEditing && canEdit && (
<button type="button" className="waline-comment-reply-btn" onClick={() => onStartEdit(c)}>
<Pencil size={14} />
</button>
)}
{!hidden && !isEditing && manageable && (
{!hidden && !isEditing && isAdmin && showEdited && (
<button type="button" className="waline-comment-reply-btn" onClick={() => setRevOpen(true)}>
<History size={14} />
</button>
)}
{!hidden && !isEditing && canDelete && (
<AlertDialog>
<AlertDialogTrigger asChild>
<button type="button" className="waline-comment-reply-btn cancel" disabled={deleting}>
@@ -231,6 +275,10 @@ function CommentItem({
)}
</div>
{isAdmin && (
<CommentRevisionDialog open={revOpen} onOpenChange={setRevOpen} comment={c} />
)}
{isReplying && renderReplyBox && (
<div id={`reply-box-${c.id}`} className="comment-box-wrap inline">
{renderReplyBox(c)}
@@ -254,6 +302,7 @@ function CommentItem({
onCancelEdit={onCancelEdit}
onSaveEdit={onSaveEdit}
onDelete={onDelete}
onApprove={onApprove}
renderReplyBox={renderReplyBox}
/>
))}
@@ -276,6 +325,7 @@ interface Props {
onCancelEdit: () => void;
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
onDelete: (comment: Comment) => Promise<void>;
onApprove?: (comment: Comment) => Promise<void>;
renderReplyBox?: (comment: Comment) => ReactNode;
}
@@ -292,6 +342,7 @@ export default function CommentThreadList({
onCancelEdit,
onSaveEdit,
onDelete,
onApprove,
renderReplyBox,
}: Props) {
const tree = buildCommentTree(comments);
@@ -312,6 +363,7 @@ export default function CommentThreadList({
onCancelEdit={onCancelEdit}
onSaveEdit={onSaveEdit}
onDelete={onDelete}
onApprove={onApprove}
renderReplyBox={renderReplyBox}
/>
))}

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
toUserId: number;
toNickname: string;
onSent?: () => void;
}
/** 发送私信对话框(对话式,无需标题) */
export default function ComposeMessageDialog({
open,
onOpenChange,
toUserId,
toNickname,
onSent,
}: Props) {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const handleOpenChange = (next: boolean) => {
if (!next) setContent('');
onOpenChange(next);
};
const submit = async () => {
if (!content.trim()) {
notify.warning('请填写内容');
return;
}
setSending(true);
try {
await api.sendMessage({
to_user_id: toUserId,
content: content.trim(),
});
notify.success('私信已发送');
handleOpenChange(false);
onSent?.();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '发送失败');
} finally {
setSending(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> {toNickname}</DialogDescription>
</DialogHeader>
<div className="pm-compose-fields">
<label className="pm-field">
<span className="sr-only"></span>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={6}
maxLength={4000}
placeholder="写点什么…"
autoFocus
/>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}></Button>
<Button loading={sending} onClick={submit}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -19,12 +19,20 @@ export default class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.error) {
return (
<div className="error-boundary">
<h3></h3>
<p className="error-boundary-msg">{this.state.error.message}</p>
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
</Button>
<div className="error-page-shell">
<div className="error-page">
<div className="error-page__code" aria-hidden>500</div>
<h1 className="error-page__title"></h1>
<p className="error-page__desc">{this.state.error.message || '发生了意外错误,请尝试刷新页面。'}</p>
<div className="error-page__actions">
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
</Button>
<Button size="sm" variant="outline" onClick={() => { window.location.href = '/'; }}>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Props {
className?: string;
size?: number;
}
/** 精华帖标识 */
export default function FeaturedIcon({ className, size = 16 }: Props) {
return (
<Sparkles
className={cn('post-featured-icon', className)}
size={size}
aria-label="精华"
role="img"
/>
);
}

View File

@@ -8,9 +8,11 @@ interface Props {
boards: Board[];
stats: ForumStats | null;
postTotal: number;
/** 首页「全部帖子」用 h2板块/搜索页用 h1 */
titleAs?: 'h1' | 'h2';
}
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) {
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal, titleAs = 'h1' }: Props) {
const nav = useNavigate();
const board = boards.find(b => b.id === boardId);
@@ -19,12 +21,27 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal
: (boardId && board ? board.name : '全部帖子');
const boardHint = boardId && board ? (board.description || '') : '';
const TitleTag = titleAs;
const inBoard = !keyword && boardId > 0 && !!board;
return (
<div className={`feed-head${keyword ? ' feed-head--solo' : ''}`}>
<div className="feed-head__title">
<h2 title={boardHint || undefined}>{title}</h2>
{!keyword && stats && (
<TitleTag title={boardHint || undefined}>{title}</TitleTag>
{!keyword && inBoard && (
<div className="feed-head__stats">
<span className="feed-stat-chip">
<FileText aria-hidden />
<strong>{postTotal}</strong>
</span>
{stats && (
<span className="feed-stat-chip feed-stat-chip--muted" title="全站统计">
{stats.posts} · {stats.users}
</span>
)}
</div>
)}
{!keyword && !inBoard && stats && (
<div className="feed-head__stats">
<span className="feed-stat-chip">
<Users aria-hidden />

View File

@@ -7,22 +7,24 @@ export default function FeedPageSkeleton() {
<div className="page-wrap page-wrap--feed" aria-busy="true" aria-label="内容加载中">
<div className="feed-panel">
<div className="feed-top">
<div className="feed-head">
<div className="feed-head__title">
<Skeleton className="skeleton--feed-title" />
<div className="feed-head__stats">
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
<div className="feed-top__bar">
<div className="feed-head">
<div className="feed-head__title">
<Skeleton className="skeleton--feed-title" />
<div className="feed-head__stats">
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
<Skeleton className="skeleton--stat-chip" />
</div>
</div>
</div>
</div>
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<span className="feed-toolbar__spacer" />
<Skeleton className="skeleton--count" />
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<Skeleton className="skeleton--sort-tab" />
<span className="feed-toolbar__spacer" />
<Skeleton className="skeleton--count" />
</div>
</div>
</div>
<div className="post-list-scroll">

View File

@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Eye, FileText, Heart, Mail, MessageCircle, UserRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { api } from '../api/client';
import type { User, UserActivityStats, UserPublic } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect';
import { formatTime } from '../utils/content';
import { userPath } from '../utils/userPath';
import ComposeMessageDialog from './ComposeMessageDialog';
import UserLink from './UserLink';
interface Props {
author?: User | null;
publishedAt?: string;
viewCount?: number;
}
/** 帖子详情右栏:作者信息卡(私信 / 主页 / 统计) */
export default function PostAuthorCard({
author,
publishedAt,
viewCount,
}: Props) {
const nav = useNavigate();
const { user: me } = useAuth();
const [profile, setProfile] = useState<UserPublic | null>(null);
const [stats, setStats] = useState<UserActivityStats | null>(null);
const [msgOpen, setMsgOpen] = useState(false);
useEffect(() => {
if (!author?.id) {
setProfile(null);
setStats(null);
return;
}
let cancelled = false;
api.userProfile(author.id)
.then((r) => {
if (cancelled) return;
setProfile(r.user);
setStats(r.stats);
})
.catch(() => {
if (cancelled) return;
// 详情里已有轻量 user接口失败时仍可展示基本信息
setProfile(null);
setStats(null);
});
return () => { cancelled = true; };
}, [author?.id]);
if (!author?.id) {
return (
<div className="widget-card widget-card--author">
<div className="widget-card-head">
<UserRound className="widget-card-icon widget-card-icon--author" aria-hidden />
</div>
<div className="widget-card-body">
<div className="widget-empty"></div>
</div>
</div>
);
}
const display = profile ?? author;
const nick = display.nickname || display.username || `用户 #${author.id}`;
const initial = nick.charAt(0) || '?';
const signature = (profile?.signature ?? author.signature ?? '').trim();
const isAdmin = display.role === 'admin';
const isSelf = !!me && me.id === author.id;
const profileHref = userPath(author.id);
const openMessage = () => {
if (!me) {
nav(loginPath(profileHref));
return;
}
setMsgOpen(true);
};
return (
<div className="widget-card widget-card--author">
<div className="widget-card-head">
<UserRound className="widget-card-icon widget-card-icon--author" aria-hidden />
</div>
<div className="widget-author-panel">
<div className="widget-author-body">
<UserLink
user={display}
showAvatar={false}
showName={false}
className="widget-author-avatar user-link--avatar-only"
>
{display.avatar
? <img src={display.avatar} alt="" loading="lazy" decoding="async" />
: initial}
</UserLink>
<div className="widget-author-meta">
<div className="widget-author-name-row">
<UserLink user={display} className="widget-author-name" />
{isAdmin && <Badge variant="green" className="widget-author-badge"></Badge>}
{display.banned && <Badge variant="destructive" className="widget-author-badge"></Badge>}
</div>
{signature ? (
<p className="widget-author-signature" title={signature}>{signature}</p>
) : null}
{(publishedAt || typeof viewCount === 'number') && (
<p className="widget-author-stats">
{publishedAt ? <span>{formatTime(publishedAt)} </span> : null}
{publishedAt && typeof viewCount === 'number' ? (
<span className="widget-author-stats-dot" aria-hidden>·</span>
) : null}
{typeof viewCount === 'number' ? (
<span className="widget-author-views">
<Eye size={12} aria-hidden />
{viewCount}
</span>
) : null}
</p>
)}
</div>
</div>
<div className="widget-author-metrics" aria-label="作者统计">
<div className="widget-author-metric">
<FileText size={13} aria-hidden />
<strong>{stats?.post_count ?? '—'}</strong>
<span></span>
</div>
<div className="widget-author-metric">
<MessageCircle size={13} aria-hidden />
<strong>{stats?.comment_count ?? '—'}</strong>
<span></span>
</div>
<div className="widget-author-metric">
<Heart size={13} aria-hidden />
<strong>{stats?.like_received ?? '—'}</strong>
<span></span>
</div>
</div>
<div className="widget-author-actions">
{!isSelf && (
<Button size="sm" className="widget-author-action" onClick={openMessage}>
<Mail size={14} />
</Button>
)}
<Button
size="sm"
variant="outline"
className="widget-author-action"
onClick={() => nav(isSelf ? '/profile' : profileHref)}
>
{isSelf ? '我的主页' : '查看主页'}
</Button>
</div>
</div>
{!isSelf && (
<ComposeMessageDialog
open={msgOpen}
onOpenChange={setMsgOpen}
toUserId={author.id}
toNickname={nick}
onSent={() => nav(`/messages?peer=${author.id}`)}
/>
)}
</div>
);
}

View File

@@ -1,11 +1,14 @@
import { memo } from 'react';
import { MessageCircle, ThumbsUp } from 'lucide-react';
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge';
import FeaturedIcon from '@/components/FeaturedIcon';
import PinnedIcon from '@/components/PinnedIcon';
import UserLink from '@/components/UserLink';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { formatTime } from '../utils/content';
import { postPath } from '../utils/permalink';
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
interface Props {
post: PostItem;
@@ -22,6 +25,10 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
: formatTime(post.created_at);
const commentCount = post.comment_count ?? 0;
const likeCount = post.like_count ?? 0;
const viewCount = post.view_count ?? 0;
const href = postPath(post.id);
const excerpt = excerptFromHTML(post.content || '', 72);
const hasImage = !!firstImageFromHTML(post.content || '');
const openPost = () => onSelect(post.id);
const onKeyDown = (e: React.KeyboardEvent) => {
@@ -30,11 +37,21 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
openPost();
}
};
const onTitleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
// 修饰键 / 非左键:交给浏览器(新标签等)
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
e.stopPropagation();
return;
}
e.preventDefault();
e.stopPropagation();
openPost();
};
return (
<div
className="post-row"
role="button"
role="link"
tabIndex={0}
onClick={openPost}
onKeyDown={onKeyDown}
@@ -50,26 +67,68 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
: initial}
</UserLink>
<div className="post-body">
<div className="post-title">
{post.pinned && <PinnedIcon className="mr-1.5" />}
<div className="post-head">
<div className="post-head-meta">
<UserLink user={post.user} stopPropagation className="post-author" />
<span className="post-head-dot" aria-hidden>·</span>
<span className="post-time">{timeLabel}</span>
</div>
{(post.featured || post.pinned || post.status === 'pending' || post.status === 'rejected') && (
<div className="post-head-badges">
{post.status === 'pending' && (
<span className="post-status-badge post-status-badge--pending" title="审核中"></span>
)}
{post.status === 'rejected' && (
<span className="post-status-badge post-status-badge--rejected" title="未通过"></span>
)}
{post.featured && (
<span className="post-feature-badge" title="精华">
<FeaturedIcon size={12} />
</span>
)}
{post.pinned && (
<span className="post-pin-badge" title="置顶">
<PinnedIcon size={12} />
</span>
)}
</div>
)}
</div>
<a href={href} className="post-title" onClick={onTitleClick}>
{post.title}
</a>
{excerpt && <p className="post-excerpt">{excerpt}</p>}
<div className="post-foot">
<div className="post-foot-left">
{post.board && <BoardBadge board={post.board} />}
</div>
<div className="post-stats">
{hasImage && (
<span className="post-stat post-stat--media" title="含图片">
<ImageIcon aria-hidden />
</span>
)}
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`} title="评论">
<MessageCircle aria-hidden />
{commentCount}
</span>
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`} title="点赞">
<ThumbsUp aria-hidden />
{likeCount}
</span>
<span className={`post-stat${viewCount === 0 ? ' post-stat--zero' : ''}`} title="浏览">
<Eye aria-hidden />
{viewCount}
</span>
</div>
</div>
<div className="post-meta">
{post.board && <BoardBadge board={post.board} />}
<UserLink user={post.user} stopPropagation className="post-meta-user" />
<span>{timeLabel}</span>
</div>
</div>
<div className="post-stats">
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}>
<MessageCircle aria-hidden />
{commentCount}
</span>
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`}>
<ThumbsUp aria-hidden />
{likeCount}
</span>
</div>
</div>
);

View File

@@ -4,7 +4,7 @@ interface Props {
count?: number;
}
/** 帖子列表加载骨架屏 */
/** 帖子列表加载骨架屏(对齐卡片式列表) */
export default function PostListSkeleton({ count = 8 }: Props) {
return (
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
@@ -12,16 +12,23 @@ export default function PostListSkeleton({ count = 8 }: Props) {
<div key={i} className="post-row post-row--skeleton">
<Skeleton className="skeleton--avatar" />
<div className="post-body">
<Skeleton className="skeleton--title" style={{ width: `${55 + (i % 4) * 10}%` }} />
<div className="skeleton-meta-row">
<Skeleton className="skeleton--badge" />
<Skeleton className="skeleton--meta" />
<Skeleton className="skeleton--meta skeleton--meta-short" />
<div className="post-head">
<div className="skeleton-meta-row">
<Skeleton className="skeleton--meta" />
<Skeleton className="skeleton--meta skeleton--meta-short" />
</div>
{i % 4 === 0 && <Skeleton className="skeleton--badge" />}
</div>
<Skeleton className="skeleton--title" style={{ width: `${58 + (i % 4) * 9}%` }} />
<Skeleton className="skeleton--excerpt" style={{ width: `${72 + (i % 3) * 8}%` }} />
<div className="post-foot">
<Skeleton className="skeleton--badge" />
<div className="post-stats">
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
</div>
</div>
</div>
<div className="post-stats">
<Skeleton className="skeleton--stat" />
<Skeleton className="skeleton--stat" />
</div>
</div>
))}

View File

@@ -1,19 +1,33 @@
import { Flame, MessageCircle, Tags } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { Flame, ListTree, MessageCircle, Tags, Sparkles } from 'lucide-react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import type { PostItem, RecentComment, TagCount } from '../api/types';
import type { PostItem, RecentComment, TagCount, User } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { useSiteBranding } from '../hooks/useSiteBranding';
import TagCloud from './TagCloud';
import UserLink from './UserLink';
import ArticleOutline from './ArticleOutline';
import PostAuthorCard from './PostAuthorCard';
export type PostDetailAside = {
author?: User | null;
publishedAt?: string;
viewCount?: number;
headings: PostHeading[];
scrollRoot?: HTMLElement | null;
outlineTitle?: string;
};
interface Props {
hot: PostItem[];
recentComments: RecentComment[];
tags?: TagCount[];
tagsLoading?: boolean;
onPostClick: (id: number) => void;
onPostClick: (id: number, opts?: { floor?: number }) => void;
/** 首次拉取中,显示骨架避免空态闪烁 */
loading?: boolean;
/** 帖子详情:右侧顶部展示作者与目录 */
postDetail?: PostDetailAside | null;
}
function hotRankClass(index: number): string {
@@ -57,107 +71,173 @@ export default function RightPanel({
tagsLoading = false,
onPostClick,
loading = false,
postDetail = null,
}: Props) {
const { branding } = useSiteBranding();
const loc = useLocation();
const [params] = useSearchParams();
const activeTag = params.get('keyword') || '';
const hotList = hot?.slice(0, 8) ?? [];
const commentList = recentComments?.slice(0, 6) ?? [];
// 站点首页:右侧品牌块承担唯一 h1板块/搜索等页面由 Feed 标题作 h1
const isSiteHome = loc.pathname === '/' && !params.get('board') && !params.get('keyword');
const description = branding.description?.trim() || '';
const slogan = branding.slogan?.trim() || '';
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
const aboutText = description || '欢迎参与讨论,发帖、评论,一起把小圈子聊热。';
// 帖子很少时热门几乎等于主列表,改显示欢迎引导
const showHot = loading || hotList.length >= 4;
const showWelcome = !loading && hotList.length > 0 && hotList.length < 4;
const isPostDetail = !!postDetail;
return (
<div className="aside-panel-inner">
<div className="widget-card">
<div className="widget-card-head">
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
</div>
<div className="widget-card-body">
{loading && hotList.length === 0 ? (
<HotSkeleton />
) : hotList.length === 0 ? (
<div className="widget-empty"></div>
) : hotList.map((item, i) => (
<button
key={item.id}
type="button"
className="widget-item"
onClick={() => onPostClick(item.id)}
>
<span className={hotRankClass(i)}>{i + 1}</span>
<span className="widget-item-title">{item.title}</span>
</button>
))}
</div>
</div>
<div className="widget-card widget-card--tags">
<div className="widget-card-head">
<Tags className="widget-card-icon widget-card-icon--tags" aria-hidden />
</div>
<div className="widget-card-body widget-card-body--tags">
<TagCloud tags={tags} loading={tagsLoading} activeTag={activeTag} />
</div>
</div>
<div className="widget-card">
<div className="widget-card-head">
<MessageCircle className="widget-card-icon widget-card-icon--notice" aria-hidden />
</div>
<div className="widget-card-body">
{loading && commentList.length === 0 ? (
<CommentSkeleton />
) : commentList.length === 0 ? (
<div className="widget-empty"></div>
) : commentList.map(item => (
<div
key={item.id}
className="widget-item widget-item--comment"
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
>
{item.user_id ? (
<UserLink
user={{ id: item.user_id, nickname: item.author, avatar: item.avatar }}
showAvatar={false}
showName={false}
stopPropagation
className="widget-item-avatar user-link--avatar-only"
>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</UserLink>
) : (
<span className="widget-item-avatar" aria-hidden>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</span>
)}
<button
type="button"
className="widget-item-comment-main"
onClick={() => onPostClick(item.post_id)}
>
<span className="widget-item-title">{item.excerpt}</span>
<span className="widget-item-time">{item.created_at}</span>
</button>
<div className={`aside-panel-inner${isPostDetail ? ' aside-panel-inner--post-detail' : ''}`}>
{isPostDetail && (
<>
<PostAuthorCard
author={postDetail.author}
publishedAt={postDetail.publishedAt}
viewCount={postDetail.viewCount}
/>
<div className="widget-card widget-card--outline">
<div className="widget-card-head">
<ListTree className="widget-card-icon widget-card-icon--outline" aria-hidden />
{postDetail.outlineTitle || '文章目录'}
</div>
))}
</div>
</div>
<div className="widget-card-body widget-outline-body">
<ArticleOutline
headings={postDetail.headings}
scrollRoot={postDetail.scrollRoot}
title={postDetail.outlineTitle || '文章目录'}
className="article-outline--aside"
/>
</div>
</div>
</>
)}
<div className="widget-card widget-card--about">
<div className="widget-card-body">
<p className="widget-about-text">
<strong>{branding.name}</strong>
{branding.slogan
? `${branding.slogan}${branding.name_en ? ` · ${branding.name_en}` : ''}`
: (branding.name_en || '轻量社区')}
</p>
{!isPostDetail && showWelcome && (
<div className="widget-card widget-card--welcome">
<div className="widget-card-head">
<Sparkles className="widget-card-icon widget-card-icon--welcome" aria-hidden />
</div>
<div className="widget-card-body widget-welcome-body">
<p></p>
<ul>
<li></li>
<li></li>
<li></li>
</ul>
</div>
</div>
</div>
)}
{!isPostDetail && showHot && (
<div className="widget-card">
<div className="widget-card-head">
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
</div>
<div className="widget-card-body">
{loading && hotList.length === 0 ? (
<HotSkeleton />
) : hotList.length === 0 ? (
<div className="widget-empty"></div>
) : hotList.map((item, i) => (
<button
key={item.id}
type="button"
className="widget-item"
onClick={() => onPostClick(item.id)}
>
<span className={hotRankClass(i)}>{i + 1}</span>
<span className="widget-item-title">{item.title}</span>
</button>
))}
</div>
</div>
)}
{!isPostDetail && (
<div className="widget-card widget-card--tags">
<div className="widget-card-head">
<Tags className="widget-card-icon widget-card-icon--tags" aria-hidden />
</div>
<div className="widget-card-body widget-card-body--tags">
<TagCloud tags={tags} loading={tagsLoading} activeTag={activeTag} />
</div>
</div>
)}
{!isPostDetail && (
<div className="widget-card">
<div className="widget-card-head">
<MessageCircle className="widget-card-icon widget-card-icon--notice" aria-hidden />
</div>
<div className="widget-card-body">
{loading && commentList.length === 0 ? (
<CommentSkeleton />
) : commentList.length === 0 ? (
<div className="widget-empty"></div>
) : commentList.map(item => (
<div
key={item.id}
className="widget-item widget-item--comment"
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
>
{item.user_id ? (
<UserLink
user={{ id: item.user_id, nickname: item.author, avatar: item.avatar }}
showAvatar={false}
showName={false}
stopPropagation
className="widget-item-avatar user-link--avatar-only"
>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</UserLink>
) : (
<span className="widget-item-avatar" aria-hidden>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</span>
)}
<button
type="button"
className="widget-item-comment-main"
onClick={() => onPostClick(item.post_id, item.floor > 0 ? { floor: item.floor } : undefined)}
>
<span className="widget-item-title">{item.excerpt}</span>
<span className="widget-item-time">{item.created_at}</span>
</button>
</div>
))}
</div>
</div>
)}
{!isPostDetail && (
<div className="widget-card widget-card--about">
<div className="widget-card-body">
<div className="widget-about-text">
{isSiteHome ? (
<h1 className="widget-about-title">{branding.name}</h1>
) : (
<p className="widget-about-title">{branding.name}</p>
)}
<p className="widget-about-desc">{aboutText}</p>
{description && slogan && slogan !== description && (
<p className="widget-about-slogan">{slogan}</p>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -20,8 +20,10 @@ export function isNeutralSidebarRoute(pathname: string): boolean {
return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix));
}
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
function resolveMenuKey(pathname: string, activeBoard: number, keyword = ''): string | null {
if (isNeutralSidebarRoute(pathname)) return null;
// 搜索结果不属于「全部帖子」或某一板块,取消侧栏选中高亮
if (keyword.trim()) return null;
if (pathname.startsWith('/favorites')) return 'favorites';
if (pathname.startsWith('/projects')) return 'projects';
if (pathname.startsWith('/admin')) return 'admin';
@@ -58,7 +60,8 @@ export default function Sidebar({
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const menuKey = resolveMenuKey(loc.pathname, activeBoard);
const keyword = params.get('keyword') || '';
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
const navItem = (key: string, label: React.ReactNode, icon?: React.ReactNode, onClick?: () => void) => (
<button
@@ -138,7 +141,9 @@ export default function Sidebar({
/>
<span className="flex-1 truncate">{b.name}</span>
{(b.post_count ?? 0) > 0 && (
<span className="sidebar-nav-item__meta">{b.post_count}</span>
<span className="sidebar-nav-item__meta" title={`${b.post_count} 篇帖子`}>
{b.post_count}
</span>
)}
</button>
);

View File

@@ -0,0 +1,70 @@
import { useSiteBranding } from '../hooks/useSiteBranding';
import { useMediaQuery } from '../hooks/useTheme';
import type { FriendLink } from '../api/types';
function FooterSep() {
return <span className="site-footer__sep" aria-hidden>·</span>;
}
/** 站点页脚版权、Sitemap、友链、备案号 */
export default function SiteFooter() {
const { branding } = useSiteBranding();
const year = new Date().getFullYear();
const links = Array.isArray(branding.friend_links) ? branding.friend_links : [];
const icp = branding.icp_beian?.trim() || '';
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
return (
<footer className="site-footer">
<div className="site-footer__inner">
<div className="site-footer__meta">
<span className="site-footer__copy">
© {year} {branding.name}
</span>
{branding.slogan?.trim() && (
<>
<FooterSep />
<span className="site-footer__slogan">{branding.slogan.trim()}</span>
</>
)}
</div>
{(links.length > 0 || icp) && (
<nav className="site-footer__nav" aria-label="站点链接">
{links.map((link: FriendLink, i) => (
<span key={`${link.name}-${link.url}`} className="site-footer__friend">
{i > 0 && <FooterSep />}
<a href={link.url} target="_blank" rel="noopener noreferrer">
{link.name}
</a>
</span>
))}
{icp && (
<>
{links.length > 0 && <FooterSep />}
<a
href={icpURL}
target="_blank"
rel="noopener noreferrer"
className="site-footer__icp"
>
{icp}
</a>
</>
)}
</nav>
)}
</div>
</footer>
);
}
/**
* 手机端随内容滚动的页脚(放在 .page-wrap / .post-list-scroll 末尾)。
* 桌面端返回 null由 MainLayout 壳层贴底页脚负责。
*/
export function InFlowSiteFooter() {
const isMobile = useMediaQuery('(max-width: 768px)');
if (!isMobile) return null;
return <SiteFooter />;
}

View File

@@ -1,11 +1,12 @@
import { useRef, useEffect, useLayoutEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox } from 'lucide-react';
import { Inbox, SearchX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PostListItem from './PostListItem';
import PostListSkeleton from './PostListSkeleton';
import FeedPagination from './FeedPagination';
import { InFlowSiteFooter } from './SiteFooter';
import { useAuth } from '../hooks/useAuth';
import { loginPath } from '../utils/authRedirect';
import type { PostItem } from '../api/types';
@@ -28,6 +29,12 @@ interface Props {
resetScrollKey?: number;
onScrollTopChange?: (top: number) => void;
onScrollRestored?: () => void;
/** 搜索关键词(用于空态文案) */
keyword?: string;
/** 当前板块 id0 表示全部 */
boardId?: number;
/** 当前板块名 */
boardName?: string;
}
export default function VirtualPostList({
@@ -45,6 +52,9 @@ export default function VirtualPostList({
resetScrollKey = 0,
onScrollTopChange,
onScrollRestored,
keyword = '',
boardId = 0,
boardName = '',
}: Props) {
const nav = useNavigate();
const { user } = useAuth();
@@ -58,7 +68,7 @@ export default function VirtualPostList({
const virtualizer = useVirtualizer({
count: posts.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
estimateSize: () => 108,
overscan: 8,
measureElement:
typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
@@ -69,6 +79,8 @@ export default function VirtualPostList({
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0;
const isEmpty = !loading && posts.length === 0;
const isSearchEmpty = isEmpty && !!keyword.trim();
const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose';
useLayoutEffect(() => {
if (resetScrollKey <= 0) return;
@@ -102,26 +114,56 @@ export default function VirtualPostList({
return () => el.removeEventListener('scroll', onScroll);
}, []);
const emptyActions = (
<div className="empty-feed-actions">
{isSearchEmpty ? (
<>
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
</Button>
<Button type="button" size="sm" onClick={() => nav(user ? composeTarget : loginPath(composeTarget))}>
{user ? '发帖' : '登录后发帖'}
</Button>
</>
) : (
<>
{boardId > 0 && (
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
</Button>
)}
{user ? (
<Button type="button" size="sm" onClick={() => nav(composeTarget)}>
{boardName ? `成为「${boardName}」第一帖` : '发第一帖'}
</Button>
) : (
<Button type="button" size="sm" onClick={() => nav(loginPath(composeTarget))}>
</Button>
)}
</>
)}
</div>
);
return (
<div className="post-list-scroll" ref={parentRef}>
{isInitialLoad ? (
<PostListSkeleton />
) : isEmpty ? (
<div className="empty-feed" role="status">
<Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<p className="empty-feed-hint"></p>
<div className="empty-feed-actions">
{user ? (
<Button type="button" size="sm" onClick={() => nav('/compose')}>
</Button>
) : (
<Button type="button" size="sm" onClick={() => nav(loginPath('/compose'))}>
</Button>
)}
</div>
{isSearchEmpty
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
: <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />}
<p>{isSearchEmpty ? '没有匹配的帖子' : '暂无帖子'}</p>
<p className="empty-feed-hint">
{isSearchEmpty
? '试试更短的关键词,或浏览标签云 / 板块'
: boardName
? `${boardName}」还没有内容,来发第一篇吧`
: '换个板块看看,或发第一篇内容'}
</p>
{emptyActions}
</div>
) : (
<>
@@ -163,6 +205,7 @@ export default function VirtualPostList({
)}
</>
)}
<InFlowSiteFooter />
</div>
);
}

View File

@@ -39,17 +39,29 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
const classes = cn(buttonVariants({ variant, size, className }));
// asChild 时 Slot 只能有单一子元素,不能夹 loading 图标
if (asChild) {
return (
<Slot
className={classes}
ref={ref}
{...props}
>
{children}
</Slot>
);
}
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
<button
className={classes}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading ? <Loader2 className="animate-spin" /> : null}
{children}
</Comp>
</button>
);
},
);

View File

@@ -7,6 +7,7 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
post_tags_max: 256,
post_content_max: 50000,
comment_max: 5000,
comment_edit_window_hours: 24,
search_keyword_min: 1,
search_keyword_max: 50,
page_size_default: 30,
@@ -15,6 +16,8 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
signature_max: 200,
open_posts_in_new_tab: true,
open_content_links_in_new_tab: true,
permalink_enabled: false,
permalink_ext: 'html',
};
let cached: ForumLimitsPublic | null = null;
@@ -70,3 +73,8 @@ export function invalidateForumLimitsCache() {
cacheEpoch += 1;
listeners.forEach(fn => fn());
}
/** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */
export function getCachedForumLimits(): ForumLimitsPublic {
return cached ?? DEFAULT_LIMITS;
}

View File

@@ -44,6 +44,8 @@ export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, e
const inner = findScrollable(target, e.deltaY, root);
// 主内容区内部嵌套滚动(如 textarea、表情面板保留原生行为
if (inner && inner !== scrollEl && scrollEl.contains(inner)) return;
// 主内容区外的独立滚动区(如右侧目录)保留原生行为,避免滚轮被抢走
if (inner && !scrollEl.contains(inner)) return;
// 鼠标已在主滚动容器上时,交给浏览器原生处理
if (inner === scrollEl) return;

View File

@@ -0,0 +1,154 @@
import { useEffect } from 'react';
import { formatDocumentTitle, getCachedSiteBranding, siteMetaDescription } from './useSiteBranding';
export interface PageSEO {
/** 页面标题(不含站点名);若提供 titleFull 则优先生效 */
title?: string;
/** 完整 document.title */
titleFull?: string;
description?: string;
/** 覆盖站点默认 keywords不传则用品牌配置 */
keywords?: string;
canonicalPath?: string;
ogType?: string;
ogImage?: string;
/** 默认 index私密页传 noindex,nofollow */
robots?: string;
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
}
/** 合并页面与站点关键词(逗号分隔) */
export function joinSEOKeywords(...parts: Array<string | undefined | null>): string {
const seen = new Set<string>();
const out: string[] = [];
for (const part of parts) {
if (!part) continue;
for (const raw of part.replace(/[,、;]/g, ',').split(',')) {
const p = raw.trim();
if (!p || seen.has(p)) continue;
seen.add(p);
out.push(p);
}
}
return out.join(',');
}
const SEO_ATTR = 'data-j13-seo';
function upsertMeta(selector: string, attr: 'name' | 'property', key: string, content: string) {
const head = document.head;
let el = head.querySelector<HTMLMetaElement>(selector);
if (!content) {
el?.remove();
return;
}
if (!el) {
el = document.createElement('meta');
el.setAttribute(attr, key);
head.appendChild(el);
}
el.content = content;
}
function upsertLink(rel: string, href: string) {
const head = document.head;
let el = head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
if (!href) {
el?.remove();
return;
}
if (!el) {
el = document.createElement('link');
el.rel = rel;
head.appendChild(el);
}
el.href = href;
}
function upsertJsonLd(data?: PageSEO['jsonLd']) {
const id = 'j13-jsonld';
document.getElementById(id)?.remove();
if (!data) return;
const script = document.createElement('script');
script.id = id;
script.type = 'application/ld+json';
script.textContent = JSON.stringify(data);
document.head.appendChild(script);
}
function absoluteURL(pathOrURL: string): string {
if (!pathOrURL) return '';
if (/^https?:\/\//i.test(pathOrURL)) return pathOrURL;
return new URL(pathOrURL, window.location.origin).href;
}
/** 客户端路由切换时同步 title / meta / JSON-LD与服务端首屏注入互补 */
export function usePageSEO(seo: PageSEO | null | undefined) {
const jsonLdKey = seo?.jsonLd ? JSON.stringify(seo.jsonLd) : '';
useEffect(() => {
if (!seo) return;
const brand = getCachedSiteBranding();
const siteName = brand.name.trim() || '姜十三论坛';
const title = seo.titleFull?.trim()
|| (seo.title?.trim() ? `${seo.title.trim()} - ${siteName}` : formatDocumentTitle(brand));
document.documentElement.setAttribute(SEO_ATTR, '1');
document.title = title;
const description = (seo.description ?? siteMetaDescription(brand)).trim();
const keywords = (seo.keywords ?? brand.keywords ?? '').trim();
const canonical = absoluteURL(seo.canonicalPath || window.location.pathname);
const ogImage = absoluteURL(seo.ogImage || brand.og_image || brand.logo || brand.favicon || '');
const ogType = seo.ogType || 'website';
const robots = seo.robots || '';
upsertMeta('meta[name="description"]', 'name', 'description', description);
upsertMeta('meta[name="keywords"]', 'name', 'keywords', keywords);
upsertMeta('meta[name="robots"]', 'name', 'robots', robots);
upsertLink('canonical', canonical);
upsertMeta('meta[property="og:type"]', 'property', 'og:type', ogType);
upsertMeta('meta[property="og:site_name"]', 'property', 'og:site_name', siteName);
upsertMeta('meta[property="og:locale"]', 'property', 'og:locale', 'zh_CN');
upsertMeta('meta[property="og:title"]', 'property', 'og:title', title);
upsertMeta('meta[property="og:description"]', 'property', 'og:description', description);
upsertMeta('meta[property="og:url"]', 'property', 'og:url', canonical);
upsertMeta('meta[property="og:image"]', 'property', 'og:image', ogImage);
upsertMeta('meta[name="twitter:card"]', 'name', 'twitter:card', ogImage ? 'summary_large_image' : 'summary');
upsertMeta('meta[name="twitter:title"]', 'name', 'twitter:title', title);
upsertMeta('meta[name="twitter:description"]', 'name', 'twitter:description', description);
upsertMeta('meta[name="twitter:image"]', 'name', 'twitter:image', ogImage);
upsertJsonLd(seo.jsonLd);
return () => {
document.documentElement.removeAttribute(SEO_ATTR);
// 离开页面时恢复站点默认标题;具体 meta 由下一页 usePageSEO 覆盖
document.title = formatDocumentTitle(getCachedSiteBranding());
upsertJsonLd(undefined);
};
// jsonLd 以序列化字符串作为依赖,避免内联对象导致重复执行
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
seo?.title,
seo?.titleFull,
seo?.description,
seo?.keywords,
seo?.canonicalPath,
seo?.ogType,
seo?.ogImage,
seo?.robots,
jsonLdKey,
]);
}
/** 管理 / 登录等私密页一键 noindex */
export function useNoIndexSEO(title: string) {
usePageSEO({
title,
robots: 'noindex,nofollow',
});
}

View File

@@ -4,21 +4,53 @@ import type { SiteBranding } from '../api/types';
export const DEFAULT_BRANDING: SiteBranding = {
name: '姜十三论坛',
name_en: 'Jiang13 Forum',
slogan: '拾三一隅,自在交流',
description: '',
keywords: '',
logo_mark: '姜',
logo: '',
favicon: '',
og_image: '',
icp_beian: '',
icp_beian_url: 'https://beian.miit.gov.cn/',
friend_links: [],
};
let cached: SiteBranding | null = null;
declare global {
interface Window {
/** 服务端注入的首屏品牌配置(见 embed_static SPA HTML */
__J13_BRANDING__?: Partial<SiteBranding>;
}
}
/** SEO / 首页展示用简介:优先 description其次 slogan */
export function siteMetaDescription(brand: SiteBranding): string {
const d = brand.description?.trim() ?? '';
if (d) return d;
return brand.slogan?.trim() ?? '';
}
/** 从服务端注入的 boot 数据同步初始化,避免首屏闪默认站名 */
function readBootBranding(): SiteBranding | null {
try {
const boot = window.__J13_BRANDING__;
if (!boot || typeof boot !== 'object') return null;
const name = typeof boot.name === 'string' ? boot.name.trim() : '';
if (!name) return null;
return { ...DEFAULT_BRANDING, ...boot, name };
} catch {
return null;
}
}
let cached: SiteBranding | null = readBootBranding();
let inflight: Promise<SiteBranding> | null = null;
let cacheEpoch = 0;
const listeners = new Set<() => void>();
function fetchBranding(): Promise<SiteBranding> {
if (cached) return Promise.resolve(cached);
if (inflight) return inflight;
// 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置
inflight = api.siteBranding()
.then(b => {
cached = { ...DEFAULT_BRANDING, ...b };
@@ -37,8 +69,11 @@ export function formatDocumentTitle(brand: SiteBranding): string {
}
function applyDocumentBrand(brand: SiteBranding) {
const title = formatDocumentTitle(brand);
if (document.title !== title) document.title = title;
// 页面级 SEO hook 已接管标题时,勿覆盖
if (!document.documentElement.hasAttribute('data-j13-seo')) {
const title = formatDocumentTitle(brand);
if (document.title !== title) document.title = title;
}
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (brand.favicon) {
@@ -53,6 +88,11 @@ function applyDocumentBrand(brand: SiteBranding) {
}
}
/** 同步读取已缓存的品牌配置(供 SEO 等非 hook 场景) */
export function getCachedSiteBranding(): SiteBranding {
return cached ?? DEFAULT_BRANDING;
}
/** 获取站点品牌配置名称、Logo 等) */
export function useSiteBranding() {
const [branding, setBranding] = useState<SiteBranding>(cached ?? DEFAULT_BRANDING);

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import {
LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft, Moon, Sun, Menu, X,
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { useAuth } from '../hooks/useAuth';
@@ -12,6 +12,7 @@ import { cn } from '@/lib/utils';
import BackToTop from '../components/BackToTop';
import { loginPath } from '../utils/authRedirect';
import { useSiteBranding } from '../hooks/useSiteBranding';
import { useNoIndexSEO } from '../hooks/usePageSEO';
import SiteBrandMark from '../components/SiteBrandMark';
const NAV = [
@@ -19,7 +20,9 @@ const NAV = [
{ to: '/admin/boards', label: '板块管理', icon: FolderKanban },
{ to: '/admin/posts', label: '帖子管理', icon: FileText },
{ to: '/admin/comments', label: '评论管理', icon: MessageSquare },
{ to: '/admin/reports', label: '举报管理', icon: Flag },
{ to: '/admin/users', label: '用户管理', icon: Users },
{ to: '/admin/media', label: '媒体库', icon: Images },
{ to: '/admin/settings', label: '系统设置', icon: Settings },
];
@@ -28,6 +31,7 @@ export default function AdminLayout() {
const { user, loading } = useAuth();
const { theme, toggle } = useTheme();
const { branding } = useSiteBranding();
useNoIndexSEO('管理后台');
const isNarrow = useMediaQuery('(max-width: 768px)');
const [navOpen, setNavOpen] = useState(false);
const nav = useNavigate();

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'rea
import PageLoader from '../components/PageLoader';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X } from 'lucide-react';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,7 +14,7 @@ import { useAuth } from '../hooks/useAuth';
import { useTheme, useMediaQuery } from '../hooks/useTheme';
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
import { api } from '../api/client';
import type { Board, PostItem, RecentComment, ForumStats, TagCount } from '../api/types';
import type { Board, PostItem, RecentComment, ForumStats, TagCount, User } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { getCachedBoards, getCachedStats, getCachedHot, getCachedRecentComments, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedHot, setCachedRecentComments, setCachedTags } from '../utils/layoutCache';
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
@@ -30,6 +30,8 @@ import { loginPath } from '../utils/authRedirect';
import { openForumPost } from '../utils/openPost';
import { useSiteBranding } from '../hooks/useSiteBranding';
import SiteBrandMark from '../components/SiteBrandMark';
import SiteFooter from '../components/SiteFooter';
import { userPath } from '../utils/userPath';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
@@ -46,15 +48,21 @@ export default function MainLayout() {
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
const [hot, setHot] = useState<PostItem[]>(() => getCachedHot());
const [recentComments, setRecentComments] = useState<RecentComment[]>(() => getCachedRecentComments());
const [unreadMessages, setUnreadMessages] = useState(0);
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
const [postOutline, setPostOutline] = useState<{
headings: PostHeading[];
scrollRoot: HTMLElement | null;
title?: string;
author?: User | null;
publishedAt?: string;
viewCount?: number;
} | null>(null);
const [asideOpen, setAsideOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchExpanded, setSearchExpanded] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
const asideEverLoaded = useRef(false);
@@ -92,6 +100,7 @@ export default function MainLayout() {
useEffect(() => {
setAsideOpen(false);
setSidebarOpen(false);
setSearchExpanded(false);
}, [loc.pathname, loc.search]);
useEffect(() => {
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
@@ -100,8 +109,16 @@ export default function MainLayout() {
if (!hideAside) setAsideOpen(false);
}, [hideAside]);
useEffect(() => {
if (!isMobile) setSidebarOpen(false);
if (!isMobile) {
setSidebarOpen(false);
setSearchExpanded(false);
}
}, [isMobile]);
useEffect(() => {
if (!searchExpanded) return;
const t = window.setTimeout(() => searchInputRef.current?.focus(), 50);
return () => window.clearTimeout(t);
}, [searchExpanded]);
useEffect(() => {
if (!asideOpen && !sidebarOpen) return;
const prev = document.body.style.overflow;
@@ -134,27 +151,42 @@ export default function MainLayout() {
return () => window.removeEventListener('boards-refresh', onRefresh);
}, [refreshBoards]);
// 标签云:非编辑页拉取(左侧栏常显)
const refreshUnreadMessages = useCallback(() => {
if (!user) {
setUnreadMessages(0);
return;
}
api.messageUnreadCount()
.then((r) => setUnreadMessages(r.count || 0))
.catch(() => setUnreadMessages(0));
}, [user]);
useEffect(() => {
refreshUnreadMessages();
const onRefresh = () => refreshUnreadMessages();
window.addEventListener('messages-unread-refresh', onRefresh);
const timer = window.setInterval(refreshUnreadMessages, 60_000);
return () => {
window.removeEventListener('messages-unread-refresh', onRefresh);
window.clearInterval(timer);
};
}, [refreshUnreadMessages]);
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/精华等不改标签)
useEffect(() => {
if (isCompose) return;
let cancelled = false;
const loadTags = () => {
if (getCachedTags().length === 0) setTagsLoading(true);
api.tags(40).then(d => {
if (cancelled) return;
const next = Array.isArray(d.tags) ? d.tags : [];
setTags(next);
setCachedTags(next);
}).catch(() => {}).finally(() => {
if (!cancelled) setTagsLoading(false);
});
};
loadTags();
const onRefresh = () => loadTags();
window.addEventListener('posts-refresh', onRefresh);
if (getCachedTags().length === 0) setTagsLoading(true);
api.tags(40).then(d => {
if (cancelled) return;
const next = Array.isArray(d.tags) ? d.tags : [];
setTags(next);
setCachedTags(next);
}).catch(() => {}).finally(() => {
if (!cancelled) setTagsLoading(false);
});
return () => {
cancelled = true;
window.removeEventListener('posts-refresh', onRefresh);
};
}, [isCompose]);
@@ -194,8 +226,10 @@ export default function MainLayout() {
const doSearch = () => {
const kw = keyword.trim();
const active = (params.get('keyword') || '').trim();
if (!kw) {
nav('/');
// 输入已空:仅当 URL 仍带搜索时才回到全部帖子
if (active) navigateFeed(nav, '/');
return;
}
const len = [...kw].length;
@@ -207,24 +241,41 @@ export default function MainLayout() {
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
return;
}
nav(`/?keyword=${encodeURIComponent(kw)}`);
const target = `/?keyword=${encodeURIComponent(kw)}`;
// 相同关键词再次回车:强制刷新,避免命中错误缓存或被当成空导航
if (active === kw && loc.pathname === '/') {
navigateFeed(nav, target);
return;
}
nav(target);
};
const openPost = useCallback((id: number) => {
const openPost = useCallback((id: number, opts?: { floor?: number }) => {
setAsideOpen(false);
openForumPost(nav, id, forumLimits.open_posts_in_new_tab);
openForumPost(nav, id, forumLimits.open_posts_in_new_tab, opts);
}, [nav, forumLimits.open_posts_in_new_tab]);
const userInitial = user?.nickname?.charAt(0) || '?';
const isFeedHome = loc.pathname === '/';
const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) ? -1 : boardId;
const outletKeyword = params.get('keyword') || '';
// 搜索结果页不选中任何板块芯片(避免看起来仍停在「全部」)
const mobileActiveBoard =
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword
? -1
: boardId;
const boardChipIds = useMemo(() => [0, ...boards.map(b => b.id)], [boards]);
const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard));
const outletKeyword = params.get('keyword') || '';
const isPostDetail = /^\/post\/\d+\/?$/.test(loc.pathname);
const setPostOutlineSafe = useCallback((outline: LayoutCtx['postOutline']) => {
const isPostDetail = /^\/post\/\d+/.test(loc.pathname) && !/\/edit$/.test(loc.pathname);
const setPostOutlineSafe = useCallback((outline: {
headings: PostHeading[];
scrollRoot: HTMLElement | null;
title?: string;
author?: User | null;
publishedAt?: string;
viewCount?: number;
} | null) => {
setPostOutline(outline);
}, []);
const layoutCtx = useMemo<LayoutCtx>(() => ({
@@ -257,14 +308,14 @@ export default function MainLayout() {
return (
<div className="app-shell">
<div className="app-frame">
<header className="app-header">
<header className={`app-header${searchExpanded && isMobile ? ' app-header--search-open' : ''}`}>
<div className="header-inner">
{isMobile && !isCompose && (
{isMobile && !isCompose && !searchExpanded && (
<button
type="button"
className="header-icon-btn"
onClick={openSidebar}
aria-label={isPostDetail ? '打开目录与导航' : '打开导航菜单'}
aria-label="打开导航菜单"
aria-expanded={sidebarOpen}
aria-controls="sidebar-drawer"
title="导航"
@@ -272,15 +323,38 @@ export default function MainLayout() {
<Menu size={18} aria-hidden />
</button>
)}
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
<SiteBrandMark branding={branding} className="header-logo-mark" />
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
</button>
{!(isMobile && searchExpanded) && (
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
<SiteBrandMark branding={branding} className="header-logo-mark" />
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
</button>
)}
{!isCompose && (
<div className="header-search-wrap">
{!isCompose && isMobile && !searchExpanded && (
<button
type="button"
className="header-icon-btn header-search-toggle"
onClick={() => setSearchExpanded(true)}
aria-label="搜索帖子"
title="搜索"
>
<Search size={18} aria-hidden />
</button>
)}
{!isCompose && (!isMobile || searchExpanded) && (
<form
className={`header-search-wrap${isMobile && searchExpanded ? ' header-search-wrap--expanded' : ''}`}
role="search"
onSubmit={e => {
e.preventDefault();
doSearch();
if (isMobile) setSearchExpanded(false);
}}
>
<Search className="header-search-icon" size={16} aria-hidden />
<input
ref={searchInputRef}
className="header-search-input"
type="search"
placeholder="搜索帖子..."
@@ -288,19 +362,29 @@ export default function MainLayout() {
value={keyword}
onChange={e => setKeyword(e.target.value)}
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
onKeyDown={e => e.key === 'Enter' && doSearch()}
enterKeyHint="search"
/>
{keyword && (
<button
type="button"
className="header-search-clear"
onClick={() => { setKeyword(''); nav('/'); }}
onClick={() => { setKeyword(''); navigateFeed(nav, '/'); }}
aria-label="清除搜索"
>×</button>
)}
</div>
{isMobile && searchExpanded && (
<button
type="button"
className="header-search-cancel"
onClick={() => setSearchExpanded(false)}
>
</button>
)}
</form>
)}
{!(isMobile && searchExpanded) && (
<div className="header-actions">
{!isCompose && (
<button
@@ -315,33 +399,49 @@ export default function MainLayout() {
)}
<div className="header-action-group">
{!isCompose && hideAside && (
{/* 平板:侧栏收起时用按钮打开社区动态;手机改由导航抽屉入口 */}
{!isCompose && hideAside && !isMobile && (
<button
type="button"
className="header-icon-btn"
onClick={openAside}
aria-label="打开社区动态"
aria-label={isPostDetail ? '打开作者与目录' : '打开社区动态'}
aria-expanded={asideOpen}
aria-controls="aside-drawer"
title="社区动态"
title={isPostDetail ? '作者与目录' : '社区动态'}
>
<PanelRight size={18} aria-hidden />
</button>
)}
<button
type="button"
className="header-icon-btn"
onClick={toggle}
aria-label={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
>
{theme === 'light' ? <Moon size={18} aria-hidden /> : <Sun size={18} aria-hidden />}
</button>
{!isMobile && (
<button
type="button"
className="header-icon-btn"
onClick={toggle}
aria-label={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
>
{theme === 'light' ? <Moon size={18} aria-hidden /> : <Sun size={18} aria-hidden />}
</button>
)}
{authLoading ? (
<span className="header-auth-slot header-auth-slot--loading" aria-hidden />
) : user ? (
<>
<button
type="button"
className="header-icon-btn header-msg-btn"
title={unreadMessages > 0 ? `${unreadMessages} 条未读私信` : '站内私信'}
aria-label={unreadMessages > 0 ? `站内私信,${unreadMessages} 条未读` : '站内私信'}
onClick={() => nav('/messages')}
>
<Mail size={18} aria-hidden />
{unreadMessages > 0 && (
<span className="header-msg-badge">{unreadMessages > 99 ? '99+' : unreadMessages}</span>
)}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="header-user-btn" title={user.nickname} aria-label={`用户菜单:${user.nickname}`}>
@@ -355,9 +455,17 @@ export default function MainLayout() {
className="w-40"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onClick={() => nav(`/user/${user.id}`)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav(userPath(user.id))}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/profile')}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/messages')}>
{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/favorites')}></DropdownMenuItem>
{isMobile && (
<DropdownMenuItem onClick={toggle}>
{theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
</DropdownMenuItem>
)}
{user.role === 'admin' && (
<>
<DropdownMenuSeparator />
@@ -370,6 +478,7 @@ export default function MainLayout() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<button type="button" className="header-login-btn" onClick={() => nav(loginPath())}>
@@ -377,6 +486,7 @@ export default function MainLayout() {
)}
</div>
</div>
)}
</div>
</header>
@@ -387,14 +497,14 @@ export default function MainLayout() {
activeBoard={boardId}
onSelectBoard={setBoardId}
boardsLoading={boardsLoading}
outlineMode={isPostDetail}
outlineHeadings={postOutline?.headings ?? []}
outlineScrollRoot={postOutline?.scrollRoot ?? null}
outlineTitle={postOutline?.title}
/>
)}
<div className={`content-workspace${isCompose ? ' content-workspace--compose' : ''}`}>
<div className={cn(
'content-workspace',
isCompose && 'content-workspace--compose',
hideAside && !isCompose && 'content-workspace--aside-hidden',
)}>
<main className={`main-content${isCompose ? ' main-content--compose' : ''}`}>
{isMobile && !isCompose && isFeedHome && (
<div
@@ -448,11 +558,22 @@ export default function MainLayout() {
tagsLoading={tagsLoading}
loading={asideLoading}
onPostClick={openPost}
postDetail={isPostDetail ? {
author: postOutline?.author ?? null,
publishedAt: postOutline?.publishedAt,
viewCount: postOutline?.viewCount,
headings: postOutline?.headings ?? [],
scrollRoot: postOutline?.scrollRoot ?? null,
outlineTitle: postOutline?.title,
} : null}
/>
</aside>
)}
</div>
</div>
{/* 桌面壳层贴底;手机端由各页 InFlowSiteFooter 随内容滚动 */}
{!isCompose && !isMobile && <SiteFooter />}
</div>
{sidebarOpen && isMobile && !isCompose && (
@@ -470,10 +591,10 @@ export default function MainLayout() {
className="sidebar-drawer"
role="dialog"
aria-modal="true"
aria-label={isPostDetail ? '目录与导航' : '导航菜单'}
aria-label="导航菜单"
>
<div className="aside-drawer-head">
<span>{isPostDetail ? '目录与导航' : '导航'}</span>
<span></span>
<button
ref={sidebarCloseRef}
type="button"
@@ -490,11 +611,25 @@ export default function MainLayout() {
activeBoard={boardId}
onSelectBoard={setBoardId}
boardsLoading={boardsLoading}
outlineMode={isPostDetail}
outlineHeadings={postOutline?.headings ?? []}
outlineScrollRoot={postOutline?.scrollRoot ?? null}
outlineTitle={postOutline?.title}
/>
<div className="sidebar-drawer-extras">
<button
type="button"
className="sidebar-drawer-extra-btn"
onClick={() => { closeSidebar(); openAside(); }}
>
<PanelRight size={16} aria-hidden />
{isPostDetail ? '作者与目录' : '社区动态'}
</button>
<button
type="button"
className="sidebar-drawer-extra-btn"
onClick={toggle}
>
{theme === 'light' ? <Moon size={16} aria-hidden /> : <Sun size={16} aria-hidden />}
{theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
</button>
</div>
</div>
</aside>
</div>
@@ -515,10 +650,10 @@ export default function MainLayout() {
className="aside-drawer"
role="dialog"
aria-modal="true"
aria-label="社区动态"
aria-label={isPostDetail ? '作者与目录' : '社区动态'}
>
<div className="aside-drawer-head">
<span></span>
<span>{isPostDetail ? '作者与目录' : '社区动态'}</span>
<button
ref={asideCloseRef}
type="button"
@@ -537,6 +672,14 @@ export default function MainLayout() {
tagsLoading={tagsLoading}
loading={asideLoading}
onPostClick={openPost}
postDetail={isPostDetail ? {
author: postOutline?.author ?? null,
publishedAt: postOutline?.publishedAt,
viewCount: postOutline?.viewCount,
headings: postOutline?.headings ?? [],
scrollRoot: postOutline?.scrollRoot ?? null,
outlineTitle: postOutline?.title,
} : null}
/>
</div>
</aside>
@@ -556,10 +699,13 @@ export type LayoutCtx = {
stats: ForumStats | null;
refreshBoards: () => void;
isMobile: boolean;
/** 详情页上报文章目录,供侧栏展示 */
/** 详情页上报作者与目录,供侧栏展示 */
setPostOutline: (outline: {
headings: PostHeading[];
scrollRoot: HTMLElement | null;
title?: string;
author?: User | null;
publishedAt?: string;
viewCount?: number;
} | null) => void;
};

View File

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

View File

@@ -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>
);
}

View File

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

View File

@@ -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>
);

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
))}

View 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>
);
}

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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退 80160
</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">

File diff suppressed because it is too large Load Diff

View File

@@ -40,11 +40,11 @@ export function validateAvatarOutput(file: File, maxMb: number): string | null {
return null;
}
/** 将裁剪区域渲染为 JPEG 文件 */
/** 将裁剪区域渲染为 WebP 文件(体积更小;不支持时回退 JPEG */
export async function getCroppedAvatarFile(
imageSrc: string,
pixelCrop: Area,
originalName = 'avatar.jpg',
originalName = 'avatar.webp',
): Promise<File> {
const image = await loadImage(imageSrc);
const canvas = document.createElement('canvas');
@@ -67,14 +67,25 @@ export async function getCroppedAvatarFile(
size,
);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
b => (b ? resolve(b) : reject(new Error('裁剪失败'))),
'image/jpeg',
0.92,
);
});
const tryTypes: { mime: string; quality: number; ext: string }[] = [
{ mime: 'image/webp', quality: 0.86, ext: 'webp' },
{ mime: 'image/jpeg', quality: 0.92, ext: 'jpg' },
];
let blob: Blob | null = null;
let picked = tryTypes[1];
for (const t of tryTypes) {
blob = await new Promise<Blob | null>(resolve => {
canvas.toBlob(b => resolve(b), t.mime, t.quality);
});
if (blob && blob.type === t.mime) {
picked = t;
break;
}
blob = null;
}
if (!blob) throw new Error('裁剪失败');
const baseName = originalName.replace(/\.[^.]+$/, '') || 'avatar';
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' });
return new File([blob], `${baseName}.${picked.ext}`, { type: picked.mime });
}

View File

@@ -22,7 +22,7 @@ export function isGuestComment(c: Comment): boolean {
return !c.user_id || c.user_id === 0;
}
/** 构建嵌套评论树( reply_to */
/** 构建嵌套评论树(优先 thread_parent_id回退 reply_to */
export function buildCommentTree(comments: Comment[]): CommentNode[] {
const map = new Map<number, CommentNode>();
const roots: CommentNode[] = [];
@@ -33,8 +33,9 @@ export function buildCommentTree(comments: Comment[]): CommentNode[] {
for (const c of comments) {
const node = map.get(c.id)!;
if (c.reply_to && map.has(c.reply_to)) {
map.get(c.reply_to)!.children.push(node);
const parentId = c.thread_parent_id ?? c.reply_to;
if (parentId && map.has(parentId)) {
map.get(parentId)!.children.push(node);
} else {
roots.push(node);
}

View File

@@ -15,34 +15,24 @@ export function highlightMentions(text: string, _onClick?: (name: string) => voi
.replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>');
}
/** 相对时间:刚刚 / N分钟前 / N小时前 / N天前更早用具体日期 */
export function formatTime(iso: string) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const now = new Date();
const diff = (now.getTime() - d.getTime()) / 1000;
if (diff < 60) return '刚刚';
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
const diffSec = Math.max(0, (now.getTime() - d.getTime()) / 1000);
if (diffSec < 60) return '刚刚';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}分钟前`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}小时前`;
const pad = (n: number) => String(n).padStart(2, '0');
const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (
d.getFullYear() === yesterday.getFullYear()
&& d.getMonth() === yesterday.getMonth()
&& d.getDate() === yesterday.getDate()
) {
return `昨天 ${clock}`;
}
const diffDay = Math.floor(diffSec / 86400);
if (diffDay < 30) return `${diffDay}天前`;
if (d.getFullYear() === now.getFullYear()) {
return `${d.getMonth() + 1}${d.getDate()} ${clock}`;
return `${d.getMonth() + 1}${d.getDate()}`;
}
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}${clock}`;
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;
}
/** 完整日期时间(用于帖子发布/修改时间展示) */

View File

@@ -1,12 +1,19 @@
import type { NavigateFunction } from 'react-router-dom';
import { postPath, type PermalinkOpts } from './permalink';
export type OpenForumPostOpts = PermalinkOpts & {
/** 跳转到指定楼层(#floor-N */
floor?: number;
};
/** 按站点配置打开帖子详情(当前页跳转或新标签) */
export function openForumPost(
nav: NavigateFunction,
postId: number,
openInNewTab: boolean,
opts?: OpenForumPostOpts,
) {
const path = `/post/${postId}`;
const path = postPath(postId, opts) + (opts?.floor && opts.floor > 0 ? `#floor-${opts.floor}` : '');
if (openInNewTab) {
window.open(path, '_blank', 'noopener,noreferrer');
return;

View File

@@ -0,0 +1,52 @@
import { getCachedForumLimits } from '../hooks/useForumLimits';
export type PermalinkOpts = {
permalink_enabled?: boolean;
permalink_ext?: string;
};
const EXT_RE = /^[a-z0-9]{1,16}$/i;
/** 规范化伪静态后缀(无点) */
export function normalizePermalinkExt(raw?: string): string {
let ext = (raw ?? 'html').trim().replace(/^\./, '').toLowerCase();
if (!ext || !EXT_RE.test(ext)) return 'html';
return ext;
}
function suffix(opts?: PermalinkOpts): string {
const limits = opts ?? getCachedForumLimits();
if (!limits.permalink_enabled) return '';
return `.${normalizePermalinkExt(limits.permalink_ext)}`;
}
/** 帖子规范路径:/post/123 或 /post/123.html */
export function postPath(id: number | string, opts?: PermalinkOpts): string {
return `/post/${id}${suffix(opts)}`;
}
/** 用户规范路径 */
export function userPath(id: number | string, opts?: PermalinkOpts): string {
return `/user/${id}${suffix(opts)}`;
}
/** 从路由参数解析数字 ID兼容 123 / 123.html */
export function parsePermalinkID(raw: string | undefined): number {
if (!raw) return NaN;
const m = String(raw).match(/^(\d+)(?:\.[A-Za-z0-9]{1,16})?$/);
return m ? Number(m[1]) : NaN;
}
/** 客户端:若当前 URL 非规范伪静态路径则返回应跳转的目标 */
export function canonicalRedirectPath(
kind: 'post' | 'user',
id: number,
currentPathname: string,
opts?: PermalinkOpts,
): string | null {
if (!id || Number.isNaN(id)) return null;
const target = kind === 'post' ? postPath(id, opts) : userPath(id, opts);
const cur = currentPathname.replace(/\/$/, '') || '/';
const want = target.replace(/\/$/, '') || '/';
return cur === want ? null : target;
}

View File

@@ -0,0 +1,22 @@
import type { ReportReason, ReportStatus } from '../api/types';
export const REPORT_REASON_OPTIONS: { value: ReportReason; label: string }[] = [
{ value: 'spam', label: '垃圾广告' },
{ value: 'abuse', label: '人身攻击 / 辱骂' },
{ value: 'illegal', label: '违法违规' },
{ value: 'irrelevant', label: '内容无关 / 灌水' },
{ value: 'other', label: '其他' },
];
export function reportReasonLabel(reason: string) {
return REPORT_REASON_OPTIONS.find(o => o.value === reason)?.label ?? reason;
}
export function reportStatusLabel(status: ReportStatus | string) {
switch (status) {
case 'pending': return '待处理';
case 'resolved': return '已处理';
case 'dismissed': return '已忽略';
default: return status;
}
}

View File

@@ -0,0 +1,18 @@
/** 从 HTML 提取纯文本摘要(供页面 description / OG */
export function excerptFromHTML(html: string, max = 160): string {
if (!html) return '';
const doc = new DOMParser().parseFromString(html, 'text/html');
const text = (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
if (text.length <= max) return text;
return `${text.slice(0, Math.max(0, max - 1))}`;
}
/** 正文中第一张图片 URL */
export function firstImageFromHTML(html: string): string {
if (!html) return '';
const doc = new DOMParser().parseFromString(html, 'text/html');
const img = doc.querySelector('img[src]');
const src = img?.getAttribute('src')?.trim() || '';
if (!src || src.startsWith('data:')) return '';
return src;
}

View File

@@ -1,4 +1,6 @@
/** 用户公开主页路径 */
export function userPath(id: number | string): string {
return `/user/${id}`;
import { userPath as permalinkUserPath, type PermalinkOpts } from './permalink';
/** 用户公开主页路径(遵循后台伪静态配置) */
export function userPath(id: number | string, opts?: PermalinkOpts): string {
return permalinkUserPath(id, opts);
}