新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。

作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 16:58:22 +08:00
parent 9487c8ab02
commit 822eef96be
83 changed files with 8578 additions and 1222 deletions

View File

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

View File

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

View File

@@ -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>
</>,
),

View File

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

View File

@@ -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[];
}

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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;
}
/** 权重档位 04驱动字号与透明度 */
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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 美化并高亮文档中的代码块(加语言标签与复制按钮) */
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');
});
}

View File

@@ -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();
}
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */

View File

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

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

View File

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

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