新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -30,6 +30,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
@@ -3043,6 +3044,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "11.11.1",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
|
||||
@@ -23,6 +23,7 @@ const ComposePage = lazy(() => import('./pages/ComposePage'));
|
||||
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
|
||||
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
|
||||
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'));
|
||||
@@ -32,8 +33,8 @@ const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<>
|
||||
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
|
||||
<Route path="/login" element={<Suspense fallback={<PageLoader fullScreen />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<PageLoader fullScreen />}><RegisterPage /></Suspense>} />
|
||||
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
@@ -51,6 +52,7 @@ const router = createBrowserRouter(
|
||||
<Route path="/compose" element={<ComposePage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/favorites" element={<FavoritesPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
</Route>
|
||||
</>,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision } from './types';
|
||||
import type { User, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, SiteBranding, RegisterConfig } from './types';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
@@ -25,12 +25,23 @@ export const api = {
|
||||
me: () => request<{ user: User | null }>('/api/me'),
|
||||
stats: () => request<ForumStats>('/api/stats'),
|
||||
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
|
||||
siteBranding: () => request<SiteBranding>('/api/site-branding'),
|
||||
boards: () => request<{ boards: Board[] }>('/api/boards'),
|
||||
projects: (params?: { page?: number; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.limit) q.set('limit', String(params.limit));
|
||||
const qs = q.toString();
|
||||
return request<{ projects: GiteaProject[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/projects${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
posts: (params: Record<string, string | number>) => {
|
||||
const q = new URLSearchParams(params as Record<string, string>).toString();
|
||||
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
|
||||
},
|
||||
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
|
||||
tags: (limit = 40) => request<{ tags: TagCount[] }>(`/api/tags?limit=${limit}`),
|
||||
post: (id: number, opts?: { skipView?: boolean }) => {
|
||||
const q = opts?.skipView ? '?skip_view=1' : '';
|
||||
return request<PostDetailResponse>(`/api/posts/${id}${q}`);
|
||||
@@ -39,9 +50,7 @@ export const api = {
|
||||
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
|
||||
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
|
||||
},
|
||||
notifications: () => request<{ notifications: Notification[] }>('/api/notifications'),
|
||||
online: () => request<OnlineStats>('/api/online'),
|
||||
presence: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/presence', { method: 'POST' }),
|
||||
recentComments: () => request<{ comments: RecentComment[] }>('/api/comments/recent'),
|
||||
favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'),
|
||||
createBoard: (body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
|
||||
request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -72,6 +81,57 @@ export const api = {
|
||||
request<{ message: string; limits: ForumLimits }>('/api/admin/settings/forum', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateMailSettings: (body: MailConfig) =>
|
||||
request<{ message: string; mail: MailConfig }>('/api/admin/settings/mail', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateOIDCSettings: (body: OIDCConfig) =>
|
||||
request<{ message: string; oidc: OIDCConfig }>('/api/admin/settings/oidc', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateGiteaSettings: (body: GiteaSyncConfig) =>
|
||||
request<{ message: string; gitea: GiteaSyncConfig }>('/api/admin/settings/gitea', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminSyncGitea: () =>
|
||||
request<{ message: string; count: number; gitea: GiteaSyncConfig }>('/api/admin/settings/gitea/sync', {
|
||||
method: 'POST',
|
||||
}),
|
||||
adminUpdateBranding: (body: SiteBranding) =>
|
||||
request<{ message: string; branding: SiteBranding }>('/api/admin/settings/branding', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUploadBrandingAsset: (kind: 'logo' | 'favicon', file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('kind', kind);
|
||||
fd.append('file', file);
|
||||
return request<{ message: string; url: string; branding: SiteBranding }>(
|
||||
'/api/admin/settings/branding/upload',
|
||||
{ method: 'POST', body: fd, headers: {} },
|
||||
);
|
||||
},
|
||||
adminClearBrandingAsset: (kind: 'logo' | 'favicon') =>
|
||||
request<{ message: string; branding: SiteBranding }>('/api/admin/settings/branding/clear', {
|
||||
method: 'POST', body: JSON.stringify({ kind }),
|
||||
}),
|
||||
adminListOAuthClients: () =>
|
||||
request<{ clients: OAuthClient[] }>('/api/admin/oauth/clients'),
|
||||
adminCreateOAuthClient: (body: OAuthClientInput) =>
|
||||
request<{ message: string; client: OAuthClient; oidc: OIDCConfig }>('/api/admin/oauth/clients', {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateOAuthClient: (id: number, body: OAuthClientInput) =>
|
||||
request<{ message: string; client: OAuthClient; oidc: OIDCConfig }>(`/api/admin/oauth/clients/${id}`, {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminDeleteOAuthClient: (id: number) =>
|
||||
request<{ message: string; oidc: OIDCConfig }>(`/api/admin/oauth/clients/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
adminTestMail: (to: string) =>
|
||||
request<{ message: string }>('/api/admin/settings/mail/test', {
|
||||
method: 'POST', body: JSON.stringify({ to }),
|
||||
}),
|
||||
adminUpdateFilterWords: (content: string) =>
|
||||
request<{ message: string; word_count: number }>('/api/admin/settings/filter-words', {
|
||||
method: 'PUT', body: JSON.stringify({ content }),
|
||||
@@ -132,19 +192,35 @@ export const api = {
|
||||
fd.append('tags', data.tags || '');
|
||||
return request<{ message: string }>(`/api/posts/${id}`, { method: 'PUT', body: fd, headers: {} });
|
||||
},
|
||||
deletePost: (id: number) => request<{ message: string }>(`/api/posts/${id}`, { method: 'DELETE' }),
|
||||
login: (username: string, password: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('username', username);
|
||||
fd.append('password', password);
|
||||
return request('/api/login', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
register: (username: string, password: string, nickname: string) => {
|
||||
register: (data: {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
emailCode?: string;
|
||||
}) => {
|
||||
const fd = new FormData();
|
||||
fd.append('username', username);
|
||||
fd.append('password', password);
|
||||
fd.append('nickname', nickname);
|
||||
fd.append('username', data.username);
|
||||
fd.append('password', data.password);
|
||||
fd.append('nickname', data.nickname);
|
||||
fd.append('email', data.email);
|
||||
if (data.emailCode) fd.append('email_code', data.emailCode);
|
||||
return request('/api/register', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
registerConfig: () => request<RegisterConfig>('/api/register/config'),
|
||||
sendRegisterEmailCode: (email: string) =>
|
||||
request<{ message: string }>('/api/register/email-code', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
captcha: () => request<{ id: string; image: string }>('/api/captcha'),
|
||||
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' }),
|
||||
@@ -165,5 +241,10 @@ export const api = {
|
||||
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: {} });
|
||||
},
|
||||
ping: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/ping', { method: 'POST' }),
|
||||
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: {} });
|
||||
},
|
||||
deleteComment: (id: number) => request<{ message: string }>(`/api/comments/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
role: 'user' | 'admin';
|
||||
banned?: boolean;
|
||||
banned_at?: string;
|
||||
last_login_at?: string;
|
||||
last_login_ip?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
@@ -24,6 +29,12 @@ export interface ForumStats {
|
||||
boards: number;
|
||||
}
|
||||
|
||||
/** 标签云单项 */
|
||||
export interface TagCount {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PostItem {
|
||||
id: number;
|
||||
board_id: number;
|
||||
@@ -78,6 +89,7 @@ export interface Comment {
|
||||
is_private?: boolean;
|
||||
content_hidden?: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
user?: User;
|
||||
post?: PostItem;
|
||||
reply_target?: Comment;
|
||||
@@ -88,7 +100,6 @@ export interface AdminDashboard {
|
||||
posts: number;
|
||||
boards: number;
|
||||
comments: number;
|
||||
online: number;
|
||||
recent_posts: PostItem[];
|
||||
}
|
||||
|
||||
@@ -106,11 +117,10 @@ export interface ForumLimits {
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
page_size_max: number;
|
||||
feed_max_pages: number;
|
||||
feed_max_items: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
open_posts_in_new_tab: boolean;
|
||||
open_content_links_in_new_tab: boolean;
|
||||
}
|
||||
|
||||
export interface ForumLimitsPublic {
|
||||
@@ -121,10 +131,19 @@ export interface ForumLimitsPublic {
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
feed_max_pages: number;
|
||||
feed_max_items: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
open_posts_in_new_tab: boolean;
|
||||
open_content_links_in_new_tab: boolean;
|
||||
}
|
||||
|
||||
export interface SiteBranding {
|
||||
name: string;
|
||||
name_en: string;
|
||||
slogan: string;
|
||||
logo_mark: string;
|
||||
logo: string;
|
||||
favicon: string;
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
@@ -133,10 +152,91 @@ export interface AdminSettings {
|
||||
db_path: string;
|
||||
port: number;
|
||||
limits: ForumLimits;
|
||||
mail: MailConfig;
|
||||
oidc: OIDCConfig;
|
||||
oauth_clients: OAuthClient[];
|
||||
gitea?: GiteaSyncConfig;
|
||||
branding?: SiteBranding;
|
||||
filter_words: string;
|
||||
filter_word_count: number;
|
||||
}
|
||||
|
||||
export interface MailConfig {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
from: string;
|
||||
from_name: string;
|
||||
encryption: 'none' | 'starttls' | 'ssl';
|
||||
has_password: boolean;
|
||||
}
|
||||
|
||||
export interface OIDCConfig {
|
||||
enabled: boolean;
|
||||
root_url: string;
|
||||
ready: boolean;
|
||||
discovery_url?: string;
|
||||
authorize_url?: string;
|
||||
logout_url?: string;
|
||||
group_claim: string;
|
||||
admin_group: string;
|
||||
user_group: string;
|
||||
client_count: number;
|
||||
}
|
||||
|
||||
export interface OAuthClient {
|
||||
id: number;
|
||||
client_id: string;
|
||||
name: string;
|
||||
redirect_uris: string;
|
||||
enabled: boolean;
|
||||
has_secret: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
client_secret?: string;
|
||||
}
|
||||
|
||||
export interface OAuthClientInput {
|
||||
client_id?: string;
|
||||
name: string;
|
||||
redirect_uris: string;
|
||||
enabled?: boolean;
|
||||
client_secret?: string;
|
||||
rotate_secret?: boolean;
|
||||
}
|
||||
|
||||
export interface GiteaProject {
|
||||
id: number;
|
||||
gitea_id: number;
|
||||
owner_login: string;
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string;
|
||||
html_url: string;
|
||||
updated_at_remote?: string | null;
|
||||
forum_user_id?: number;
|
||||
synced_at: string;
|
||||
}
|
||||
|
||||
export interface GiteaSyncConfig {
|
||||
enabled: boolean;
|
||||
base_url: string;
|
||||
token?: string;
|
||||
has_token: boolean;
|
||||
sync_interval_min: number;
|
||||
ready: boolean;
|
||||
repo_count: number;
|
||||
}
|
||||
|
||||
export interface RegisterConfig {
|
||||
is_first_user: boolean;
|
||||
mail_ready: boolean;
|
||||
require_email_code: boolean;
|
||||
register_open: boolean;
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
total: number;
|
||||
page: number;
|
||||
@@ -144,22 +244,12 @@ export interface Paginated<T> {
|
||||
items: T;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
export interface RecentComment {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
post_id: number;
|
||||
author: string;
|
||||
avatar: string;
|
||||
excerpt: string;
|
||||
post_title: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface OnlineUser {
|
||||
id: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface OnlineStats {
|
||||
count: number;
|
||||
members: number;
|
||||
guests: number;
|
||||
users: OnlineUser[];
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
FileCode, PenLine, Maximize2, Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||
import PostContent from './PostContent';
|
||||
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
|
||||
import {
|
||||
wrapMarkdownSelection,
|
||||
@@ -362,7 +362,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
}, [markdownSource, handleMarkdownChange]);
|
||||
|
||||
const markdownPreviewHtml = useMemo(
|
||||
() => renderPostContentHtml(sanitizeHtml(markdownToHtml(markdownSource)), true),
|
||||
() => sanitizeHtml(markdownToHtml(markdownSource)),
|
||||
[markdownSource],
|
||||
);
|
||||
|
||||
@@ -384,7 +384,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '独立输入区;Ctrl+Enter 退出',
|
||||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
@@ -451,9 +451,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
</div>
|
||||
<div className="article-editor-markdown-preview">
|
||||
<div className="article-editor-markdown-preview-label">预览</div>
|
||||
<div
|
||||
<PostContent
|
||||
html={markdownPreviewHtml}
|
||||
isLoggedIn
|
||||
className="article-editor-markdown-preview-body post-detail-content"
|
||||
dangerouslySetInnerHTML={{ __html: markdownPreviewHtml }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
149
frontend/src/components/ArticleOutline.tsx
Normal file
149
frontend/src/components/ArticleOutline.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ListTree } from 'lucide-react';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
headings: PostHeading[];
|
||||
/** 滚动容器;不传则用 viewport */
|
||||
scrollRoot?: HTMLElement | null;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 根据滚动位置取当前应高亮的标题 id */
|
||||
function resolveActiveHeadingId(
|
||||
headings: PostHeading[],
|
||||
root: HTMLElement | null,
|
||||
offsetPx = 28,
|
||||
): string {
|
||||
if (headings.length === 0) return '';
|
||||
|
||||
const rootTop = root ? root.getBoundingClientRect().top : 0;
|
||||
const marker = rootTop + offsetPx;
|
||||
|
||||
let current = headings[0].id;
|
||||
for (const h of headings) {
|
||||
const el = document.getElementById(h.id);
|
||||
if (!el) continue;
|
||||
if (el.getBoundingClientRect().top <= marker) {
|
||||
current = h.id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** 文章目录树:点击跳转,滚动时高亮当前标题 */
|
||||
export default function ArticleOutline({
|
||||
headings,
|
||||
scrollRoot,
|
||||
title = '文章目录',
|
||||
className,
|
||||
}: Props) {
|
||||
const [activeId, setActiveId] = useState(headings[0]?.id ?? '');
|
||||
/** 点击跳转期间锁定高亮,避免 Intersection/滚动回调来回抢 */
|
||||
const lockUntilRef = useRef(0);
|
||||
const lockIdRef = useRef('');
|
||||
const rafRef = useRef(0);
|
||||
|
||||
const minLevel = useMemo(
|
||||
() => (headings.length ? Math.min(...headings.map(h => h.level)) : 2),
|
||||
[headings],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(headings[0]?.id ?? '');
|
||||
lockUntilRef.current = 0;
|
||||
lockIdRef.current = '';
|
||||
}, [headings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (headings.length === 0) return undefined;
|
||||
|
||||
const root: HTMLElement | Window = scrollRoot ?? window;
|
||||
|
||||
const syncActive = () => {
|
||||
if (Date.now() < lockUntilRef.current) {
|
||||
if (lockIdRef.current) setActiveId(lockIdRef.current);
|
||||
return;
|
||||
}
|
||||
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
|
||||
if (next) setActiveId(next);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(syncActive);
|
||||
};
|
||||
|
||||
syncActive();
|
||||
root.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
root.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [headings, scrollRoot]);
|
||||
|
||||
const jumpTo = (id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
|
||||
// 立即高亮并锁定一段时间,覆盖 smooth 滚动过程中的中间态
|
||||
setActiveId(id);
|
||||
lockIdRef.current = id;
|
||||
lockUntilRef.current = Date.now() + 900;
|
||||
|
||||
const root = scrollRoot;
|
||||
if (root) {
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
|
||||
root.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// 滚动结束后再按位置校正一次(若用户中途手动滑会自然解锁)
|
||||
window.setTimeout(() => {
|
||||
if (lockIdRef.current !== id) return;
|
||||
lockUntilRef.current = 0;
|
||||
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
|
||||
if (next) setActiveId(next);
|
||||
}, 920);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('article-outline', className)}>
|
||||
<div className="sidebar-section article-outline-head">
|
||||
<ListTree size={12} aria-hidden />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{headings.length === 0 ? (
|
||||
<p className="article-outline-empty">本文暂无标题结构</p>
|
||||
) : (
|
||||
<nav className="article-outline-nav" aria-label="文章目录">
|
||||
{headings.map(h => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'article-outline-item',
|
||||
`article-outline-item--l${Math.min(6, Math.max(1, h.level - minLevel + 1))}`,
|
||||
activeId === h.id && 'active',
|
||||
)}
|
||||
onClick={() => jumpTo(h.id)}
|
||||
title={h.text}
|
||||
>
|
||||
{h.text}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import { Clock, MessageSquare, X } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Clock, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Comment } from '../api/types';
|
||||
import type { Comment, User } from '../api/types';
|
||||
import CommentContent from './CommentContent';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
commentNick,
|
||||
commentInitial,
|
||||
@@ -10,33 +22,75 @@ import {
|
||||
buildCommentTree,
|
||||
type CommentNode,
|
||||
} from '../utils/comment';
|
||||
import { isTimeDiffSignificant } from '../utils/content';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
|
||||
function canManageComment(c: Comment, user?: User | null): boolean {
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
return c.user_id > 0 && c.user_id === user.id;
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
node: CommentNode;
|
||||
nested?: boolean;
|
||||
highlightFloor?: number | null;
|
||||
replyToId?: number | null;
|
||||
editingId?: number | null;
|
||||
currentUser?: User | null;
|
||||
onReply: (comment: Comment) => void;
|
||||
onCancelReply: () => void;
|
||||
onStartEdit: (comment: Comment) => void;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||
onDelete: (comment: Comment) => Promise<void>;
|
||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||
}
|
||||
|
||||
/** 单条评论(支持嵌套子回复 + 内联回复框) */
|
||||
/** 单条评论(支持嵌套子回复 + 内联回复框 + 编辑/删除) */
|
||||
function CommentItem({
|
||||
node,
|
||||
nested,
|
||||
highlightFloor,
|
||||
replyToId,
|
||||
editingId,
|
||||
currentUser,
|
||||
onReply,
|
||||
onCancelReply,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
renderReplyBox,
|
||||
}: ItemProps) {
|
||||
const { limits } = useForumLimits();
|
||||
const c = node.comment;
|
||||
const nick = commentNick(c);
|
||||
const guest = isGuestComment(c);
|
||||
const isHighlighted = highlightFloor === c.floor;
|
||||
const hidden = !!c.content_hidden;
|
||||
const isReplying = replyToId === c.id;
|
||||
const isEditing = editingId === c.id;
|
||||
const manageable = canManageComment(c, currentUser);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) setEditText(c.content);
|
||||
}, [isEditing, c.content, c.id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const next = editText.trim();
|
||||
if (!next) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSaveEdit(c, next);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -66,6 +120,29 @@ function CommentItem({
|
||||
<div className="waline-comment-private-mask">
|
||||
该评论为私密评论,仅文章作者与评论发起者可见!
|
||||
</div>
|
||||
) : isEditing ? (
|
||||
<div className="waline-comment-edit">
|
||||
<textarea
|
||||
className="waline-comment-edit-input"
|
||||
value={editText}
|
||||
onChange={e => setEditText(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={limits.comment_max > 0 ? limits.comment_max : undefined}
|
||||
/>
|
||||
<div className="waline-comment-edit-actions">
|
||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelEdit} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="waline-comment-reply-btn"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !editText.trim()}
|
||||
>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="waline-comment-bubble">
|
||||
{c.reply_target && (
|
||||
@@ -79,18 +156,58 @@ function CommentItem({
|
||||
<span className="waline-comment-date">
|
||||
<Clock size={14} />
|
||||
{formatCommentDate(c.created_at)}
|
||||
{showEdited && <span className="waline-comment-edited"> · 已编辑</span>}
|
||||
</span>
|
||||
{isReplying ? (
|
||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
||||
<X size={14} />
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
|
||||
<MessageSquare size={14} />
|
||||
回复
|
||||
{!hidden && !isEditing && (
|
||||
isReplying ? (
|
||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
||||
<X size={14} />
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
|
||||
<MessageSquare size={14} />
|
||||
回复
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{!hidden && !isEditing && manageable && (
|
||||
<button type="button" className="waline-comment-reply-btn" onClick={() => onStartEdit(c)}>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
{!hidden && !isEditing && manageable && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button type="button" className="waline-comment-reply-btn cancel" disabled={deleting}>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||
<AlertDialogDescription>删除后不可恢复。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete(c);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isReplying && renderReplyBox && (
|
||||
@@ -108,8 +225,14 @@ function CommentItem({
|
||||
nested
|
||||
highlightFloor={highlightFloor}
|
||||
replyToId={replyToId}
|
||||
editingId={editingId}
|
||||
currentUser={currentUser}
|
||||
onReply={onReply}
|
||||
onCancelReply={onCancelReply}
|
||||
onStartEdit={onStartEdit}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
@@ -124,8 +247,14 @@ interface Props {
|
||||
comments: Comment[];
|
||||
highlightFloor?: number | null;
|
||||
replyToId?: number | null;
|
||||
editingId?: number | null;
|
||||
currentUser?: User | null;
|
||||
onReply: (comment: Comment) => void;
|
||||
onCancelReply: () => void;
|
||||
onStartEdit: (comment: Comment) => void;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||
onDelete: (comment: Comment) => Promise<void>;
|
||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -134,8 +263,14 @@ export default function CommentThreadList({
|
||||
comments,
|
||||
highlightFloor,
|
||||
replyToId,
|
||||
editingId,
|
||||
currentUser,
|
||||
onReply,
|
||||
onCancelReply,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
renderReplyBox,
|
||||
}: Props) {
|
||||
const tree = buildCommentTree(comments);
|
||||
@@ -148,8 +283,14 @@ export default function CommentThreadList({
|
||||
node={node}
|
||||
highlightFloor={highlightFloor}
|
||||
replyToId={replyToId}
|
||||
editingId={editingId}
|
||||
currentUser={currentUser}
|
||||
onReply={onReply}
|
||||
onCancelReply={onCancelReply}
|
||||
onStartEdit={onStartEdit}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
|
||||
34
frontend/src/components/FeedPageSkeleton.tsx
Normal file
34
frontend/src/components/FeedPageSkeleton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import PostListSkeleton from './PostListSkeleton';
|
||||
|
||||
/** 首页 Feed 初始骨架(标题区 + 排序栏 + 列表) */
|
||||
export default function FeedPageSkeleton() {
|
||||
return (
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div className="post-list-scroll">
|
||||
<PostListSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
frontend/src/components/FeedPagination.tsx
Normal file
149
frontend/src/components/FeedPagination.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
postTotal: number;
|
||||
loading?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
/** 生成页码窗口:两端 + 当前邻页,中间用省略号 */
|
||||
function buildPageItems(current: number, total: number): Array<number | 'gap'> {
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const set = new Set<number>();
|
||||
set.add(1);
|
||||
set.add(total);
|
||||
for (let i = current - 1; i <= current + 1; i++) {
|
||||
if (i >= 1 && i <= total) set.add(i);
|
||||
}
|
||||
// 靠近端点时多露出几页,避免 1 … 2 3 这种浪费
|
||||
if (current <= 3) {
|
||||
set.add(2);
|
||||
set.add(3);
|
||||
set.add(4);
|
||||
}
|
||||
if (current >= total - 2) {
|
||||
set.add(total - 1);
|
||||
set.add(total - 2);
|
||||
set.add(total - 3);
|
||||
}
|
||||
|
||||
const sorted = [...set].sort((a, b) => a - b);
|
||||
const items: Array<number | 'gap'> = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) items.push('gap');
|
||||
items.push(sorted[i]);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export default function FeedPagination({
|
||||
page,
|
||||
totalPages,
|
||||
postTotal,
|
||||
loading = false,
|
||||
onPageChange,
|
||||
}: Props) {
|
||||
const [jumpInput, setJumpInput] = useState(String(page));
|
||||
const pageItems = buildPageItems(page, totalPages);
|
||||
const showJump = totalPages > 5;
|
||||
|
||||
useEffect(() => {
|
||||
setJumpInput(String(page));
|
||||
}, [page]);
|
||||
|
||||
const commitJump = () => {
|
||||
if (loading) return;
|
||||
const n = Number.parseInt(jumpInput, 10);
|
||||
if (!Number.isFinite(n)) {
|
||||
setJumpInput(String(page));
|
||||
return;
|
||||
}
|
||||
const target = Math.min(totalPages, Math.max(1, n));
|
||||
setJumpInput(String(target));
|
||||
if (target !== page) onPageChange(target);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="feed-pagination" aria-label="帖子分页">
|
||||
<p className="feed-pagination__meta" aria-live="polite">
|
||||
共 <strong>{postTotal}</strong> 条
|
||||
</p>
|
||||
|
||||
<div className="feed-pagination__pages">
|
||||
<button
|
||||
type="button"
|
||||
className="feed-pagination__nav"
|
||||
disabled={loading || page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeft aria-hidden size={16} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{pageItems.map((item, idx) =>
|
||||
item === 'gap' ? (
|
||||
<span key={`gap-${idx}`} className="feed-pagination__gap" aria-hidden>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={cn('feed-pagination__page', item === page && 'is-active')}
|
||||
disabled={loading || item === page}
|
||||
aria-label={`第 ${item} 页`}
|
||||
aria-current={item === page ? 'page' : undefined}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="feed-pagination__nav"
|
||||
disabled={loading || page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRight aria-hidden size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showJump && (
|
||||
<form
|
||||
className="feed-pagination__jump"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
commitJump();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="feed-page-jump" className="feed-pagination__jump-label">
|
||||
跳至
|
||||
</label>
|
||||
<input
|
||||
id="feed-page-jump"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={jumpInput}
|
||||
disabled={loading}
|
||||
onChange={(e) => setJumpInput(e.target.value.replace(/\D/g, ''))}
|
||||
onBlur={commitJump}
|
||||
className="feed-pagination__jump-input"
|
||||
aria-label={`跳转到指定页,共 ${totalPages} 页`}
|
||||
/>
|
||||
<span className="feed-pagination__jump-suffix">/ {totalPages}</span>
|
||||
</form>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
/** 路由懒加载时的轻量占位,避免引入 Arco Spin 增大首屏 */
|
||||
export default function PageLoader() {
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type PageLoaderProps = {
|
||||
/** 独立全屏路由(登录/注册)占满视口居中 */
|
||||
fullScreen?: boolean;
|
||||
};
|
||||
|
||||
/** 通用路由懒加载占位;首页请用 FeedPageSkeleton,避免非 Feed 页闪出鱼骨骨架 */
|
||||
export default function PageLoader({ fullScreen = false }: PageLoaderProps) {
|
||||
return (
|
||||
<div className="page-loader" role="status" aria-live="polite">
|
||||
<span className="page-loader__dot" />
|
||||
加载中…
|
||||
<div
|
||||
className={cn('page-loader', fullScreen && 'page-loader--viewport')}
|
||||
aria-busy="true"
|
||||
aria-label="加载中"
|
||||
>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,72 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useMemo, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
|
||||
import { loginPath, registerPath } from '../utils/authRedirect';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { notify } from '@/lib/notify';
|
||||
|
||||
interface Props {
|
||||
html: string;
|
||||
isLoggedIn: boolean;
|
||||
className?: string;
|
||||
/** 正文标题树变化时回调(用于侧栏目录) */
|
||||
onHeadingsChange?: (headings: PostHeading[]) => void;
|
||||
}
|
||||
|
||||
/** 帖子正文渲染(含会员专属区块) */
|
||||
export default function PostContent({ html, isLoggedIn, className = 'post-detail-content' }: Props) {
|
||||
/** 帖子正文渲染(含会员专属区块、代码块美化) */
|
||||
export default function PostContent({
|
||||
html,
|
||||
isLoggedIn,
|
||||
className = 'post-detail-content',
|
||||
onHeadingsChange,
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const { limits } = useForumLimits();
|
||||
|
||||
const rendered = useMemo(
|
||||
() => renderPostContentHtml(html, isLoggedIn),
|
||||
[html, isLoggedIn],
|
||||
);
|
||||
const prepared = useMemo(() => {
|
||||
const rendered = renderPostContentHtml(html, isLoggedIn, {
|
||||
openLinksInNewTab: limits.open_content_links_in_new_tab,
|
||||
});
|
||||
return {
|
||||
html: rendered,
|
||||
headings: extractHeadingsFromHtml(rendered),
|
||||
};
|
||||
}, [html, isLoggedIn, limits.open_content_links_in_new_tab]);
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
useEffect(() => {
|
||||
onHeadingsChange?.(prepared.headings);
|
||||
}, [prepared.headings, onHeadingsChange]);
|
||||
|
||||
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-members-login]')) {
|
||||
e.preventDefault();
|
||||
nav('/login');
|
||||
nav(loginPath());
|
||||
return;
|
||||
}
|
||||
if (target.closest('[data-members-register]')) {
|
||||
e.preventDefault();
|
||||
nav('/register');
|
||||
nav(registerPath());
|
||||
return;
|
||||
}
|
||||
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
|
||||
if (copyBtn) {
|
||||
e.preventDefault();
|
||||
const block = copyBtn.closest('.md-codeblock');
|
||||
const text = block?.querySelector('pre')?.textContent ?? '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const prev = copyBtn.textContent;
|
||||
copyBtn.textContent = '已复制';
|
||||
copyBtn.classList.add('is-copied');
|
||||
window.setTimeout(() => {
|
||||
copyBtn.textContent = prev || '复制';
|
||||
copyBtn.classList.remove('is-copied');
|
||||
}, 1600);
|
||||
} catch {
|
||||
notify.error('复制失败');
|
||||
}
|
||||
}
|
||||
}, [nav]);
|
||||
|
||||
@@ -34,7 +74,7 @@ export default function PostContent({ html, isLoggedIn, className = 'post-detail
|
||||
<div
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
dangerouslySetInnerHTML={{ __html: rendered }}
|
||||
dangerouslySetInnerHTML={{ __html: prepared.html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { MessageCircle, ThumbsUp } from 'lucide-react';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
@@ -8,10 +9,10 @@ import { formatTime } from '../utils/content';
|
||||
interface Props {
|
||||
post: PostItem;
|
||||
sort?: FeedSort;
|
||||
onClick: () => void;
|
||||
onSelect: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function PostListItem({ post, sort = 'latest', onClick }: Props) {
|
||||
function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
const initial = post.user?.nickname?.[0] || '?';
|
||||
const timeLabel = sort === 'reply'
|
||||
? (post.last_reply_at
|
||||
@@ -22,7 +23,7 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
|
||||
const likeCount = post.like_count ?? 0;
|
||||
|
||||
return (
|
||||
<button type="button" className="post-row" onClick={onClick}>
|
||||
<button type="button" className="post-row" onClick={() => onSelect(post.id)}>
|
||||
<div className="post-avatar">
|
||||
{post.user?.avatar
|
||||
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
@@ -52,3 +53,5 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(PostListItem);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Flame, Megaphone, Users } from 'lucide-react';
|
||||
import type { PostItem, Notification, OnlineStats } from '../api/types';
|
||||
import { Flame, MessageCircle, Tags } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { PostItem, RecentComment, TagCount } from '../api/types';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import TagCloud from './TagCloud';
|
||||
|
||||
interface Props {
|
||||
hot: PostItem[];
|
||||
notifications: Notification[];
|
||||
online: OnlineStats | null;
|
||||
recentComments: RecentComment[];
|
||||
tags?: TagCount[];
|
||||
tagsLoading?: boolean;
|
||||
onPostClick: (id: number) => void;
|
||||
/** 首次拉取中,避免空态闪烁 */
|
||||
/** 首次拉取中,显示骨架避免空态闪烁 */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -17,16 +22,46 @@ function hotRankClass(index: number): string {
|
||||
return 'widget-rank';
|
||||
}
|
||||
|
||||
function HotSkeleton() {
|
||||
return (
|
||||
<div className="widget-skeleton" aria-busy="true" aria-label="热门加载中">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="widget-item widget-item--skeleton">
|
||||
<Skeleton className="skeleton--widget-rank" />
|
||||
<Skeleton className="skeleton--widget-title" style={{ width: `${62 + (i % 4) * 8}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentSkeleton() {
|
||||
return (
|
||||
<div className="widget-skeleton" aria-busy="true" aria-label="评论加载中">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<div key={i} className="widget-item widget-item--comment widget-item--skeleton">
|
||||
<Skeleton className="skeleton--widget-avatar" />
|
||||
<Skeleton className="skeleton--widget-title" style={{ width: `${55 + (i % 3) * 12}%` }} />
|
||||
<Skeleton className="skeleton--widget-time" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightPanel({
|
||||
hot,
|
||||
notifications,
|
||||
online,
|
||||
recentComments,
|
||||
tags = [],
|
||||
tagsLoading = false,
|
||||
onPostClick,
|
||||
loading = false,
|
||||
}: Props) {
|
||||
const { branding } = useSiteBranding();
|
||||
const [params] = useSearchParams();
|
||||
const activeTag = params.get('keyword') || '';
|
||||
const hotList = hot?.slice(0, 8) ?? [];
|
||||
const noticeList = notifications?.slice(0, 6) ?? [];
|
||||
const members = online?.users ?? [];
|
||||
const commentList = recentComments?.slice(0, 6) ?? [];
|
||||
|
||||
return (
|
||||
<div className="aside-panel-inner">
|
||||
@@ -37,7 +72,7 @@ export default function RightPanel({
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{loading && hotList.length === 0 ? (
|
||||
<div className="widget-empty">加载中…</div>
|
||||
<HotSkeleton />
|
||||
) : hotList.length === 0 ? (
|
||||
<div className="widget-empty">暂无数据</div>
|
||||
) : hotList.map((item, i) => (
|
||||
@@ -54,65 +89,53 @@ export default function RightPanel({
|
||||
</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">
|
||||
<Megaphone className="widget-card-icon widget-card-icon--notice" aria-hidden />
|
||||
最新动态
|
||||
<MessageCircle className="widget-card-icon widget-card-icon--notice" aria-hidden />
|
||||
最新评论
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{loading && noticeList.length === 0 ? (
|
||||
<div className="widget-empty">加载中…</div>
|
||||
) : noticeList.length === 0 ? (
|
||||
<div className="widget-empty">暂无动态</div>
|
||||
) : noticeList.map(item => (
|
||||
{loading && commentList.length === 0 ? (
|
||||
<CommentSkeleton />
|
||||
) : commentList.length === 0 ? (
|
||||
<div className="widget-empty">暂无评论</div>
|
||||
) : commentList.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="widget-item widget-item--notice"
|
||||
onClick={() => onPostClick(item.id)}
|
||||
className="widget-item widget-item--comment"
|
||||
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
|
||||
onClick={() => onPostClick(item.post_id)}
|
||||
>
|
||||
<span className="widget-item-title">{item.title}</span>
|
||||
<span className="widget-item-avatar" aria-hidden>
|
||||
{item.avatar
|
||||
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (item.author?.[0] || '?')}
|
||||
</span>
|
||||
<span className="widget-item-title">{item.excerpt}</span>
|
||||
<span className="widget-item-time">{item.created_at}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">
|
||||
<Users className="widget-card-icon widget-card-icon--online" aria-hidden />
|
||||
当前浏览 <span className="widget-head-count">{online?.count ?? '—'}</span> 人
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
<div className="widget-online-meta">
|
||||
会员 {online?.members ?? 0} · 游客 {online?.guests ?? 0}
|
||||
</div>
|
||||
<div className="widget-online-list">
|
||||
{loading && online == null ? (
|
||||
<span className="widget-empty widget-empty--inline">加载中…</span>
|
||||
) : (
|
||||
<>
|
||||
{members.map(u => (
|
||||
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
|
||||
{u.avatar
|
||||
? <img src={u.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (u.nickname?.[0] || '?')}
|
||||
</span>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<span className="widget-empty widget-empty--inline">暂无会员在线</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-card widget-card--about">
|
||||
<div className="widget-card-body">
|
||||
<p className="widget-about-text">
|
||||
<strong>姜十三论坛</strong>
|
||||
拾三一隅,自在交流。轻量社区,专为小圈子打造。
|
||||
<strong>{branding.name}</strong>
|
||||
{branding.slogan
|
||||
? `${branding.slogan}${branding.name_en ? ` · ${branding.name_en}` : ''}`
|
||||
: (branding.name_en || '轻量社区')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
Home, Star, LayoutDashboard,
|
||||
Home, Star, LayoutDashboard, FolderGit2, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import type { Board } from '../api/types';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
import BoardIconDisplay from './BoardIconDisplay';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
import ArticleOutline from './ArticleOutline';
|
||||
|
||||
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
||||
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
|
||||
@@ -20,6 +23,7 @@ export function isNeutralSidebarRoute(pathname: string): boolean {
|
||||
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
|
||||
if (isNeutralSidebarRoute(pathname)) return null;
|
||||
if (pathname.startsWith('/favorites')) return 'favorites';
|
||||
if (pathname.startsWith('/projects')) return 'projects';
|
||||
if (pathname.startsWith('/admin')) return 'admin';
|
||||
return activeBoard === 0 ? 'all' : String(activeBoard);
|
||||
}
|
||||
@@ -28,9 +32,25 @@ interface Props {
|
||||
boards: Board[];
|
||||
activeBoard: number;
|
||||
onSelectBoard: (id: number) => void;
|
||||
/** 板块列表首次拉取中 */
|
||||
boardsLoading?: boolean;
|
||||
/** 帖子详情:左侧切换为文章目录 */
|
||||
outlineMode?: boolean;
|
||||
outlineHeadings?: PostHeading[];
|
||||
outlineScrollRoot?: HTMLElement | null;
|
||||
outlineTitle?: string;
|
||||
}
|
||||
|
||||
export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
export default function Sidebar({
|
||||
boards,
|
||||
activeBoard,
|
||||
onSelectBoard,
|
||||
boardsLoading = false,
|
||||
outlineMode = false,
|
||||
outlineHeadings = [],
|
||||
outlineScrollRoot = null,
|
||||
outlineTitle,
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
@@ -52,15 +72,48 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
</button>
|
||||
);
|
||||
|
||||
if (outlineMode) {
|
||||
return (
|
||||
<aside className="sidebar sidebar--outline">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-nav-item sidebar-outline-back"
|
||||
onClick={() => navigateFeed(nav, '/')}
|
||||
>
|
||||
<ArrowLeft aria-hidden />
|
||||
<span className="flex-1 truncate">返回首页</span>
|
||||
</button>
|
||||
<ArticleOutline
|
||||
headings={outlineHeadings}
|
||||
scrollRoot={outlineScrollRoot}
|
||||
title={outlineTitle || '文章目录'}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section">浏览</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('all', '全部帖子', <Home aria-hidden />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
|
||||
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
|
||||
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => nav('/projects'))}
|
||||
</nav>
|
||||
|
||||
{boards.length > 0 && (
|
||||
{(boardsLoading && boards.length === 0) ? (
|
||||
<>
|
||||
<div className="sidebar-section sidebar-section--boards">板块</div>
|
||||
<nav className="sidebar-nav sidebar-nav--skeleton" aria-busy="true" aria-label="板块加载中">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="sidebar-nav-item sidebar-nav-item--skeleton">
|
||||
<Skeleton className="skeleton--sidebar-icon" />
|
||||
<Skeleton className="skeleton--sidebar-label" style={{ width: `${58 + (i % 3) * 12}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
) : boards.length > 0 ? (
|
||||
<>
|
||||
<div className="sidebar-section sidebar-section--boards">板块</div>
|
||||
<nav className="sidebar-nav">
|
||||
@@ -92,7 +145,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
|
||||
26
frontend/src/components/SiteBrandMark.tsx
Normal file
26
frontend/src/components/SiteBrandMark.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SiteBranding } from '../api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
branding: SiteBranding;
|
||||
/** CSS 类:header-logo-mark / logo-mark / admin-topbar-mark */
|
||||
className?: string;
|
||||
/** 有 Logo 图时用的额外类名 */
|
||||
imgClassName?: string;
|
||||
}
|
||||
|
||||
/** 站点字标或 Logo 图 */
|
||||
export default function SiteBrandMark({ branding, className, imgClassName }: Props) {
|
||||
if (branding.logo) {
|
||||
return (
|
||||
<img
|
||||
src={branding.logo}
|
||||
alt={branding.name}
|
||||
className={cn(className, 'site-brand-logo-img', imgClassName)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span className={className}>{branding.logo_mark || branding.name.charAt(0) || '?'}</span>;
|
||||
}
|
||||
114
frontend/src/components/TagCloud.tsx
Normal file
114
frontend/src/components/TagCloud.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { TagCount } from '../api/types';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
tags: TagCount[];
|
||||
loading?: boolean;
|
||||
activeTag?: string;
|
||||
}
|
||||
|
||||
type TagTone = 0 | 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
/** 稳定哈希,让同一标签颜色固定 */
|
||||
function hashTone(name: string): TagTone {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return (h % 6) as TagTone;
|
||||
}
|
||||
|
||||
/** 权重档位 0–4,驱动字号与透明度 */
|
||||
function weightTier(count: number, min: number, max: number): number {
|
||||
if (max <= min) return 2;
|
||||
const t = (count - min) / (max - min);
|
||||
return Math.min(4, Math.max(0, Math.round(t * 4)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 打散排序:热门标签穿插分布,避免「大标签全挤在顶上」。
|
||||
* 用名称哈希做次级键,视觉更像云而非排行榜。
|
||||
*/
|
||||
function layoutTags(tags: TagCount[]): TagCount[] {
|
||||
const ranked = [...tags].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, 'zh'));
|
||||
const top = ranked.slice(0, Math.min(6, ranked.length));
|
||||
const rest = ranked.slice(top.length);
|
||||
const out: TagCount[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < top.length || j < rest.length) {
|
||||
if (j < rest.length) out.push(rest[j++]);
|
||||
if (i < top.length) out.push(top[i++]);
|
||||
if (j < rest.length) out.push(rest[j++]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 右侧栏标签云:按热度缩放,色调错落 */
|
||||
export default function TagCloud({ tags, loading = false, activeTag = '' }: Props) {
|
||||
const nav = useNavigate();
|
||||
|
||||
const { items, min, max } = useMemo(() => {
|
||||
if (tags.length === 0) return { items: [] as TagCount[], min: 1, max: 1 };
|
||||
let lo = tags[0].count;
|
||||
let hi = tags[0].count;
|
||||
for (const t of tags) {
|
||||
if (t.count < lo) lo = t.count;
|
||||
if (t.count > hi) hi = t.count;
|
||||
}
|
||||
return { items: layoutTags(tags), min: lo, max: hi };
|
||||
}, [tags]);
|
||||
|
||||
if (loading && tags.length === 0) {
|
||||
return (
|
||||
<div className="tag-cloud tag-cloud--skeleton" aria-busy="true" aria-label="标签加载中">
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="skeleton--tag-cloud"
|
||||
style={{
|
||||
width: `${42 + (i % 5) * 16}px`,
|
||||
height: `${20 + (i % 3) * 4}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className="tag-cloud-empty">暂无标签</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tag-cloud" role="list" aria-label="标签云">
|
||||
{items.map((tag, index) => {
|
||||
const active = activeTag.trim().toLowerCase() === tag.name.toLowerCase();
|
||||
const tier = weightTier(tag.count, min, max);
|
||||
const tone = hashTone(tag.name);
|
||||
return (
|
||||
<button
|
||||
key={tag.name}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={cn(
|
||||
'tag-cloud-item',
|
||||
`tag-cloud-item--w${tier}`,
|
||||
`tag-cloud-item--t${tone}`,
|
||||
`tag-cloud-item--r${index % 5}`,
|
||||
active && 'active',
|
||||
)}
|
||||
title={`${tag.name} · ${tag.count} 篇`}
|
||||
onClick={() => nav(`/?keyword=${encodeURIComponent(tag.name)}`)}
|
||||
>
|
||||
<span className="tag-cloud-item__name">{tag.name}</span>
|
||||
{tier >= 3 && <span className="tag-cloud-item__count">{tag.count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Inbox } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PostListItem from './PostListItem';
|
||||
import PostListSkeleton from './PostListSkeleton';
|
||||
import FeedPagination from './FeedPagination';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from './FeedSortBar';
|
||||
|
||||
@@ -11,15 +15,16 @@ interface Props {
|
||||
posts: PostItem[];
|
||||
sort?: FeedSort;
|
||||
loading: boolean;
|
||||
/** 当前页之后是否还有更多 */
|
||||
hasMore: boolean;
|
||||
/** 是否允许滚动触底自动加载(达到上限后为 false) */
|
||||
canAutoLoad: boolean;
|
||||
/** 是否显示底部分页控件 */
|
||||
showPagination: boolean;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
postTotal: number;
|
||||
onLoadMore: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onSelect: (id: number) => void;
|
||||
/** 返回列表时恢复的滚动位置 */
|
||||
restoreScrollTop?: number | null;
|
||||
/** 递增时强制回到列表顶部(主动刷新导航) */
|
||||
resetScrollKey?: number;
|
||||
onScrollTopChange?: (top: number) => void;
|
||||
onScrollRestored?: () => void;
|
||||
@@ -30,17 +35,25 @@ export default function VirtualPostList({
|
||||
sort = 'latest',
|
||||
loading,
|
||||
hasMore,
|
||||
canAutoLoad,
|
||||
showPagination,
|
||||
page,
|
||||
totalPages,
|
||||
postTotal,
|
||||
onLoadMore,
|
||||
onPageChange,
|
||||
onSelect,
|
||||
restoreScrollTop,
|
||||
resetScrollKey = 0,
|
||||
onScrollTopChange,
|
||||
onScrollRestored,
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
const onScrollTopChangeRef = useRef(onScrollTopChange);
|
||||
const onScrollRestoredRef = useRef(onScrollRestored);
|
||||
onScrollTopChangeRef.current = onScrollTopChange;
|
||||
onScrollRestoredRef.current = onScrollRestored;
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: posts.length,
|
||||
@@ -53,10 +66,8 @@ export default function VirtualPostList({
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
|
||||
const showEnd = !hasMore && posts.length > 0 && !loading;
|
||||
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
|
||||
const isInitialLoad = loading && posts.length === 0;
|
||||
const isLoadingMore = loading && posts.length > 0;
|
||||
const isEmpty = !loading && posts.length === 0;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -67,15 +78,15 @@ export default function VirtualPostList({
|
||||
virtualizer.scrollToOffset(0);
|
||||
}
|
||||
restoredRef.current = true;
|
||||
onScrollTopChange?.(0);
|
||||
}, [resetScrollKey, virtualizer, onScrollTopChange]);
|
||||
onScrollTopChangeRef.current?.(0);
|
||||
}, [resetScrollKey, virtualizer]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
|
||||
virtualizer.scrollToOffset(restoreScrollTop);
|
||||
restoredRef.current = true;
|
||||
onScrollRestored?.();
|
||||
}, [restoreScrollTop, posts.length, virtualizer, onScrollRestored]);
|
||||
onScrollRestoredRef.current?.();
|
||||
}, [restoreScrollTop, posts.length, virtualizer]);
|
||||
|
||||
useEffect(() => {
|
||||
restoredRef.current = false;
|
||||
@@ -85,33 +96,42 @@ export default function VirtualPostList({
|
||||
const el = parentRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
onScrollTopChange?.(el.scrollTop);
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && canAutoLoad && hasMore && !loading) {
|
||||
onLoadMore();
|
||||
}
|
||||
onScrollTopChangeRef.current?.(el.scrollTop);
|
||||
};
|
||||
el.addEventListener('scroll', onScroll);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [canAutoLoad, hasMore, loading, onLoadMore, onScrollTopChange]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="post-list-scroll" ref={parentRef}>
|
||||
{isInitialLoad ? (
|
||||
<PostListSkeleton />
|
||||
) : isEmpty ? (
|
||||
<div className="empty-feed">
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(vi => {
|
||||
const post = posts[vi.index];
|
||||
if (!post) return null;
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
key={vi.key}
|
||||
data-index={vi.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
@@ -122,20 +142,20 @@ export default function VirtualPostList({
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
|
||||
<PostListItem post={post} sort={sort} onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isLoadingMore && <PostListSkeleton count={2} />}
|
||||
{showHistoryPrompt && (
|
||||
<div className="feed-list-footer feed-list-footer--history">
|
||||
<p className="feed-list-footer__hint">
|
||||
已显示 {posts.length} / {postTotal} 条
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onLoadMore}>
|
||||
加载更多历史
|
||||
</Button>
|
||||
{showPagination && (
|
||||
<div className="feed-list-footer feed-list-footer--pagination">
|
||||
<FeedPagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
postTotal={postTotal}
|
||||
loading={loading}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showEnd && (
|
||||
|
||||
@@ -1,62 +1,106 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react';
|
||||
import { LockKeyhole, LogOut } from 'lucide-react';
|
||||
import { LockKeyhole, Trash2 } from 'lucide-react';
|
||||
|
||||
/** 查找光标所在的登录可见节点深度 */
|
||||
function findMembersOnlyDepth($pos: { depth: number; node: (d: number) => { type: { name: string } } }): number {
|
||||
function findMembersOnlyDepth($pos: {
|
||||
depth: number;
|
||||
node: (d: number) => { type: { name: string }; nodeSize: number };
|
||||
before: (d: number) => number;
|
||||
start: (d: number) => number;
|
||||
}): number {
|
||||
for (let d = $pos.depth; d > 0; d -= 1) {
|
||||
if ($pos.node(d).type.name === 'membersOnly') return d;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 登录可见区块是否无实质文字 */
|
||||
function isMembersOnlyEmpty(node: ProseMirrorNode): boolean {
|
||||
return node.textContent.trim().length === 0;
|
||||
}
|
||||
|
||||
/** 编辑态「登录可见」区块视图 */
|
||||
function MembersOnlyView({ selected, editor }: NodeViewProps) {
|
||||
const handleExit = () => {
|
||||
editor.chain().focus().exitMembersOnly().run();
|
||||
function MembersOnlyView({ selected, editor, node, getPos }: NodeViewProps) {
|
||||
const empty = isMembersOnlyEmpty(node);
|
||||
|
||||
/** 按 NodeView 自身位置删除,不依赖光标是否仍在块内 */
|
||||
const deleteThisBlock = () => {
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().removeMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
const handleUnwrap = () => {
|
||||
editor.chain().focus().unwrapMembersOnly().run();
|
||||
const pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().unwrapMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (isMembersOnlyEmpty(node)) {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
} else if (dispatch) {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
as="members-only"
|
||||
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}`}
|
||||
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}${empty ? ' editor-members-only--empty' : ''}`}
|
||||
>
|
||||
<div className="post-members-only__badge" contentEditable={false}>
|
||||
<span className="post-members-only__badge-icon" aria-hidden="true">
|
||||
<LockKeyhole size={12} />
|
||||
</span>
|
||||
<span>登录可见</span>
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__exit-btn"
|
||||
title="Ctrl+Enter 退出到公开区域"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleExit}
|
||||
>
|
||||
<LogOut size={11} />
|
||||
退出
|
||||
</button>
|
||||
<span className="post-members-only__shortcut-hint">Ctrl+Enter 退出</span>
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__unwrap-btn"
|
||||
title="取消登录可见包裹,保留正文"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleUnwrap}
|
||||
>
|
||||
取消包裹
|
||||
</button>
|
||||
<div className="post-members-only__badge-actions">
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__unwrap-btn"
|
||||
title="取消登录可见包裹,保留正文"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleUnwrap}
|
||||
>
|
||||
取消包裹
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__remove-btn"
|
||||
title={empty ? '删除空的登录可见区块' : '删除整个登录可见区块'}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={deleteThisBlock}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<NodeViewContent className="post-members-only__body" />
|
||||
<NodeViewContent className="post-members-only__body" data-placeholder="此处内容游客不可见…" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -68,6 +112,7 @@ declare module '@tiptap/core' {
|
||||
wrapMembersOnly: () => ReturnType;
|
||||
exitMembersOnly: () => ReturnType;
|
||||
unwrapMembersOnly: () => ReturnType;
|
||||
removeMembersOnly: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -94,6 +139,37 @@ export const MembersOnly = Node.create({
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// 空区块内 Backspace / Delete:整块删除
|
||||
Backspace: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const node = $from.node(depth);
|
||||
if (!isMembersOnlyEmpty(node)) {
|
||||
// 有内容时:在区块首字位置再按 Backspace 则解除包裹(与常见编辑器一致)
|
||||
if ($from.parentOffset !== 0) return false;
|
||||
const start = $from.start(depth);
|
||||
if ($from.pos !== start) return false;
|
||||
return editor.commands.unwrapMembersOnly();
|
||||
}
|
||||
|
||||
return editor.commands.removeMembersOnly();
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const node = $from.node(depth);
|
||||
if (!isMembersOnlyEmpty(node)) return false;
|
||||
|
||||
return editor.commands.removeMembersOnly();
|
||||
},
|
||||
// 在区块末尾空行按 Enter 时退出到公开区域
|
||||
Enter: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
@@ -107,6 +183,12 @@ export const MembersOnly = Node.create({
|
||||
const isEmptyBlock = parent.textContent.trim().length === 0;
|
||||
if (!atBlockEnd || !isEmptyBlock) return false;
|
||||
|
||||
// 整块为空时直接删除,避免退出后仍残留空登录可见壳
|
||||
const membersNode = $from.node(depth);
|
||||
if (isMembersOnlyEmpty(membersNode) && membersNode.childCount <= 1) {
|
||||
return editor.commands.removeMembersOnly();
|
||||
}
|
||||
|
||||
return editor.commands.exitMembersOnly();
|
||||
},
|
||||
// Ctrl+Enter / Cmd+Enter 退出到公开区域
|
||||
@@ -162,7 +244,25 @@ export const MembersOnly = Node.create({
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
|
||||
// 空区块:直接删除,避免留下空段落套壳
|
||||
if (isMembersOnlyEmpty(node)) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
} else {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
removeMembersOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -13,7 +13,8 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
/* 需高于全屏编辑器 (z-120),否则未保存提示会被挡住 */
|
||||
'fixed inset-0 z-[200] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -31,7 +32,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'fixed left-[50%] top-[50%] z-[200] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -15,7 +15,8 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
/* 需高于全屏编辑器 (z-120) */
|
||||
'fixed inset-0 z-[200] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -32,7 +33,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'fixed left-[50%] top-[50%] z-[200] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,14 +10,16 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
|
||||
search_keyword_min: 1,
|
||||
search_keyword_max: 50,
|
||||
page_size_default: 30,
|
||||
feed_max_pages: 10,
|
||||
feed_max_items: 300,
|
||||
password_min_len: 6,
|
||||
avatar_max_mb: 2,
|
||||
open_posts_in_new_tab: true,
|
||||
open_content_links_in_new_tab: true,
|
||||
};
|
||||
|
||||
let cached: ForumLimitsPublic | null = null;
|
||||
let inflight: Promise<ForumLimitsPublic> | null = null;
|
||||
let cacheEpoch = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
@@ -27,7 +29,7 @@ function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||
cached = limits;
|
||||
return limits;
|
||||
})
|
||||
.catch(() => DEFAULT_LIMITS)
|
||||
.catch(() => cached ?? DEFAULT_LIMITS)
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
@@ -36,14 +38,34 @@ function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||
export function useForumLimits() {
|
||||
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
const [epoch, setEpoch] = useState(cacheEpoch);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLimits().then(setLimits).finally(() => setLoading(false));
|
||||
const onInvalidate = () => setEpoch(cacheEpoch);
|
||||
listeners.add(onInvalidate);
|
||||
return () => { listeners.delete(onInvalidate); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// 无缓存时显示加载中,避免首页用默认 30/300 误拉全量
|
||||
if (!cached) setLoading(true);
|
||||
fetchLimits()
|
||||
.then(next => {
|
||||
if (!cancelled) setLimits(next);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [epoch]);
|
||||
|
||||
return { limits, loading };
|
||||
}
|
||||
|
||||
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
||||
export function invalidateForumLimitsCache() {
|
||||
cached = null;
|
||||
cacheEpoch += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
92
frontend/src/hooks/useSiteBranding.ts
Normal file
92
frontend/src/hooks/useSiteBranding.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { SiteBranding } from '../api/types';
|
||||
|
||||
export const DEFAULT_BRANDING: SiteBranding = {
|
||||
name: '姜十三论坛',
|
||||
name_en: 'Jiang13 Forum',
|
||||
slogan: '拾三一隅,自在交流',
|
||||
logo_mark: '姜',
|
||||
logo: '',
|
||||
favicon: '',
|
||||
};
|
||||
|
||||
let cached: SiteBranding | null = null;
|
||||
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;
|
||||
inflight = api.siteBranding()
|
||||
.then(b => {
|
||||
cached = { ...DEFAULT_BRANDING, ...b };
|
||||
return cached;
|
||||
})
|
||||
.catch(() => cached ?? DEFAULT_BRANDING)
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
|
||||
function applyDocumentBrand(brand: SiteBranding) {
|
||||
const title = brand.name_en ? `${brand.name} ${brand.name_en}` : brand.name;
|
||||
if (document.title !== title) document.title = title;
|
||||
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (brand.favicon) {
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (link.href !== new URL(brand.favicon, window.location.origin).href) {
|
||||
link.href = brand.favicon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取站点品牌配置(名称、Logo 等) */
|
||||
export function useSiteBranding() {
|
||||
const [branding, setBranding] = useState<SiteBranding>(cached ?? DEFAULT_BRANDING);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
const [epoch, setEpoch] = useState(cacheEpoch);
|
||||
|
||||
useEffect(() => {
|
||||
const onInvalidate = () => setEpoch(cacheEpoch);
|
||||
listeners.add(onInvalidate);
|
||||
return () => { listeners.delete(onInvalidate); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!cached) setLoading(true);
|
||||
fetchBranding()
|
||||
.then(next => {
|
||||
if (cancelled) return;
|
||||
setBranding(next);
|
||||
applyDocumentBrand(next);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [epoch]);
|
||||
|
||||
return { branding, loading };
|
||||
}
|
||||
|
||||
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
||||
export function invalidateSiteBrandingCache() {
|
||||
cached = null;
|
||||
cacheEpoch += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
/** 用管理端刚保存的值立即更新缓存与文档标题 */
|
||||
export function seedSiteBrandingCache(brand: SiteBranding) {
|
||||
cached = { ...DEFAULT_BRANDING, ...brand };
|
||||
applyDocumentBrand(cached);
|
||||
cacheEpoch += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import { useOverlayA11y } from '../hooks/useOverlayA11y';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import BackToTop from '../components/BackToTop';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
||||
@@ -24,6 +27,7 @@ const NAV = [
|
||||
export default function AdminLayout() {
|
||||
const { user, loading } = useAuth();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { branding } = useSiteBranding();
|
||||
const isNarrow = useMediaQuery('(max-width: 768px)');
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const nav = useNavigate();
|
||||
@@ -38,7 +42,7 @@ export default function AdminLayout() {
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) {
|
||||
nav('/login');
|
||||
nav(loginPath('/admin/dashboard'));
|
||||
return;
|
||||
}
|
||||
if (user.role !== 'admin') {
|
||||
@@ -91,9 +95,9 @@ export default function AdminLayout() {
|
||||
{navOpen ? <X size={18} aria-hidden /> : <Menu size={18} aria-hidden />}
|
||||
</button>
|
||||
)}
|
||||
<div className="admin-topbar-mark">姜</div>
|
||||
<SiteBrandMark branding={branding} className="admin-topbar-mark" />
|
||||
<div>
|
||||
<div className="admin-topbar-title">姜十三论坛</div>
|
||||
<div className="admin-topbar-title">{branding.name}</div>
|
||||
<div className="admin-topbar-sub">管理后台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
||||
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { Moon, Sun, Search, Plus, PanelRight, X } from 'lucide-react';
|
||||
import {
|
||||
@@ -13,8 +14,9 @@ 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, Notification, OnlineStats, ForumStats } from '../api/types';
|
||||
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
|
||||
import type { Board, PostItem, RecentComment, ForumStats, TagCount } 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';
|
||||
import RightPanel from '../components/RightPanel';
|
||||
import BackToTop from '../components/BackToTop';
|
||||
@@ -24,10 +26,15 @@ import { navigateFeed } from '../utils/feedCache';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
|
||||
export default function MainLayout() {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { branding } = useSiteBranding();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const hideAside = useMediaQuery('(max-width: 1100px)');
|
||||
const nav = useNavigate();
|
||||
@@ -37,11 +44,18 @@ export default function MainLayout() {
|
||||
|
||||
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
||||
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
||||
const [hot, setHot] = useState<PostItem[]>([]);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [online, setOnline] = useState<OnlineStats | null>(null);
|
||||
const [hot, setHot] = useState<PostItem[]>(() => getCachedHot());
|
||||
const [recentComments, setRecentComments] = useState<RecentComment[]>(() => getCachedRecentComments());
|
||||
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
|
||||
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
|
||||
const [postOutline, setPostOutline] = useState<{
|
||||
headings: PostHeading[];
|
||||
scrollRoot: HTMLElement | null;
|
||||
title?: string;
|
||||
} | null>(null);
|
||||
const [asideOpen, setAsideOpen] = useState(false);
|
||||
const [asideLoading, setAsideLoading] = useState(false);
|
||||
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
|
||||
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
|
||||
const asideEverLoaded = useRef(false);
|
||||
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
|
||||
const [keyword, setKeyword] = useState(params.get('keyword') || '');
|
||||
@@ -60,6 +74,9 @@ export default function MainLayout() {
|
||||
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
|
||||
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
|
||||
useEffect(() => { setAsideOpen(false); }, [loc.pathname, loc.search]);
|
||||
useEffect(() => {
|
||||
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
|
||||
}, [loc.pathname]);
|
||||
useEffect(() => {
|
||||
if (!hideAside) setAsideOpen(false);
|
||||
}, [hideAside]);
|
||||
@@ -71,7 +88,7 @@ export default function MainLayout() {
|
||||
}, [asideOpen]);
|
||||
|
||||
const refreshBoards = useCallback(() => {
|
||||
Promise.all([
|
||||
return Promise.all([
|
||||
api.boards().then(d => {
|
||||
const next = d.boards ?? [];
|
||||
setBoards(next);
|
||||
@@ -83,18 +100,9 @@ export default function MainLayout() {
|
||||
setCachedStats(next);
|
||||
return next;
|
||||
}).catch(() => null),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const refreshOnline = useCallback(() => {
|
||||
api.online().then(d => {
|
||||
setOnline({
|
||||
count: d.count ?? 0,
|
||||
members: d.members ?? 0,
|
||||
guests: d.guests ?? 0,
|
||||
users: Array.isArray(d.users) ? d.users : [],
|
||||
});
|
||||
}).catch(() => {});
|
||||
]).finally(() => {
|
||||
setBoardsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -104,25 +112,51 @@ export default function MainLayout() {
|
||||
return () => window.removeEventListener('boards-refresh', onRefresh);
|
||||
}, [refreshBoards]);
|
||||
|
||||
// 标签云:非编辑页拉取(左侧栏常显)
|
||||
useEffect(() => {
|
||||
if (isCompose) return;
|
||||
api.presence().catch(() => {});
|
||||
const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000);
|
||||
return () => clearInterval(presenceTimer);
|
||||
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);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('posts-refresh', onRefresh);
|
||||
};
|
||||
}, [isCompose]);
|
||||
|
||||
const needAsideData = !isCompose && (!hideAside || asideOpen);
|
||||
useEffect(() => {
|
||||
if (!needAsideData) return;
|
||||
let cancelled = false;
|
||||
if (!asideEverLoaded.current) setAsideLoading(true);
|
||||
// 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动
|
||||
if (!asideEverLoaded.current && !hasCachedAside()) {
|
||||
setAsideLoading(true);
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
api.hotPosts().then(d => {
|
||||
if (!cancelled) setHot(Array.isArray(d.posts) ? d.posts : []);
|
||||
if (cancelled) return;
|
||||
const next = Array.isArray(d.posts) ? d.posts : [];
|
||||
setHot(next);
|
||||
setCachedHot(next);
|
||||
}).catch(() => {}),
|
||||
api.notifications().then(d => {
|
||||
if (!cancelled) setNotifications(Array.isArray(d.notifications) ? d.notifications : []);
|
||||
api.recentComments().then(d => {
|
||||
if (cancelled) return;
|
||||
const next = Array.isArray(d.comments) ? d.comments : [];
|
||||
setRecentComments(next);
|
||||
setCachedRecentComments(next);
|
||||
}).catch(() => {}),
|
||||
]).finally(() => {
|
||||
if (!cancelled) {
|
||||
@@ -131,13 +165,10 @@ export default function MainLayout() {
|
||||
}
|
||||
});
|
||||
|
||||
refreshOnline();
|
||||
const onlineTimer = setInterval(refreshOnline, 30000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(onlineTimer);
|
||||
};
|
||||
}, [needAsideData, refreshOnline]);
|
||||
}, [needAsideData]);
|
||||
|
||||
const doSearch = () => {
|
||||
const kw = keyword.trim();
|
||||
@@ -157,10 +188,10 @@ export default function MainLayout() {
|
||||
nav(`/?keyword=${encodeURIComponent(kw)}`);
|
||||
};
|
||||
|
||||
const openPost = (id: number) => {
|
||||
const openPost = useCallback((id: number) => {
|
||||
setAsideOpen(false);
|
||||
nav(`/post/${id}`);
|
||||
};
|
||||
openForumPost(nav, id, forumLimits.open_posts_in_new_tab);
|
||||
}, [nav, forumLimits.open_posts_in_new_tab]);
|
||||
|
||||
const userInitial = user?.nickname?.charAt(0) || '?';
|
||||
const isFeedHome = loc.pathname === '/';
|
||||
@@ -169,6 +200,22 @@ export default function MainLayout() {
|
||||
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']) => {
|
||||
setPostOutline(outline);
|
||||
}, []);
|
||||
const layoutCtx = useMemo<LayoutCtx>(() => ({
|
||||
boardId,
|
||||
keyword: outletKeyword,
|
||||
setBoardId,
|
||||
boards,
|
||||
stats,
|
||||
refreshBoards,
|
||||
isMobile,
|
||||
setPostOutline: setPostOutlineSafe,
|
||||
}), [boardId, outletKeyword, boards, stats, refreshBoards, isMobile, setPostOutlineSafe]);
|
||||
|
||||
const selectBoardChip = (id: number) => {
|
||||
setBoardId(id);
|
||||
navigateFeed(nav, buildHomeUrl(id, feedSort));
|
||||
@@ -191,8 +238,8 @@ export default function MainLayout() {
|
||||
<header className="app-header">
|
||||
<div className="header-inner">
|
||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||
<span className="header-logo-mark">姜</span>
|
||||
{!isMobile && <span className="header-logo-text">姜十三论坛</span>}
|
||||
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
||||
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
||||
</button>
|
||||
|
||||
{!isCompose && (
|
||||
@@ -224,7 +271,7 @@ export default function MainLayout() {
|
||||
<button
|
||||
type="button"
|
||||
className="header-compose-btn"
|
||||
onClick={() => user ? nav('/compose') : nav('/login')}
|
||||
onClick={() => user ? nav('/compose') : nav(loginPath('/compose'))}
|
||||
aria-label="发帖"
|
||||
>
|
||||
<Plus size={16} aria-hidden />
|
||||
@@ -288,7 +335,7 @@ export default function MainLayout() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button type="button" className="header-login-btn" onClick={() => nav('/login')}>
|
||||
<button type="button" className="header-login-btn" onClick={() => nav(loginPath())}>
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
@@ -303,6 +350,11 @@ export default function MainLayout() {
|
||||
boards={boards}
|
||||
activeBoard={boardId}
|
||||
onSelectBoard={setBoardId}
|
||||
boardsLoading={boardsLoading}
|
||||
outlineMode={isPostDetail}
|
||||
outlineHeadings={postOutline?.headings ?? []}
|
||||
outlineScrollRoot={postOutline?.scrollRoot ?? null}
|
||||
outlineTitle={postOutline?.title}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -346,16 +398,8 @@ export default function MainLayout() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet context={{
|
||||
boardId,
|
||||
keyword: params.get('keyword') || '',
|
||||
setBoardId,
|
||||
boards,
|
||||
stats,
|
||||
refreshBoards,
|
||||
isMobile,
|
||||
} satisfies LayoutCtx} />
|
||||
<Suspense fallback={isFeedHome ? <FeedPageSkeleton /> : <PageLoader />}>
|
||||
<Outlet context={layoutCtx} />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
@@ -363,8 +407,9 @@ export default function MainLayout() {
|
||||
<aside className="aside-panel">
|
||||
<RightPanel
|
||||
hot={hot}
|
||||
notifications={notifications}
|
||||
online={online}
|
||||
recentComments={recentComments}
|
||||
tags={tags}
|
||||
tagsLoading={tagsLoading}
|
||||
loading={asideLoading}
|
||||
onPostClick={openPost}
|
||||
/>
|
||||
@@ -406,8 +451,9 @@ export default function MainLayout() {
|
||||
<div className="aside-drawer-body">
|
||||
<RightPanel
|
||||
hot={hot}
|
||||
notifications={notifications}
|
||||
online={online}
|
||||
recentComments={recentComments}
|
||||
tags={tags}
|
||||
tagsLoading={tagsLoading}
|
||||
loading={asideLoading}
|
||||
onPostClick={openPost}
|
||||
/>
|
||||
@@ -429,4 +475,10 @@ export type LayoutCtx = {
|
||||
stats: ForumStats | null;
|
||||
refreshBoards: () => void;
|
||||
isMobile: boolean;
|
||||
/** 详情页上报文章目录,供左侧栏展示 */
|
||||
setPostOutline: (outline: {
|
||||
headings: PostHeading[];
|
||||
scrollRoot: HTMLElement | null;
|
||||
title?: string;
|
||||
} | null) => void;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, Send, Pencil } from 'lucide-react';
|
||||
import { notify } from '@/lib/notify';
|
||||
@@ -14,6 +14,13 @@ import TagInput, { serializeTags, parseTags } from '../components/TagInput';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getCachedBoards } from '../utils/layoutCache';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import {
|
||||
loadComposeDraft,
|
||||
saveComposeDraft,
|
||||
clearComposeDraft,
|
||||
draftHasContent,
|
||||
} from '../utils/composeDraft';
|
||||
|
||||
interface ComposeBaseline {
|
||||
title: string;
|
||||
@@ -27,6 +34,22 @@ function resolveBoards(ctxBoards?: Board[]): Board[] {
|
||||
return getCachedBoards();
|
||||
}
|
||||
|
||||
/** 格式化剩余可编辑时间 */
|
||||
function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||
if (windowHours <= 0) return '';
|
||||
const deadline = new Date(createdAt).getTime() + windowHours * 3600_000;
|
||||
const ms = deadline - Date.now();
|
||||
if (ms <= 0) return '可编辑时限已到';
|
||||
const hours = Math.floor(ms / 3600_000);
|
||||
const mins = Math.floor((ms % 3600_000) / 60_000);
|
||||
if (hours >= 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `还可编辑约 ${days} 天`;
|
||||
}
|
||||
if (hours > 0) return `还可编辑约 ${hours} 小时 ${mins} 分`;
|
||||
return `还可编辑约 ${mins} 分钟`;
|
||||
}
|
||||
|
||||
export default function ComposePage() {
|
||||
const nav = useNavigate();
|
||||
const { id: editIdParam } = useParams();
|
||||
@@ -50,13 +73,21 @@ export default function ComposePage() {
|
||||
() => isEdit || resolveBoards(layoutCtx?.boards).length > 0,
|
||||
);
|
||||
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
|
||||
const [editWindowHint, setEditWindowHint] = useState('');
|
||||
const [draftHint, setDraftHint] = useState('');
|
||||
const draftReadyRef = useRef(false);
|
||||
const draftTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) { nav('/login'); return; }
|
||||
if (!user) {
|
||||
nav(loginPath(isEdit ? `/post/${editId}/edit` : '/compose'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
setLoading(true);
|
||||
draftReadyRef.current = false;
|
||||
const cached = resolveBoards(layoutCtx?.boards);
|
||||
const boardsPromise = cached.length > 0
|
||||
? Promise.resolve({ boards: cached })
|
||||
@@ -78,16 +109,43 @@ export default function ComposePage() {
|
||||
return;
|
||||
}
|
||||
const loadedBoardId = String(post.board_id);
|
||||
setBoardId(loadedBoardId);
|
||||
setTitle(post.title);
|
||||
setTags(post.tags ?? '');
|
||||
setContent(post.content ?? '');
|
||||
setBaseline({
|
||||
const serverBaseline: ComposeBaseline = {
|
||||
title: post.title,
|
||||
tags: post.tags ?? '',
|
||||
content: post.content ?? '',
|
||||
boardId: loadedBoardId,
|
||||
});
|
||||
};
|
||||
setBoardId(loadedBoardId);
|
||||
setBaseline(serverBaseline);
|
||||
|
||||
const windowHours = postData.post_edit_window_hours ?? 0;
|
||||
if (user.role !== 'admin' && windowHours > 0) {
|
||||
setEditWindowHint(formatEditRemaining(post.created_at, windowHours));
|
||||
} else {
|
||||
setEditWindowHint('');
|
||||
}
|
||||
|
||||
const draft = loadComposeDraft(editId);
|
||||
const useDraft = draft
|
||||
&& draftHasContent(draft)
|
||||
&& (
|
||||
draft.title !== serverBaseline.title
|
||||
|| draft.tags !== serverBaseline.tags
|
||||
|| draft.content !== serverBaseline.content
|
||||
);
|
||||
if (useDraft && draft) {
|
||||
setTitle(draft.title);
|
||||
setTags(draft.tags);
|
||||
setContent(draft.content);
|
||||
setDraftHint('已恢复未保存的编辑草稿');
|
||||
notify.success('已恢复未保存的编辑草稿');
|
||||
} else {
|
||||
setTitle(serverBaseline.title);
|
||||
setTags(serverBaseline.tags);
|
||||
setContent(serverBaseline.content);
|
||||
setDraftHint('');
|
||||
}
|
||||
draftReadyRef.current = true;
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notify.error(e instanceof Error ? e.message : '加载帖子失败');
|
||||
@@ -97,40 +155,77 @@ export default function ComposePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
draftReadyRef.current = false;
|
||||
const applyNewBaseline = (list: Board[], initialBoardId: string) => {
|
||||
setBoards(list);
|
||||
if (!defaultBoard) setBoardId(initialBoardId);
|
||||
const boardForBaseline = defaultBoard || initialBoardId;
|
||||
setBoardId(prev => prev || boardForBaseline);
|
||||
|
||||
const draft = loadComposeDraft(null);
|
||||
if (draft && draftHasContent(draft)) {
|
||||
setTitle(draft.title);
|
||||
setTags(draft.tags);
|
||||
setContent(draft.content);
|
||||
if (draft.boardId && list.some(b => String(b.id) === draft.boardId)) {
|
||||
setBoardId(draft.boardId);
|
||||
}
|
||||
setBaseline({
|
||||
title: '',
|
||||
tags: '',
|
||||
content: '',
|
||||
boardId: draft.boardId || boardForBaseline,
|
||||
});
|
||||
setDraftHint('已恢复本地草稿');
|
||||
notify.success('已恢复本地草稿');
|
||||
} else {
|
||||
setBaseline({
|
||||
title: '',
|
||||
tags: '',
|
||||
content: '',
|
||||
boardId: boardForBaseline,
|
||||
});
|
||||
setDraftHint('');
|
||||
}
|
||||
draftReadyRef.current = true;
|
||||
};
|
||||
|
||||
const list = resolveBoards(layoutCtx?.boards);
|
||||
if (list.length > 0) {
|
||||
setBoards(list);
|
||||
setBoardsReady(true);
|
||||
const initialBoardId = defaultBoard || String(list[0].id);
|
||||
if (!defaultBoard) setBoardId(initialBoardId);
|
||||
setBaseline({
|
||||
title: '',
|
||||
tags: '',
|
||||
content: '',
|
||||
boardId: initialBoardId,
|
||||
});
|
||||
applyNewBaseline(list, initialBoardId);
|
||||
setBoardsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setBoardsReady(false);
|
||||
api.boards().then(d => {
|
||||
const next = d.boards ?? [];
|
||||
setBoards(next);
|
||||
const initialBoardId = defaultBoard || (next.length > 0 ? String(next[0].id) : '');
|
||||
if (!defaultBoard && next.length > 0) {
|
||||
setBoardId(initialBoardId);
|
||||
}
|
||||
setBaseline({
|
||||
title: '',
|
||||
tags: '',
|
||||
content: '',
|
||||
boardId: initialBoardId,
|
||||
});
|
||||
applyNewBaseline(next, initialBoardId);
|
||||
}).catch(() => {
|
||||
setBoards([]);
|
||||
}).finally(() => setBoardsReady(true));
|
||||
}, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards]);
|
||||
|
||||
// 防抖自动保存草稿
|
||||
useEffect(() => {
|
||||
if (!draftReadyRef.current || !user) return;
|
||||
clearTimeout(draftTimerRef.current);
|
||||
draftTimerRef.current = setTimeout(() => {
|
||||
saveComposeDraft(isEdit ? editId : null, {
|
||||
title,
|
||||
tags,
|
||||
content,
|
||||
boardId,
|
||||
});
|
||||
if (title.trim() || tags.trim() || content.trim()) {
|
||||
setDraftHint('草稿已自动保存');
|
||||
}
|
||||
}, 800);
|
||||
return () => clearTimeout(draftTimerRef.current);
|
||||
}, [title, tags, content, boardId, isEdit, editId, user]);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!baseline) return false;
|
||||
return (
|
||||
@@ -206,11 +301,13 @@ export default function ComposePage() {
|
||||
if (isEdit) {
|
||||
await api.updatePost(editId!, payload);
|
||||
notify.success('帖子已更新');
|
||||
clearComposeDraft(editId);
|
||||
markSaved();
|
||||
nav(`/post/${editId}`);
|
||||
} else {
|
||||
const res = await api.createPost({ board_id: boardId, ...payload });
|
||||
notify.success('发帖成功');
|
||||
clearComposeDraft(null);
|
||||
markSaved();
|
||||
nav(`/post/${res.post_id}`);
|
||||
}
|
||||
@@ -230,12 +327,20 @@ export default function ComposePage() {
|
||||
<button
|
||||
type="button"
|
||||
className="compose-back"
|
||||
onClick={() => requestLeave(() => nav(isEdit ? `/post/${editId}` : -1))}
|
||||
onClick={() => requestLeave(() => {
|
||||
if (isEdit) nav(`/post/${editId}`);
|
||||
else nav(-1);
|
||||
})}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>返回</span>
|
||||
</button>
|
||||
<div className="compose-header-actions">
|
||||
{(draftHint || editWindowHint) && (
|
||||
<span className="compose-draft-hint" title={editWindowHint || draftHint}>
|
||||
{editWindowHint || draftHint}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="compose-publish-btn"
|
||||
@@ -287,6 +392,9 @@ export default function ComposePage() {
|
||||
{currentBoard && (
|
||||
<div className="compose-subtitle">
|
||||
{isEdit ? '编辑于' : '发布至'} <strong>{currentBoard.name}</strong>
|
||||
{editWindowHint && (
|
||||
<span className="compose-edit-window"> · {editWindowHint}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ArticleEditor
|
||||
|
||||
@@ -8,6 +8,9 @@ import { api } from '../api/client';
|
||||
import type { PostItem } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import PostListItem from '../components/PostListItem';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
|
||||
interface FavItem {
|
||||
id: number;
|
||||
@@ -19,12 +22,13 @@ interface FavItem {
|
||||
export default function FavoritesPage() {
|
||||
const nav = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
const [list, setList] = useState<FavItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) { nav('/login'); return; }
|
||||
if (!user) { nav(loginPath('/favorites')); return; }
|
||||
api.favorites()
|
||||
.then(d => setList(Array.isArray(d.favorites) ? d.favorites as FavItem[] : []))
|
||||
.catch(e => notify.error(e.message))
|
||||
@@ -58,14 +62,14 @@ export default function FavoritesPage() {
|
||||
<PostListItem
|
||||
key={fav.id}
|
||||
post={fav.post}
|
||||
onClick={() => nav(`/post/${fav.post_id}`)}
|
||||
onSelect={(id) => openForumPost(nav, id, limits.open_posts_in_new_tab)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={fav.id}
|
||||
type="button"
|
||||
className="post-row"
|
||||
onClick={() => nav(`/post/${fav.post_id}`)}
|
||||
onClick={() => openForumPost(nav, fav.post_id, limits.open_posts_in_new_tab)}
|
||||
>
|
||||
<div className="post-body">
|
||||
<div className="post-title">帖子已删除</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
@@ -6,6 +6,7 @@ import type { PostItem } from '../api/types';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import VirtualPostList from '../components/VirtualPostList';
|
||||
import FeedHeader from '../components/FeedHeader';
|
||||
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import {
|
||||
@@ -16,44 +17,41 @@ import {
|
||||
FEED_RESET_EVENT,
|
||||
type FeedNavState,
|
||||
} from '../utils/feedCache';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
|
||||
export default function HomePage() {
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const { limits } = useForumLimits();
|
||||
const pageSize = limits.page_size_default;
|
||||
const feedMaxPages = limits.feed_max_pages;
|
||||
const feedMaxItems = limits.feed_max_items;
|
||||
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 initialCache = getFeedCache(boardId, keyword, sort);
|
||||
|
||||
const [posts, setPosts] = useState<PostItem[]>(() => initialCache?.posts ?? []);
|
||||
const [postTotal, setPostTotal] = useState(() => initialCache?.postTotal ?? 0);
|
||||
const [page, setPage] = useState(() => initialCache?.page ?? 1);
|
||||
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
|
||||
const [loading, setLoading] = useState(() => !initialCache);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [postTotal, setPostTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(null);
|
||||
const [listResetKey, setListResetKey] = useState(0);
|
||||
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
|
||||
const pageWrapRef = useRef<HTMLDivElement>(null);
|
||||
/** 主动刷新时不把旧列表/滚动位置写回 cache */
|
||||
const skipCacheSaveRef = useRef(false);
|
||||
|
||||
const canAutoLoad = useMemo(
|
||||
() => hasMore && page < feedMaxPages && posts.length < feedMaxItems,
|
||||
[hasMore, page, feedMaxPages, posts.length, feedMaxItems],
|
||||
);
|
||||
const scrollTopRef = useRef(0);
|
||||
const skipCacheSaveRef = useRef(false);
|
||||
const loadingRef = useRef(false);
|
||||
const pageRef = useRef(1);
|
||||
pageRef.current = page;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||
const showPagination = totalPages > 1 && posts.length > 0;
|
||||
const hasMore = page < totalPages;
|
||||
|
||||
const resetFeedView = useCallback(() => {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
setListResetKey(k => k + 1);
|
||||
pageWrapRef.current?.scrollTo(0);
|
||||
}, []);
|
||||
|
||||
const beginFeedRefresh = useCallback(() => {
|
||||
@@ -62,7 +60,9 @@ export default function HomePage() {
|
||||
resetFeedView();
|
||||
}, [resetFeedView]);
|
||||
|
||||
const load = useCallback(async (p: number, reset = false) => {
|
||||
const fetchPage = useCallback(async (p: number) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.posts({
|
||||
@@ -73,67 +73,77 @@ export default function HomePage() {
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
setPosts(prev => (reset ? batch : [...prev, ...batch]));
|
||||
setPostTotal(data.total ?? 0);
|
||||
setHasMore(!!data.has_more);
|
||||
const total = data.total ?? 0;
|
||||
setPosts(batch);
|
||||
setPostTotal(total);
|
||||
setPage(p);
|
||||
pageRef.current = p;
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
if (reset) setPosts([]);
|
||||
setPosts([]);
|
||||
setPostTotal(0);
|
||||
setPage(1);
|
||||
pageRef.current = 1;
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [boardId, keyword, sort, pageSize]);
|
||||
|
||||
/** 有缓存时静默刷新第 1 页,合并置顶等变化同时保留已加载的历史 */
|
||||
const revalidate = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.posts({
|
||||
page: 1,
|
||||
size: pageSize,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const fresh = Array.isArray(data.posts) ? data.posts : [];
|
||||
const freshIds = new Set(fresh.map(p => p.id));
|
||||
setPosts(prev => [...fresh, ...prev.filter(p => !freshIds.has(p.id))]);
|
||||
setPostTotal(data.total ?? 0);
|
||||
setHasMore(!!data.has_more);
|
||||
} catch {
|
||||
// 静默失败,保留缓存数据
|
||||
}
|
||||
}, [boardId, keyword, sort, pageSize]);
|
||||
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
||||
|
||||
const loadNextPage = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
load(page + 1);
|
||||
}, [loading, hasMore, page, load]);
|
||||
const goToPage = useCallback((p: number) => {
|
||||
if (loadingRef.current) return;
|
||||
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||
if (p < 1 || p > maxPage) return;
|
||||
if (p === pageRef.current) return;
|
||||
resetFeedView();
|
||||
fetchPage(p);
|
||||
}, [fetchPage, postTotal, pageSize, resetFeedView]);
|
||||
|
||||
const handleSelectPost = useCallback((id: number) => {
|
||||
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
||||
}, [nav, limits.open_posts_in_new_tab]);
|
||||
|
||||
// 等限制就绪后再拉列表;筛选变化时重载
|
||||
useEffect(() => {
|
||||
if (limitsLoading) return;
|
||||
|
||||
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
||||
if (forceRefresh) {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
loadFirst();
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = getFeedCache(boardId, keyword, sort);
|
||||
if (cached) {
|
||||
if (cached && cached.posts.length > 0) {
|
||||
setPosts(cached.posts);
|
||||
setPostTotal(cached.postTotal);
|
||||
setPage(cached.page);
|
||||
setHasMore(cached.hasMore);
|
||||
pageRef.current = cached.page;
|
||||
setRestoreScrollTop(cached.scrollTop);
|
||||
scrollTopRef.current = cached.scrollTop;
|
||||
setLoading(false);
|
||||
revalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
load(1, true);
|
||||
}, [boardId, keyword, sort, location.key, location.state, load, revalidate, beginFeedRefresh]);
|
||||
loadFirst();
|
||||
}, [
|
||||
limitsLoading,
|
||||
pageSize,
|
||||
boardId,
|
||||
keyword,
|
||||
sort,
|
||||
location.key,
|
||||
location.state,
|
||||
loadFirst,
|
||||
beginFeedRefresh,
|
||||
]);
|
||||
|
||||
// 离开当前筛选条件时写入内存缓存
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (skipCacheSaveRef.current || posts.length === 0) return;
|
||||
@@ -141,22 +151,17 @@ export default function HomePage() {
|
||||
posts,
|
||||
postTotal,
|
||||
page,
|
||||
hasMore,
|
||||
scrollTop: scrollTopRef.current,
|
||||
});
|
||||
};
|
||||
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
|
||||
}, [boardId, keyword, sort, posts, postTotal, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && posts.length > 0) {
|
||||
skipCacheSaveRef.current = false;
|
||||
}
|
||||
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
|
||||
}, [loading, posts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onFeedReset = () => {
|
||||
beginFeedRefresh();
|
||||
};
|
||||
const onFeedReset = () => beginFeedRefresh();
|
||||
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||
}, [beginFeedRefresh]);
|
||||
@@ -164,16 +169,16 @@ export default function HomePage() {
|
||||
useEffect(() => {
|
||||
const fn = () => {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
loadFirst();
|
||||
};
|
||||
window.addEventListener('posts-refresh', fn);
|
||||
return () => window.removeEventListener('posts-refresh', fn);
|
||||
}, [beginFeedRefresh, load]);
|
||||
}, [beginFeedRefresh, loadFirst]);
|
||||
|
||||
const handleSortChange = (next: FeedSort) => {
|
||||
if (next === sort) {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
loadFirst();
|
||||
return;
|
||||
}
|
||||
navigateFeed(nav, buildHomeUrl(boardId, next));
|
||||
@@ -181,8 +186,13 @@ export default function HomePage() {
|
||||
|
||||
const showSortBar = !keyword;
|
||||
|
||||
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
|
||||
if ((loading || limitsLoading) && posts.length === 0) {
|
||||
return <FeedPageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-wrap" ref={pageWrapRef}>
|
||||
<div className="page-wrap page-wrap--feed">
|
||||
<div className="feed-panel">
|
||||
<div className="feed-top">
|
||||
<FeedHeader
|
||||
@@ -197,19 +207,21 @@ export default function HomePage() {
|
||||
)}
|
||||
</div>
|
||||
<VirtualPostList
|
||||
posts={posts}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
canAutoLoad={canAutoLoad}
|
||||
postTotal={postTotal}
|
||||
onLoadMore={loadNextPage}
|
||||
onSelect={(id) => nav(`/post/${id}`)}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
/>
|
||||
posts={posts}
|
||||
sort={sort}
|
||||
loading={loading || limitsLoading}
|
||||
hasMore={hasMore}
|
||||
showPagination={showPagination}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
postTotal={postTotal}
|
||||
onPageChange={goToPage}
|
||||
onSelect={handleSelectPost}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -9,6 +9,9 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
|
||||
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 SiteBrandMark from '../components/SiteBrandMark';
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
@@ -19,8 +22,11 @@ type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
const nav = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
const { branding } = useSiteBranding();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const redirectTo = resolveAuthRedirect(searchParams);
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { username: '', password: '' },
|
||||
@@ -32,7 +38,7 @@ export default function LoginPage() {
|
||||
await api.login(values.username, values.password);
|
||||
await refresh();
|
||||
notify.success('登录成功');
|
||||
nav('/', { replace: true });
|
||||
navigateAfterAuth(nav, redirectTo);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
@@ -43,9 +49,9 @@ export default function LoginPage() {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<div className="logo-mark">姜</div>
|
||||
<h1>登录姜十三论坛</h1>
|
||||
<p className="subtitle">拾三一隅,自在交流</p>
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
<h1>登录{branding.name}</h1>
|
||||
<p className="subtitle">{branding.slogan || '欢迎回来'}</p>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
@@ -80,7 +86,7 @@ export default function LoginPage() {
|
||||
</form>
|
||||
</Form>
|
||||
<p className="auth-footer">
|
||||
没有账号?<Link to="/register">注册</Link>
|
||||
没有账号?<Link to={registerPath(redirectTo === '/' ? undefined : redirectTo)}>注册</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion } from 'lucide-react';
|
||||
import { useParams, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion, Trash2 } from 'lucide-react';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem, Comment } from '../api/types';
|
||||
@@ -13,23 +24,42 @@ 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 { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
||||
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
|
||||
import { clearAllFeedCache } from '../utils/feedCache';
|
||||
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
|
||||
/** 格式化剩余可编辑时间 */
|
||||
function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||
if (windowHours <= 0) return '';
|
||||
const deadline = new Date(createdAt).getTime() + windowHours * 3600_000;
|
||||
const ms = deadline - Date.now();
|
||||
if (ms <= 0) return '';
|
||||
const hours = Math.floor(ms / 3600_000);
|
||||
const mins = Math.floor((ms % 3600_000) / 60_000);
|
||||
if (hours >= 24) return `还可编辑约 ${Math.floor(hours / 24)} 天`;
|
||||
if (hours > 0) return `还可编辑约 ${hours} 小时`;
|
||||
return `还可编辑约 ${mins} 分钟`;
|
||||
}
|
||||
|
||||
export default function PostDetailPage() {
|
||||
const { id } = useParams();
|
||||
const postId = Number(id);
|
||||
const nav = useNavigate();
|
||||
const { user, refresh } = useAuth();
|
||||
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
|
||||
|
||||
const [post, setPost] = useState<PostItem | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState<Comment | null>(null);
|
||||
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
|
||||
@@ -37,7 +67,10 @@ export default function PostDetailPage() {
|
||||
const [canEdit, setCanEdit] = useState(false);
|
||||
const [isEdited, setIsEdited] = useState(false);
|
||||
const [editBlockReason, setEditBlockReason] = useState('');
|
||||
const [editWindowHours, setEditWindowHours] = useState(0);
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
const [deletingPost, setDeletingPost] = useState(false);
|
||||
const [headings, setHeadings] = useState<PostHeading[]>([]);
|
||||
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
@@ -46,18 +79,37 @@ export default function PostDetailPage() {
|
||||
|
||||
useGlobalWheelScroll(pageRef, !loading && !!post);
|
||||
|
||||
const handleHeadingsChange = useCallback((next: PostHeading[]) => {
|
||||
setHeadings(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !post) {
|
||||
setPostOutline({ headings: [], scrollRoot: null, title: '文章目录' });
|
||||
return () => setPostOutline(null);
|
||||
}
|
||||
setPostOutline({
|
||||
headings,
|
||||
scrollRoot: pageRef.current,
|
||||
title: '文章目录',
|
||||
});
|
||||
return () => setPostOutline(null);
|
||||
}, [headings, loading, post, setPostOutline]);
|
||||
|
||||
const loadSeq = useRef(0);
|
||||
const postPath = `/post/${postId}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!postId) return;
|
||||
setReplyTo(null);
|
||||
setEditingCommentId(null);
|
||||
setHeadings([]);
|
||||
const seq = ++loadSeq.current;
|
||||
setLoading(true);
|
||||
setPost(null);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// 游客评论归属:仅在进入该帖时读取,不把 user 放进依赖以免 refresh 触发重载循环
|
||||
const myIds = user ? [] : loadMyCommentIds();
|
||||
const [detail, comm] = await Promise.all([
|
||||
api.post(postId),
|
||||
@@ -70,8 +122,8 @@ export default function PostDetailPage() {
|
||||
setCanEdit(detail.can_edit ?? false);
|
||||
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
|
||||
setEditBlockReason(detail.edit_block_reason ?? '');
|
||||
setEditWindowHours(detail.post_edit_window_hours ?? 0);
|
||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||
// 会话刷新与正文展示解耦;勿作为 effect 依赖
|
||||
void refresh();
|
||||
} catch (e: unknown) {
|
||||
if (seq !== loadSeq.current) return;
|
||||
@@ -81,16 +133,15 @@ export default function PostDetailPage() {
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
})();
|
||||
// 仅 postId 变化时加载;user/refresh 变化不得重跑
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 见上
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅 postId 变化时加载
|
||||
}, [postId]);
|
||||
|
||||
// 发评后局部刷新评论列表(不整页重载)
|
||||
const reloadComments = useCallback(async () => {
|
||||
const myIds = user ? [] : loadMyCommentIds();
|
||||
const comm = await api.comments(postId, myIds);
|
||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||
}, [postId, user]);
|
||||
|
||||
const jumpToFloor = useCallback((floor: number) => {
|
||||
const el = document.getElementById(`floor-${floor}`);
|
||||
if (!el) return;
|
||||
@@ -100,7 +151,13 @@ export default function PostDetailPage() {
|
||||
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||
}, []);
|
||||
|
||||
const requireLogin = (actionLabel: string) => {
|
||||
notify.warning(`登录后即可${actionLabel}`);
|
||||
nav(loginPath(postPath));
|
||||
};
|
||||
|
||||
const handleReplyTo = (comment: Comment) => {
|
||||
setEditingCommentId(null);
|
||||
if (replyTo?.id === comment.id) {
|
||||
setReplyTo(null);
|
||||
return;
|
||||
@@ -108,7 +165,6 @@ export default function PostDetailPage() {
|
||||
setReplyTo(comment);
|
||||
};
|
||||
|
||||
// DOM 提交后再滚动,避免 setTimeout 与 focus 抢滚动导致概率性错位
|
||||
useLayoutEffect(() => {
|
||||
if (!replyTo) return;
|
||||
const el = document.getElementById(`reply-box-${replyTo.id}`);
|
||||
@@ -120,7 +176,7 @@ export default function PostDetailPage() {
|
||||
}, []);
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!user) { nav('/login'); return; }
|
||||
if (!user) { requireLogin('点赞'); return; }
|
||||
try {
|
||||
const r = await api.like(postId);
|
||||
setLiked(r.liked);
|
||||
@@ -131,7 +187,7 @@ export default function PostDetailPage() {
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (!user) { nav('/login'); return; }
|
||||
if (!user) { requireLogin('收藏'); return; }
|
||||
try {
|
||||
const r = await api.favorite(postId);
|
||||
setFavorited(r.favorited);
|
||||
@@ -165,6 +221,50 @@ export default function PostDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveComment = async (comment: Comment, content: string) => {
|
||||
try {
|
||||
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
|
||||
)));
|
||||
setEditingCommentId(null);
|
||||
notify.success('评论已更新');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (comment: Comment) => {
|
||||
try {
|
||||
await api.deleteComment(comment.id);
|
||||
setComments(list => list.filter(c => c.id !== comment.id));
|
||||
if (replyTo?.id === comment.id) setReplyTo(null);
|
||||
if (editingCommentId === comment.id) setEditingCommentId(null);
|
||||
notify.success('评论已删除');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePost = async () => {
|
||||
setDeletingPost(true);
|
||||
try {
|
||||
await api.deletePost(postId);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success('帖子已删除');
|
||||
nav('/', { replace: true });
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||
} finally {
|
||||
setDeletingPost(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commentBoxProps = {
|
||||
user,
|
||||
submitting,
|
||||
@@ -184,9 +284,12 @@ export default function PostDetailPage() {
|
||||
|
||||
const authorInitial = post.user?.nickname?.[0] || '?';
|
||||
const tags = post.tags?.split(/[,,]/).map(t => t.trim()).filter(Boolean) ?? [];
|
||||
const isOwnerOrAdmin = user && (user.role === 'admin' || user.id === post.user_id);
|
||||
const isOwnerOrAdmin = !!(user && (user.role === 'admin' || user.id === post.user_id));
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const showEdited = isEdited && post.updated_at;
|
||||
const editRemaining = canEdit && user?.role !== 'admin'
|
||||
? formatEditRemaining(post.created_at, editWindowHours)
|
||||
: '';
|
||||
|
||||
const handlePin = async () => {
|
||||
if (!post) return;
|
||||
@@ -264,14 +367,41 @@ export default function PostDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PostContent html={post.content || ''} isLoggedIn={!!user} />
|
||||
{isMobile && headings.length > 0 && (
|
||||
<details className="post-detail-toc-mobile">
|
||||
<summary>文章目录({headings.length})</summary>
|
||||
<ArticleOutline
|
||||
headings={headings}
|
||||
scrollRoot={pageRef.current}
|
||||
title="目录"
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<PostContent
|
||||
html={post.content || ''}
|
||||
isLoggedIn={!!user}
|
||||
onHeadingsChange={handleHeadingsChange}
|
||||
/>
|
||||
|
||||
<div className="post-detail-actions">
|
||||
<Button variant={liked ? 'default' : 'outline'} size="sm" onClick={handleLike}>
|
||||
<Button
|
||||
variant={liked ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleLike}
|
||||
title={!user ? '登录后可点赞' : undefined}
|
||||
className={!user ? 'post-action-guest' : undefined}
|
||||
>
|
||||
<ThumbsUp />
|
||||
点赞 {post.like_count}
|
||||
</Button>
|
||||
<Button variant={favorited ? 'default' : 'outline'} size="sm" onClick={handleFavorite}>
|
||||
<Button
|
||||
variant={favorited ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleFavorite}
|
||||
title={!user ? '登录后可收藏' : undefined}
|
||||
className={!user ? 'post-action-guest' : undefined}
|
||||
>
|
||||
<Star />
|
||||
{favorited ? '已收藏' : '收藏'}
|
||||
</Button>
|
||||
@@ -287,6 +417,29 @@ export default function PostDetailPage() {
|
||||
编辑历史
|
||||
</Button>
|
||||
)}
|
||||
{isOwnerOrAdmin && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={deletingPost}>
|
||||
<Trash2 />
|
||||
删除
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该帖子?</AlertDialogTitle>
|
||||
<AlertDialogDescription>相关评论也将一并删除,不可恢复。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeletePost}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
{editRemaining && (
|
||||
<span className="post-detail-edit-hint">{editRemaining}</span>
|
||||
)}
|
||||
{isOwnerOrAdmin && !canEdit && editBlockReason && (
|
||||
<span className="post-detail-edit-hint" title={editBlockReason}>
|
||||
{editBlockReason}
|
||||
@@ -338,8 +491,17 @@ export default function PostDetailPage() {
|
||||
comments={comments}
|
||||
highlightFloor={highlightFloor}
|
||||
replyToId={replyTo?.id ?? null}
|
||||
editingId={editingCommentId}
|
||||
currentUser={user}
|
||||
onReply={handleReplyTo}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onStartEdit={(c) => {
|
||||
setReplyTo(null);
|
||||
setEditingCommentId(c.id);
|
||||
}}
|
||||
onCancelEdit={() => setEditingCommentId(null)}
|
||||
onSaveEdit={handleSaveComment}
|
||||
onDelete={handleDeleteComment}
|
||||
renderReplyBox={(c) => (
|
||||
<CommentBox
|
||||
key={c.id}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import AvatarCropDialog from '../components/AvatarCropDialog';
|
||||
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
|
||||
const nickSchema = z.object({
|
||||
nickname: z.string().min(1, '请输入昵称').max(64),
|
||||
@@ -61,7 +62,7 @@ export default function ProfilePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
nav('/login');
|
||||
nav(loginPath('/profile'));
|
||||
}
|
||||
}, [authLoading, user, nav]);
|
||||
|
||||
@@ -317,6 +318,12 @@ export default function ProfilePage() {
|
||||
<Input value={user.username} disabled />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl>
|
||||
<Input value={user.email || '未设置'} disabled />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormField
|
||||
control={nickForm.control}
|
||||
name="nickname"
|
||||
|
||||
119
frontend/src/pages/ProjectsPage.tsx
Normal file
119
frontend/src/pages/ProjectsPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ExternalLink, FolderGit2 } 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 { GiteaProject } from '../api/types';
|
||||
|
||||
function formatRemoteTime(raw?: string | null): string {
|
||||
if (!raw) return '';
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const nav = useNavigate();
|
||||
const [list, setList] = useState<GiteaProject[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api.projects({ page, limit: 30 })
|
||||
.then(d => {
|
||||
setList(Array.isArray(d.projects) ? d.projects : []);
|
||||
setTotal(d.total ?? 0);
|
||||
setTotalPages(d.total_pages ?? 0);
|
||||
})
|
||||
.catch(e => notify.error(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide">
|
||||
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
<h1 className="page-title">开源码桶</h1>
|
||||
<p className="page-desc">
|
||||
论坛会员在 Gitea 上的公开仓库
|
||||
{total > 0 ? ` · 共 ${total} 个` : ''}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<FolderGit2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
<p>暂无同步到的公开项目</p>
|
||||
<p className="page-desc" style={{ marginTop: 8 }}>
|
||||
管理员可在「系统设置 → Gitea 同步」配置后执行同步
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="content-surface projects-list">
|
||||
{list.map(p => (
|
||||
<article key={p.id} className="project-row">
|
||||
<div className="project-row-body">
|
||||
<h2 className="project-row-title">{p.full_name || p.name}</h2>
|
||||
{p.description ? (
|
||||
<p className="project-row-desc">{p.description}</p>
|
||||
) : null}
|
||||
<div className="project-row-meta">
|
||||
<span>{p.owner_login}</span>
|
||||
{p.updated_at_remote && (
|
||||
<span>更新于 {formatRemoteTime(p.updated_at_remote)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
className="project-row-link"
|
||||
href={p.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
在 Gitea 打开
|
||||
<ExternalLink size={14} aria-hidden />
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="projects-pager">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="projects-pager-info">{page} / {totalPages}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -10,32 +10,96 @@ import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { resolveAuthRedirect, loginPath, navigateAfterAuth } from '../utils/authRedirect';
|
||||
import type { RegisterConfig } from '../api/types';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
|
||||
const schema = (minLen: number) => z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
username: z.string().min(2, '用户名至少 2 位').max(32, '用户名最多 32 位'),
|
||||
nickname: z.string().optional(),
|
||||
email: z.string().min(1, '请输入邮箱').email('请输入有效邮箱'),
|
||||
password: z.string().min(minLen, `密码至少 ${minLen} 位`),
|
||||
email_code: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<ReturnType<typeof schema>>;
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { limits } = useForumLimits();
|
||||
const { branding } = useSiteBranding();
|
||||
const nav = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [regConfig, setRegConfig] = useState<RegisterConfig | null>(null);
|
||||
const redirectTo = resolveAuthRedirect(searchParams);
|
||||
const requireCode = !!regConfig?.require_email_code;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema(limits.password_min_len)),
|
||||
defaultValues: { username: '', nickname: '', password: '' },
|
||||
defaultValues: { username: '', nickname: '', email: '', password: '', email_code: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
api.registerConfig()
|
||||
.then(setRegConfig)
|
||||
.catch(() => setRegConfig({
|
||||
is_first_user: false,
|
||||
mail_ready: false,
|
||||
require_email_code: false,
|
||||
register_open: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return;
|
||||
const t = window.setTimeout(() => setCountdown(c => c - 1), 1000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [countdown]);
|
||||
|
||||
const sendCode = async () => {
|
||||
const email = form.getValues('email');
|
||||
const parsed = z.string().email().safeParse(email);
|
||||
if (!parsed.success) {
|
||||
form.setError('email', { message: '请先填写有效邮箱' });
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
try {
|
||||
const r = await api.sendRegisterEmailCode(email);
|
||||
notify.success(r.message);
|
||||
setCountdown(60);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (regConfig && !regConfig.register_open) {
|
||||
notify.error('论坛暂未开放注册,请联系管理员配置邮件服务');
|
||||
return;
|
||||
}
|
||||
if (requireCode && !values.email_code?.trim()) {
|
||||
form.setError('email_code', { message: '请输入邮箱验证码' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.register(values.username, values.password, values.nickname || values.username);
|
||||
await api.register({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
nickname: values.nickname || values.username,
|
||||
email: values.email,
|
||||
emailCode: values.email_code,
|
||||
});
|
||||
await refresh();
|
||||
notify.success('注册成功');
|
||||
nav('/', { replace: true });
|
||||
navigateAfterAuth(nav, redirectTo);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '注册失败');
|
||||
} finally {
|
||||
@@ -43,61 +107,124 @@ export default function RegisterPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const subtitle = (() => {
|
||||
if (!regConfig) return branding.slogan || '欢迎加入';
|
||||
if (regConfig.is_first_user) return '首个注册用户自动成为管理员';
|
||||
if (!regConfig.register_open) return '注册暂未开放,请等待管理员配置邮件服务';
|
||||
return branding.slogan || '欢迎加入';
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<div className="logo-mark">姜</div>
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
<h1>注册账号</h1>
|
||||
<p className="subtitle">首个注册用户自动成为管理员</p>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="3-32 位字母数字下划线" autoComplete="username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nickname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>昵称</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="显示名称(可选)" autoComplete="nickname" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder={`至少 ${limits.password_min_len} 位`} autoComplete="new-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
注册
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<p className="auth-footer">
|
||||
已有账号?<Link to="/login">登录</Link>
|
||||
</p>
|
||||
<p className="subtitle">{subtitle}</p>
|
||||
{regConfig && !regConfig.register_open ? (
|
||||
<p className="auth-footer">
|
||||
已有账号?<Link to={loginPath(redirectTo === '/' ? undefined : redirectTo)}>登录</Link>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="2-32 位,支持中文" autoComplete="username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nickname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>昵称</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="显示名称(可选)" autoComplete="nickname" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="用于接收验证码" autoComplete="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder={`至少 ${limits.password_min_len} 位`} autoComplete="new-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{requireCode && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱验证码</FormLabel>
|
||||
<div className="auth-captcha-row">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="6 位数字验证码"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="auth-code-btn"
|
||||
loading={sendingCode}
|
||||
disabled={countdown > 0}
|
||||
onClick={() => void sendCode()}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : '发送验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{regConfig?.is_first_user && !regConfig.mail_ready && (
|
||||
<p className="auth-hint">首次注册无需邮箱验证码,请注册后到后台配置 SMTP。</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
注册
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<p className="auth-footer">
|
||||
已有账号?<Link to={loginPath(redirectTo === '/' ? undefined : redirectTo)}>登录</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,6 @@ export default function AdminDashboardPage() {
|
||||
{ label: '帖子总数', value: data.posts, cls: 'admin-stat-posts' },
|
||||
{ label: '板块数量', value: data.boards, cls: 'admin-stat-boards' },
|
||||
{ label: '评论总数', value: data.comments, cls: 'admin-stat-comments' },
|
||||
{ label: '当前在线', value: data.online, cls: 'admin-stat-online' },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,14 +58,18 @@ export default function AdminUsersPage() {
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-scroll">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>昵称</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>上次登录</th>
|
||||
<th>登录 IP</th>
|
||||
<th>注册时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -76,12 +80,15 @@ export default function AdminUsersPage() {
|
||||
<td>{u.id}</td>
|
||||
<td>{u.username}</td>
|
||||
<td>{u.nickname}</td>
|
||||
<td className="admin-table-email">{u.email || '—'}</td>
|
||||
<td>
|
||||
{u.role === 'admin'
|
||||
? <Badge variant="orange">管理员</Badge>
|
||||
: <Badge variant="secondary">用户</Badge>}
|
||||
</td>
|
||||
<td>{u.banned ? <Badge variant="destructive">已禁言</Badge> : '正常'}</td>
|
||||
<td>{u.last_login_at ? new Date(u.last_login_at).toLocaleString('zh-CN') : '—'}</td>
|
||||
<td className="admin-table-mono">{u.last_login_ip || '—'}</td>
|
||||
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'}</td>
|
||||
<td>
|
||||
{u.role !== 'admin' && (
|
||||
@@ -94,6 +101,7 @@ export default function AdminUsersPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{users.length === 0 && <div className="admin-empty">暂无用户</div>}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
49
frontend/src/utils/authRedirect.ts
Normal file
49
frontend/src/utils/authRedirect.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/** 构造带回跳的登录路径 */
|
||||
export function loginPath(from?: string): string {
|
||||
const path = sanitizeReturnPath(from ?? currentPath());
|
||||
if (!path) return '/login';
|
||||
return `/login?from=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
/** 构造带回跳的注册路径 */
|
||||
export function registerPath(from?: string): string {
|
||||
const path = sanitizeReturnPath(from ?? currentPath());
|
||||
if (!path) return '/register';
|
||||
return `/register?from=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
/** 从查询参数解析登录/注册成功后的回跳地址 */
|
||||
export function resolveAuthRedirect(search: string | URLSearchParams, fallback = '/'): string {
|
||||
const params = typeof search === 'string' ? new URLSearchParams(search) : search;
|
||||
return sanitizeReturnPath(params.get('from') ?? '') || fallback;
|
||||
}
|
||||
|
||||
/** OAuth/OIDC 协议路径需整页跳转,不能走 React Router */
|
||||
export function isProtocolReturnPath(path: string): boolean {
|
||||
return path.startsWith('/oauth/') || path.startsWith('/.well-known/');
|
||||
}
|
||||
|
||||
/** 登录/注册成功后回跳(协议路径用 location 整页导航) */
|
||||
export function navigateAfterAuth(
|
||||
nav: (to: string, opts?: { replace?: boolean }) => void,
|
||||
redirectTo: string,
|
||||
): void {
|
||||
if (isProtocolReturnPath(redirectTo)) {
|
||||
window.location.assign(redirectTo);
|
||||
return;
|
||||
}
|
||||
nav(redirectTo, { replace: true });
|
||||
}
|
||||
|
||||
function currentPath(): string {
|
||||
if (typeof window === 'undefined') return '/';
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
/** 仅允许站内相对路径,避免开放重定向 */
|
||||
function sanitizeReturnPath(raw: string): string {
|
||||
const path = raw.trim();
|
||||
if (!path.startsWith('/') || path.startsWith('//')) return '';
|
||||
if (path.startsWith('/login') || path.startsWith('/register')) return '';
|
||||
return path;
|
||||
}
|
||||
61
frontend/src/utils/composeDraft.ts
Normal file
61
frontend/src/utils/composeDraft.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export interface ComposeDraft {
|
||||
title: string;
|
||||
tags: string;
|
||||
content: string;
|
||||
boardId: string;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
const PREFIX = 'j13-compose-draft:';
|
||||
|
||||
function draftKey(editId: number | null): string {
|
||||
return editId == null ? `${PREFIX}new` : `${PREFIX}edit:${editId}`;
|
||||
}
|
||||
|
||||
/** 读取发帖/编辑草稿 */
|
||||
export function loadComposeDraft(editId: number | null): ComposeDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKey(editId));
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw) as ComposeDraft;
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
return {
|
||||
title: typeof data.title === 'string' ? data.title : '',
|
||||
tags: typeof data.tags === 'string' ? data.tags : '',
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
boardId: typeof data.boardId === 'string' ? data.boardId : '',
|
||||
savedAt: typeof data.savedAt === 'number' ? data.savedAt : 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入发帖/编辑草稿 */
|
||||
export function saveComposeDraft(editId: number | null, draft: Omit<ComposeDraft, 'savedAt'>): void {
|
||||
try {
|
||||
const payload: ComposeDraft = { ...draft, savedAt: Date.now() };
|
||||
localStorage.setItem(draftKey(editId), JSON.stringify(payload));
|
||||
} catch {
|
||||
// 配额不足等场景静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除发帖/编辑草稿 */
|
||||
export function clearComposeDraft(editId: number | null): void {
|
||||
try {
|
||||
localStorage.removeItem(draftKey(editId));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** 草稿是否相对 baseline 有实质内容 */
|
||||
export function draftHasContent(draft: ComposeDraft): boolean {
|
||||
return Boolean(
|
||||
draft.title.trim()
|
||||
|| draft.tags.trim()
|
||||
|| draft.content.trim()
|
||||
|| draft.boardId.trim(),
|
||||
);
|
||||
}
|
||||
74
frontend/src/utils/enhanceCodeBlocks.ts
Normal file
74
frontend/src/utils/enhanceCodeBlocks.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
|
||||
/** 从 class / data-lang 中解析作者标注的语言标识 */
|
||||
function detectLang(...els: Element[]): string {
|
||||
for (const el of els) {
|
||||
const data = el.getAttribute('data-lang') || el.getAttribute('data-language');
|
||||
if (data?.trim()) return data.trim().toLowerCase();
|
||||
const cls = el.getAttribute('class') || '';
|
||||
const m = cls.match(/(?:language|lang)-([a-z0-9_+-]+)/i);
|
||||
if (m?.[1]) return m[1].toLowerCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 美化并高亮文档中的代码块(加语言标签与复制按钮) */
|
||||
export function enhanceCodeBlocks(root: ParentNode): void {
|
||||
root.querySelectorAll('pre').forEach(pre => {
|
||||
if (pre.closest('.md-codeblock')) return;
|
||||
const code = pre.querySelector('code') || pre;
|
||||
const raw = code.textContent || '';
|
||||
// 作者写了语言标签则以标注为准,绝不被自动识别覆盖
|
||||
const declaredLang = detectLang(code, pre);
|
||||
let label = declaredLang || 'code';
|
||||
|
||||
try {
|
||||
if (declaredLang && hljs.getLanguage(declaredLang)) {
|
||||
const result = hljs.highlight(raw, { language: declaredLang, ignoreIllegals: true });
|
||||
code.innerHTML = result.value;
|
||||
code.classList.add('hljs', `language-${declaredLang}`);
|
||||
} else if (declaredLang) {
|
||||
// 未收录语言(如 aardio):保留原文与标签,不做自动猜测
|
||||
code.classList.add('hljs', `language-${declaredLang}`);
|
||||
} else if (raw.length >= 24) {
|
||||
const result = hljs.highlightAuto(raw);
|
||||
code.innerHTML = result.value;
|
||||
code.classList.add('hljs');
|
||||
if (result.language) {
|
||||
label = result.language;
|
||||
code.classList.add(`language-${result.language}`);
|
||||
}
|
||||
} else {
|
||||
code.classList.add('hljs');
|
||||
}
|
||||
} catch {
|
||||
code.textContent = raw;
|
||||
code.classList.add('hljs');
|
||||
if (declaredLang) code.classList.add(`language-${declaredLang}`);
|
||||
}
|
||||
|
||||
const wrap = pre.ownerDocument.createElement('div');
|
||||
wrap.className = 'md-codeblock';
|
||||
wrap.setAttribute('data-lang', label);
|
||||
|
||||
const head = pre.ownerDocument.createElement('div');
|
||||
head.className = 'md-codeblock__head';
|
||||
head.innerHTML = `
|
||||
<span class="md-codeblock__lang">${escapeHtml(label)}</span>
|
||||
<button type="button" class="md-codeblock__copy" data-code-copy>复制</button>
|
||||
`;
|
||||
|
||||
pre.parentNode?.insertBefore(wrap, pre);
|
||||
wrap.appendChild(head);
|
||||
wrap.appendChild(pre);
|
||||
pre.classList.add('md-codeblock__pre');
|
||||
});
|
||||
}
|
||||
@@ -5,111 +5,33 @@ import type { FeedSort } from '../components/FeedSortBar';
|
||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||
export type FeedNavState = { refreshFeed?: boolean };
|
||||
|
||||
|
||||
|
||||
export type FeedCache = {
|
||||
|
||||
posts: PostItem[];
|
||||
|
||||
postTotal: number;
|
||||
|
||||
page: number;
|
||||
|
||||
hasMore: boolean;
|
||||
|
||||
scrollTop: number;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const PREFIX = 'j13-feed-cache:';
|
||||
|
||||
|
||||
/** 仅存内存:SPA 内返回可恢复,浏览器刷新自动清空 */
|
||||
const store = new Map<string, FeedCache>();
|
||||
|
||||
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
|
||||
|
||||
return `${PREFIX}${boardId}:${keyword}:${sort}`;
|
||||
|
||||
return `${boardId}:${keyword}:${sort}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 读取帖子列表缓存,用于从详情页返回时恢复浏览位置 */
|
||||
|
||||
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
||||
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
|
||||
|
||||
try {
|
||||
|
||||
const raw = sessionStorage.getItem(cacheKey(boardId, keyword, sort));
|
||||
|
||||
return raw ? (JSON.parse(raw) as FeedCache) : null;
|
||||
|
||||
} catch {
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
return store.get(cacheKey(boardId, keyword, sort)) ?? null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 保存帖子列表缓存 */
|
||||
|
||||
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
|
||||
|
||||
try {
|
||||
|
||||
sessionStorage.setItem(cacheKey(boardId, keyword, sort), JSON.stringify(data));
|
||||
|
||||
} catch {
|
||||
|
||||
// sessionStorage 不可用时忽略
|
||||
|
||||
}
|
||||
|
||||
store.set(cacheKey(boardId, keyword, sort), data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 清除指定筛选条件下的列表缓存 */
|
||||
|
||||
export function clearFeedCache(boardId: number, keyword: string, sort: FeedSort) {
|
||||
|
||||
try {
|
||||
|
||||
sessionStorage.removeItem(cacheKey(boardId, keyword, sort));
|
||||
|
||||
} catch {
|
||||
|
||||
// ignore
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 清除所有帖子列表缓存(置顶等操作后列表需全量刷新) */
|
||||
/** 清除所有帖子列表缓存 */
|
||||
export function clearAllFeedCache() {
|
||||
|
||||
try {
|
||||
|
||||
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
||||
|
||||
const key = sessionStorage.key(i);
|
||||
|
||||
if (key?.startsWith(PREFIX)) sessionStorage.removeItem(key);
|
||||
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
// ignore
|
||||
|
||||
}
|
||||
|
||||
store.clear();
|
||||
}
|
||||
|
||||
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Board, ForumStats } from '../api/types';
|
||||
import type { Board, ForumStats, RecentComment, PostItem, TagCount } from '../api/types';
|
||||
|
||||
const BOARDS_KEY = 'j13-cache-boards';
|
||||
const STATS_KEY = 'j13-cache-stats';
|
||||
const HOT_KEY = 'j13-cache-hot';
|
||||
const RECENT_COMMENTS_KEY = 'j13-cache-recent-comments';
|
||||
const TAGS_KEY = 'j13-cache-tags';
|
||||
|
||||
function readJson<T>(key: string): T | null {
|
||||
try {
|
||||
@@ -30,6 +33,33 @@ export function getCachedStats(): ForumStats | null {
|
||||
return readJson<ForumStats>(STATS_KEY);
|
||||
}
|
||||
|
||||
/** 读取缓存的热门帖子,避免右栏/抽屉首屏高度跳动 */
|
||||
export function getCachedHot(): PostItem[] {
|
||||
const list = readJson<PostItem[]>(HOT_KEY);
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
/** 读取缓存的最新评论,避免右栏/抽屉首屏高度跳动 */
|
||||
export function getCachedRecentComments(): RecentComment[] {
|
||||
const list = readJson<RecentComment[]>(RECENT_COMMENTS_KEY);
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
/** 读取缓存的标签云 */
|
||||
export function getCachedTags(): TagCount[] {
|
||||
const list = readJson<TagCount[]>(TAGS_KEY);
|
||||
return Array.isArray(list) ? list : [];
|
||||
}
|
||||
|
||||
/** 右栏是否已有可展示的 session 缓存(含空列表) */
|
||||
export function hasCachedAside(): boolean {
|
||||
try {
|
||||
return sessionStorage.getItem(HOT_KEY) != null || sessionStorage.getItem(RECENT_COMMENTS_KEY) != null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCachedBoards(boards: Board[]) {
|
||||
writeJson(BOARDS_KEY, boards);
|
||||
}
|
||||
@@ -37,3 +67,15 @@ export function setCachedBoards(boards: Board[]) {
|
||||
export function setCachedStats(stats: ForumStats) {
|
||||
writeJson(STATS_KEY, stats);
|
||||
}
|
||||
|
||||
export function setCachedHot(posts: PostItem[]) {
|
||||
writeJson(HOT_KEY, posts);
|
||||
}
|
||||
|
||||
export function setCachedRecentComments(list: RecentComment[]) {
|
||||
writeJson(RECENT_COMMENTS_KEY, list);
|
||||
}
|
||||
|
||||
export function setCachedTags(tags: TagCount[]) {
|
||||
writeJson(TAGS_KEY, tags);
|
||||
}
|
||||
|
||||
15
frontend/src/utils/openPost.ts
Normal file
15
frontend/src/utils/openPost.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
/** 按站点配置打开帖子详情(当前页跳转或新标签) */
|
||||
export function openForumPost(
|
||||
nav: NavigateFunction,
|
||||
postId: number,
|
||||
openInNewTab: boolean,
|
||||
) {
|
||||
const path = `/post/${postId}`;
|
||||
if (openInNewTab) {
|
||||
window.open(path, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
nav(path);
|
||||
}
|
||||
@@ -1,52 +1,34 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { Config } from 'dompurify';
|
||||
import { enhanceCodeBlocks } from './enhanceCodeBlocks';
|
||||
import { enhanceHeadingAnchors } from './postHeadings';
|
||||
|
||||
/** DOMPurify 配置:允许会员专属自定义标签 */
|
||||
export const POST_CONTENT_PURIFY_CONFIG: DOMPurify.Config = {
|
||||
/** DOMPurify 配置:允许会员专属自定义标签与链接 target */
|
||||
export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
||||
ADD_TAGS: ['members-only'],
|
||||
ADD_ATTR: ['data-locked', 'data-length'],
|
||||
ADD_ATTR: ['data-locked', 'data-length', 'target', 'rel', 'data-code-copy', 'data-lang'],
|
||||
};
|
||||
|
||||
const VISIBLE_BADGE_HTML = `
|
||||
<div class="post-members-only__badge">
|
||||
<span class="post-members-only__badge-icon" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</span>
|
||||
<span>登录可见</span>
|
||||
</div>`;
|
||||
const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
|
||||
|
||||
const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
|
||||
|
||||
/** 游客看到的锁定区块:模糊占位 + 登录引导 */
|
||||
/** 游客看到的锁定区块:流内嵌条 + 登录引导(精简高度) */
|
||||
function buildLockedGateHtml(charLength: number): string {
|
||||
const lineCount = charLength > 0
|
||||
? Math.min(6, Math.max(3, Math.ceil(charLength / 42)))
|
||||
: 4;
|
||||
const lines = Array.from({ length: lineCount }, (_, i) => {
|
||||
const mod = i % 3;
|
||||
const widthClass = mod === 1 ? ' post-members-only__preview-line--medium'
|
||||
: mod === 2 ? ' post-members-only__preview-line--short' : '';
|
||||
return `<div class="post-members-only__preview-line${widthClass}"></div>`;
|
||||
}).join('');
|
||||
|
||||
const lengthHint = charLength > 0
|
||||
? `约 ${charLength} 字的`
|
||||
: '一段';
|
||||
? `约 ${charLength} 字`
|
||||
: '专属内容';
|
||||
|
||||
return `
|
||||
<div class="post-members-only__locked-wrap">
|
||||
<div class="post-members-only__badge post-members-only__badge--locked">
|
||||
<span class="post-members-only__badge-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
|
||||
<span>登录可见</span>
|
||||
</div>
|
||||
<div class="post-members-only__preview" aria-hidden="true">
|
||||
${lines}
|
||||
</div>
|
||||
<div class="post-members-only__gate">
|
||||
<div class="post-members-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</div>
|
||||
<p class="post-members-only__gate-title">此处有${lengthHint}专属内容</p>
|
||||
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
|
||||
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
|
||||
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
|
||||
<span class="post-members-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
|
||||
<div class="post-members-only__gate-text">
|
||||
<p class="post-members-only__gate-title">登录后可见(${lengthHint})</p>
|
||||
<p class="post-members-only__gate-desc">作者将此段设为仅登录用户可读</p>
|
||||
</div>
|
||||
<div class="post-members-only__gate-actions">
|
||||
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
|
||||
<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -55,18 +37,22 @@ function buildLockedGateHtml(charLength: number): string {
|
||||
export function isHtmlEmpty(html: string): boolean {
|
||||
if (!html.trim()) return true;
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string,
|
||||
'text/html',
|
||||
);
|
||||
return (doc.body.textContent ?? '').trim().length === 0;
|
||||
}
|
||||
|
||||
/** 根据登录状态渲染帖子正文 HTML */
|
||||
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
|
||||
export function renderPostContentHtml(
|
||||
html: string,
|
||||
isLoggedIn: boolean,
|
||||
opts?: { openLinksInNewTab?: boolean },
|
||||
): string {
|
||||
if (!html.trim()) return '';
|
||||
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string,
|
||||
'text/html',
|
||||
);
|
||||
|
||||
@@ -87,8 +73,9 @@ export function renderPostContentHtml(html: string, isLoggedIn: boolean): string
|
||||
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
|
||||
.join('');
|
||||
|
||||
// 已登录:降噪,不展示醒目 badge,仅保留结构容器
|
||||
el.className = 'post-members-only post-members-only--visible';
|
||||
el.innerHTML = `${VISIBLE_BADGE_HTML}<div class="post-members-only__body">${innerHtml}</div>`;
|
||||
el.innerHTML = `<div class="post-members-only__body">${innerHtml}</div>`;
|
||||
});
|
||||
|
||||
doc.querySelectorAll('img').forEach(img => {
|
||||
@@ -96,5 +83,20 @@ export function renderPostContentHtml(html: string, isLoggedIn: boolean): string
|
||||
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
|
||||
});
|
||||
|
||||
if (opts?.openLinksInNewTab) {
|
||||
doc.querySelectorAll('a[href]').forEach(a => {
|
||||
const href = a.getAttribute('href') || '';
|
||||
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
||||
a.setAttribute('target', '_blank');
|
||||
const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
|
||||
rel.add('noopener');
|
||||
rel.add('noreferrer');
|
||||
a.setAttribute('rel', Array.from(rel).join(' '));
|
||||
});
|
||||
}
|
||||
|
||||
enhanceHeadingAnchors(doc.body);
|
||||
enhanceCodeBlocks(doc.body);
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
50
frontend/src/utils/postHeadings.ts
Normal file
50
frontend/src/utils/postHeadings.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/** 正文标题节点(用于文章目录树) */
|
||||
export interface PostHeading {
|
||||
id: string;
|
||||
level: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 为标题补全锚点 id,并返回目录树数据 */
|
||||
export function enhanceHeadingAnchors(root: ParentNode): PostHeading[] {
|
||||
const headings: PostHeading[] = [];
|
||||
const used = new Map<string, number>();
|
||||
|
||||
root.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((el, index) => {
|
||||
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) return;
|
||||
|
||||
const level = Number(el.tagName.slice(1)) || 2;
|
||||
let id = el.getAttribute('id')?.trim() || '';
|
||||
if (!id) {
|
||||
id = `heading-${index + 1}`;
|
||||
}
|
||||
const n = (used.get(id) || 0) + 1;
|
||||
used.set(id, n);
|
||||
if (n > 1) id = `${id}-${n}`;
|
||||
el.setAttribute('id', id);
|
||||
el.classList.add('post-heading-anchor');
|
||||
|
||||
headings.push({ id, level, text });
|
||||
});
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
/** 从已渲染 HTML 中读取目录(假定 id 已由 enhanceHeadingAnchors 写入) */
|
||||
export function extractHeadingsFromHtml(html: string): PostHeading[] {
|
||||
if (!html.trim()) return [];
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const headings: PostHeading[] = [];
|
||||
doc.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(el => {
|
||||
const id = el.getAttribute('id')?.trim();
|
||||
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (!id || !text) return;
|
||||
headings.push({
|
||||
id,
|
||||
level: Number(el.tagName.slice(1)) || 2,
|
||||
text,
|
||||
});
|
||||
});
|
||||
return headings;
|
||||
}
|
||||
Reference in New Issue
Block a user