支持公开用户主页、帖子图缩略图与编辑器图组排版。

新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;TipTap 支持多图分组与环绕排版,并注入站点标题避免刷新闪烁。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-01 12:29:50 +08:00
parent 822eef96be
commit 060b7707cb
51 changed files with 3563 additions and 397 deletions

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>姜十三论坛 Jiang13 Forum</title>
<title>姜十三论坛 - 拾三一隅,自在交流</title>
<style>
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
html { scrollbar-gutter: stable; }

View File

@@ -22,6 +22,7 @@ const RegisterPage = lazy(() => import('./pages/RegisterPage'));
const ComposePage = lazy(() => import('./pages/ComposePage'));
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
const ProjectsPage = lazy(() => import('./pages/ProjectsPage'));
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
@@ -51,6 +52,7 @@ const router = createBrowserRouter(
<Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/compose" element={<ComposePage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/user/:id" element={<UserProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} />
<Route path="/projects" element={<ProjectsPage />} />
</Route>

View File

@@ -1,4 +1,4 @@
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';
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, SiteBranding, RegisterConfig } from './types';
const BASE = '';
@@ -156,11 +156,21 @@ export const api = {
}),
adminBackup: () =>
request<{ message: string; filename: string; download: string }>('/api/admin/backup', { method: 'POST' }),
profileStats: () => request<{ stats: UserActivityStats }>('/api/profile/stats'),
userProfile: (id: number) =>
request<{ user: UserPublic; stats: UserActivityStats }>(`/api/users/${id}`),
updateNickname: (nickname: string) => {
const fd = new FormData();
fd.append('nickname', nickname);
return request('/api/profile/nickname', { method: 'POST', body: fd, headers: {} });
},
updateSignature: (signature: string) => {
const fd = new FormData();
fd.append('signature', signature);
return request<{ message: string; user: User }>('/api/profile/signature', {
method: 'POST', body: fd, headers: {},
});
},
updatePassword: (oldPassword: string, newPassword: string) => {
const fd = new FormData();
fd.append('old_password', oldPassword);

View File

@@ -3,6 +3,7 @@ export interface User {
username: string;
email?: string;
nickname: string;
signature?: string;
avatar: string;
role: 'user' | 'admin';
banned?: boolean;
@@ -13,6 +14,27 @@ export interface User {
updated_at?: string;
}
/** 公开用户主页(无邮箱) */
export interface UserPublic {
id: number;
username: string;
nickname: string;
signature: string;
avatar: string;
role: 'user' | 'admin';
banned?: boolean;
banned_at?: string;
created_at: string;
}
/** 个人中心活动统计 */
export interface UserActivityStats {
post_count: number;
comment_count: number;
favorite_count: number;
like_received: number;
}
export interface Board {
id: number;
name: string;
@@ -119,6 +141,7 @@ export interface ForumLimits {
page_size_default: number;
password_min_len: number;
avatar_max_mb: number;
signature_max: number;
open_posts_in_new_tab: boolean;
open_content_links_in_new_tab: boolean;
}
@@ -133,6 +156,7 @@ export interface ForumLimitsPublic {
page_size_default: number;
password_min_len: number;
avatar_max_mb: number;
signature_max: number;
open_posts_in_new_tab: boolean;
open_content_links_in_new_tab: boolean;
}
@@ -247,6 +271,7 @@ export interface Paginated<T> {
export interface RecentComment {
id: number;
post_id: number;
user_id?: number;
author: string;
avatar: string;
excerpt: string;

View File

@@ -5,7 +5,6 @@ import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import { TextSelection } from '@tiptap/pm/state';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import DOMPurify from 'dompurify';
@@ -13,6 +12,7 @@ import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
FileCode, PenLine, Maximize2, Minimize2,
Columns2, PanelLeft, PanelRight, StretchHorizontal,
} from 'lucide-react';
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
@@ -30,6 +30,9 @@ import { api } from '../api/client';
import { notify } from '@/lib/notify';
import { MembersOnly } from './editor/MembersOnlyExtension';
import { TabIndent } from './editor/TabIndentExtension';
import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension';
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagraph';
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
import { Tooltip } from './ui/Tooltip';
@@ -85,25 +88,29 @@ function cycleHeading(editor: Editor) {
editor.chain().focus().toggleHeading({ level: 2 }).run();
}
/** 触发图片文件选择并上传 */
async function uploadPostImageFile(): Promise<string | null> {
/** 触发图片文件选择并上传(支持多选) */
async function uploadPostImageFiles(multiple = true): Promise<string[]> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.multiple = multiple;
input.onchange = async () => {
const file = input.files?.[0];
if (!file) {
resolve(null);
const files = [...(input.files ?? [])];
if (!files.length) {
resolve([]);
return;
}
try {
const { url } = await api.uploadPostImage(file);
resolve(url);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
resolve(null);
const urls: string[] = [];
for (const file of files) {
try {
const { url } = await api.uploadPostImage(file);
urls.push(url);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
}
}
resolve(urls);
};
input.click();
});
@@ -149,14 +156,18 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
extensions: [
StarterKit.configure({
heading: { levels: [2, 3, 4, 5, 6] },
paragraph: false,
}),
ClearFloatParagraph,
ClearFloatSync,
Underline,
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
}),
Image.configure({ inline: false, allowBase64: false }),
ArticleImage.configure({ inline: false, allowBase64: false }),
ImageGroup,
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') {
@@ -302,9 +313,24 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const setImage = useCallback(async () => {
if (!editor) return;
const url = await uploadPostImageFile();
if (url) {
editor.chain().focus().setImage({ src: url }).run();
const urls = await uploadPostImageFiles(true);
if (!urls.length) return;
if (urls.length === 1) {
editor.chain().focus().setImage({ src: urls[0] }).run();
return;
}
editor.chain().focus().insertImageGroup(urls, suggestImageGroupLayout(urls.length)).run();
}, [editor]);
const setImageDisplay = useCallback((display: ImageDisplay) => {
if (!editor) return;
editor.chain().focus().setImageDisplay(display).run();
}, [editor]);
const wrapSelectedAsGroup = useCallback(() => {
if (!editor) return;
if (!editor.commands.wrapImagesInGroup()) {
notify.warning('请先点击或靠近至少两张连续图片,再合并为图组');
}
}, [editor]);
@@ -356,9 +382,16 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const insertMarkdownImage = useCallback(async () => {
const textarea = markdownRef.current;
if (!textarea) return;
const url = await uploadPostImageFile();
if (!url) return;
insertAtCursor(textarea, markdownSource, `\n\n![图片](${url})\n\n`, handleMarkdownChange);
const urls = await uploadPostImageFiles(true);
if (!urls.length) return;
if (urls.length === 1) {
insertAtCursor(textarea, markdownSource, `\n\n![图片](${urls[0]})\n\n`, handleMarkdownChange);
return;
}
const layout = suggestImageGroupLayout(urls.length);
const imgs = urls.map(u => `<img src="${u}" alt="">`).join('');
const block = `\n\n<div data-image-group data-layout="${layout}" class="image-group image-group--${layout}">${imgs}</div>\n\n`;
insertAtCursor(textarea, markdownSource, block, handleMarkdownChange);
}, [markdownSource, handleMarkdownChange]);
const markdownPreviewHtml = useMemo(
@@ -368,7 +401,11 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const buildRichTools = useCallback((): ToolBtn[] => {
if (!editor) return [];
return [
const imageActive = editor.isActive('image');
const groupActive = editor.isActive('imageGroup');
const currentDisplay = (editor.getAttributes('image').display as ImageDisplay) || 'default';
const tools: ToolBtn[] = [
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', active: editor.isActive('heading'), action: () => cycleHeading(editor) },
{ icon: <Bold size={15} />, title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() },
{ icon: <Italic size={15} />, title: '斜体', active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() },
@@ -380,17 +417,57 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
{ icon: <ListOrdered size={15} />, title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() },
{ icon: <Code size={15} />, title: '代码块', active: editor.isActive('codeBlock'), action: () => editor.chain().focus().toggleCodeBlock().run() },
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
{ icon: <ImageIcon size={15} />, title: '上传图片', action: setImage },
{
icon: <LockKeyhole size={15} />,
title: '登录可见',
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
active: editor.isActive('membersOnly'),
className: 'article-tool-btn--members',
action: wrapMembersOnly,
icon: <ImageIcon size={15} />,
title: '上传图片',
hint: '可多选;多张自动并排成图组',
action: setImage,
},
{
icon: <Columns2 size={15} />,
title: '合并为图组',
hint: '点击一张图后合并其附近连续图片(无需框选多张)',
active: groupActive,
action: wrapSelectedAsGroup,
},
];
}, [editor, openLinkDialog, setImage, wrapMembersOnly]);
if (imageActive && !groupActive) {
tools.push(
{
icon: <StretchHorizontal size={15} />,
title: '通栏大图',
active: currentDisplay === 'wide',
action: () => setImageDisplay(currentDisplay === 'wide' ? 'default' : 'wide'),
},
{
icon: <PanelLeft size={15} />,
title: '左绕排',
hint: '图片居左,文字环绕',
active: currentDisplay === 'float-left',
action: () => setImageDisplay(currentDisplay === 'float-left' ? 'default' : 'float-left'),
},
{
icon: <PanelRight size={15} />,
title: '右绕排',
hint: '图片居右,文字环绕',
active: currentDisplay === 'float-right',
action: () => setImageDisplay(currentDisplay === 'float-right' ? 'default' : 'float-right'),
},
);
}
tools.push({
icon: <LockKeyhole size={15} />,
title: '登录可见',
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
active: editor.isActive('membersOnly'),
className: 'article-tool-btn--members',
action: wrapMembersOnly,
});
return tools;
}, [editor, openLinkDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
const buildMarkdownTools = useCallback((): ToolBtn[] => [
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
@@ -463,12 +540,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
<div className="article-editor-status">
<div className="article-editor-status-meta">
<span>{words} </span>
<span className="article-editor-status-sep">·</span>
<span>
{mode === 'rich' ? '富文本' : 'Markdown 源码'}
{' · Tab 缩进 / Shift+Tab 回退'}
{mode === 'rich' ? ' · 登录可见内 Ctrl+Enter 退出' : ''}
<span className="article-editor-wordcount">{words} </span>
<span className="article-editor-status-sep" aria-hidden>·</span>
<span className="article-editor-mode-label">
{mode === 'rich' ? '所见即所得' : 'Markdown'}
</span>
</div>
@@ -498,12 +573,12 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
>
<button
type="button"
className="article-editor-view-btn"
className={`article-editor-view-btn${fullscreen ? ' active' : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={() => setFullscreen(v => !v)}
>
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
<span>{fullscreen ? '退出全屏' : '全屏'}</span>
<span>{fullscreen ? '退出' : '全屏'}</span>
</button>
</Tooltip>
</div>

View File

@@ -11,12 +11,14 @@ import {
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { getCroppedAvatarFile } from '../utils/avatarCrop';
import { getCroppedAvatarFile, validateAvatarOutput } from '../utils/avatarCrop';
interface Props {
open: boolean;
imageSrc: string | null;
fileName?: string;
/** 裁剪后文件体积上限MB */
maxMb: number;
onOpenChange: (open: boolean) => void;
onConfirm: (file: File) => void;
}
@@ -25,6 +27,7 @@ export default function AvatarCropDialog({
open,
imageSrc,
fileName,
maxMb,
onOpenChange,
onConfirm,
}: Props) {
@@ -50,6 +53,11 @@ export default function AvatarCropDialog({
setConfirming(true);
try {
const file = await getCroppedAvatarFile(imageSrc, croppedAreaPixels, fileName);
const sizeErr = validateAvatarOutput(file, maxMb);
if (sizeErr) {
notify.error(sizeErr);
return;
}
onConfirm(file);
onOpenChange(false);
} catch {

View File

@@ -24,6 +24,7 @@ import {
} from '../utils/comment';
import { isTimeDiffSignificant } from '../utils/content';
import { useForumLimits } from '../hooks/useForumLimits';
import UserLink from './UserLink';
function canManageComment(c: Comment, user?: User | null): boolean {
if (!user) return false;
@@ -97,20 +98,40 @@ function CommentItem({
id={`floor-${c.floor}`}
className={`waline-comment ${nested ? 'nested' : ''} ${isHighlighted ? 'highlight' : ''}`}
>
<div className={`waline-comment-avatar ${guest && !c.user?.avatar ? 'guest' : ''}`}>
{c.user?.avatar ? (
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
) : (
commentInitial(c)
)}
</div>
{!guest && c.user_id ? (
<UserLink
user={c.user ?? { id: c.user_id, nickname: nick }}
showAvatar={false}
showName={false}
className={`waline-comment-avatar user-link--avatar-only${!c.user?.avatar ? ' guest' : ''}`}
>
{c.user?.avatar ? (
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
) : (
commentInitial(c)
)}
</UserLink>
) : (
<div className={`waline-comment-avatar ${!c.user?.avatar ? 'guest' : ''}`}>
{c.user?.avatar ? (
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
) : (
commentInitial(c)
)}
</div>
)}
<div className="waline-comment-main">
<div className="waline-comment-head">
{c.guest_url ? (
{guest && c.guest_url ? (
<a href={c.guest_url} target="_blank" rel="noopener noreferrer" className="waline-comment-author">
{nick}
</a>
) : !guest && c.user_id ? (
<UserLink
user={c.user ?? { id: c.user_id, nickname: nick }}
className="waline-comment-author"
/>
) : (
<span className="waline-comment-author">{nick}</span>
)}

View File

@@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { X } from 'lucide-react';
import { createPortal } from 'react-dom';
interface Props {
src: string | null;
alt?: string;
open: boolean;
onClose: () => void;
}
/** 帖子正文图片灯箱:展示原图,点击遮罩 / Esc / 关闭按钮退出 */
export default function ImageLightbox({ src, alt = '', open, onClose }: Props) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener('keydown', onKey);
};
}, [open, onClose]);
if (!open || !src) return null;
return createPortal(
<div className="image-lightbox" role="dialog" aria-modal="true" aria-label="查看原图">
<button
type="button"
className="image-lightbox-backdrop"
aria-label="关闭"
onClick={onClose}
/>
<button
type="button"
className="image-lightbox-close"
aria-label="关闭"
onClick={onClose}
>
<X size={20} aria-hidden />
</button>
<div className="image-lightbox-stage">
<img
src={src}
alt={alt || '原图'}
className="image-lightbox-img"
decoding="async"
/>
</div>
<p className="image-lightbox-hint"></p>
</div>,
document.body,
);
}

View File

@@ -1,10 +1,11 @@
import { useMemo, useCallback, useEffect } from 'react';
import { useMemo, useCallback, useEffect, useState } 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';
import ImageLightbox from './ImageLightbox';
interface Props {
html: string;
@@ -14,7 +15,7 @@ interface Props {
onHeadingsChange?: (headings: PostHeading[]) => void;
}
/** 帖子正文渲染(含会员专属区块、代码块美化) */
/** 帖子正文渲染(含会员专属区块、代码块美化、图片灯箱 */
export default function PostContent({
html,
isLoggedIn,
@@ -23,6 +24,8 @@ export default function PostContent({
}: Props) {
const nav = useNavigate();
const { limits } = useForumLimits();
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const [lightboxAlt, setLightboxAlt] = useState('');
const prepared = useMemo(() => {
const rendered = renderPostContentHtml(html, isLoggedIn, {
@@ -38,6 +41,13 @@ export default function PostContent({
onHeadingsChange?.(prepared.headings);
}, [prepared.headings, onHeadingsChange]);
const openLightbox = useCallback((img: HTMLImageElement) => {
const full = img.getAttribute('data-full') || img.currentSrc || img.src;
if (!full) return;
setLightboxSrc(full);
setLightboxAlt(img.getAttribute('alt') || '');
}, []);
const handleClick = useCallback(async (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-members-login]')) {
@@ -50,6 +60,12 @@ export default function PostContent({
nav(registerPath());
return;
}
const zoomImg = target.closest<HTMLImageElement>('img.post-content-img--zoomable');
if (zoomImg) {
e.preventDefault();
openLightbox(zoomImg);
return;
}
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
if (copyBtn) {
e.preventDefault();
@@ -68,13 +84,30 @@ export default function PostContent({
notify.error('复制失败');
}
}
}, [nav]);
}, [nav, openLightbox]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const zoomImg = (e.target as HTMLElement).closest?.('img.post-content-img--zoomable');
if (!zoomImg || !(zoomImg instanceof HTMLImageElement)) return;
e.preventDefault();
openLightbox(zoomImg);
}, [openLightbox]);
return (
<div
className={className}
onClick={handleClick}
dangerouslySetInnerHTML={{ __html: prepared.html }}
/>
<>
<div
className={className}
onClick={handleClick}
onKeyDown={handleKeyDown}
dangerouslySetInnerHTML={{ __html: prepared.html }}
/>
<ImageLightbox
src={lightboxSrc}
alt={lightboxAlt}
open={!!lightboxSrc}
onClose={() => setLightboxSrc(null)}
/>
</>
);
}

View File

@@ -2,6 +2,7 @@ import { memo } from 'react';
import { MessageCircle, ThumbsUp } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge';
import PinnedIcon from '@/components/PinnedIcon';
import UserLink from '@/components/UserLink';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
import { formatTime } from '../utils/content';
@@ -22,13 +23,33 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
const commentCount = post.comment_count ?? 0;
const likeCount = post.like_count ?? 0;
const openPost = () => onSelect(post.id);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openPost();
}
};
return (
<button type="button" className="post-row" onClick={() => onSelect(post.id)}>
<div className="post-avatar">
<div
className="post-row"
role="button"
tabIndex={0}
onClick={openPost}
onKeyDown={onKeyDown}
>
<UserLink
user={post.user}
showAvatar={false}
showName={false}
stopPropagation
className="post-avatar user-link--avatar-only"
>
{post.user?.avatar
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
: initial}
</div>
</UserLink>
<div className="post-body">
<div className="post-title">
{post.pinned && <PinnedIcon className="mr-1.5" />}
@@ -36,7 +57,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
</div>
<div className="post-meta">
{post.board && <BoardBadge board={post.board} />}
<span>{post.user?.nickname || '匿名'}</span>
<UserLink user={post.user} stopPropagation className="post-meta-user" />
<span>{timeLabel}</span>
</div>
</div>
@@ -50,7 +71,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
{likeCount}
</span>
</div>
</button>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import type { PostRevision } from '../api/types';
import PostContent from './PostContent';
import { formatDateTime } from '../utils/content';
import { moveTabIndex, useOverlayA11y } from '../hooks/useOverlayA11y';
import UserLink from './UserLink';
import {
type PostSnapshot,
htmlToDiffText,
@@ -273,7 +274,14 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
</div>
<span className="post-revision-item-title">{entry.rev.title}</span>
<span className="post-revision-item-meta">
{entry.rev.editor?.nickname ?? '未知'} · {formatDateTime(entry.rev.created_at)}
<UserLink
user={entry.rev.editor
? entry.rev.editor
: { nickname: '未知' }}
stopPropagation
className="post-revision-editor-link"
/>
{' · '}{formatDateTime(entry.rev.created_at)}
</span>
</button>
</li>
@@ -291,7 +299,12 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
<div className="post-revision-main-head">
<div>
<span className="post-revision-main-editor">
{selected.rev.editor?.nickname ?? '未知'}
<UserLink
user={selected.rev.editor
? selected.rev.editor
: { nickname: '未知' }}
className="post-revision-editor-link"
/>
</span>
<span className="post-revision-main-time">
{formatDateTime(selected.rev.created_at)}

View File

@@ -4,6 +4,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import type { PostItem, RecentComment, TagCount } from '../api/types';
import { useSiteBranding } from '../hooks/useSiteBranding';
import TagCloud from './TagCloud';
import UserLink from './UserLink';
interface Props {
hot: PostItem[];
@@ -110,21 +111,39 @@ export default function RightPanel({
) : commentList.length === 0 ? (
<div className="widget-empty"></div>
) : commentList.map(item => (
<button
<div
key={item.id}
type="button"
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-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>
{item.user_id ? (
<UserLink
user={{ id: item.user_id, nickname: item.author, avatar: item.avatar }}
showAvatar={false}
showName={false}
stopPropagation
className="widget-item-avatar user-link--avatar-only"
>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</UserLink>
) : (
<span className="widget-item-avatar" aria-hidden>
{item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')}
</span>
)}
<button
type="button"
className="widget-item-comment-main"
onClick={() => onPostClick(item.post_id)}
>
<span className="widget-item-title">{item.excerpt}</span>
<span className="widget-item-time">{item.created_at}</span>
</button>
</div>
))}
</div>
</div>

View File

@@ -14,7 +14,7 @@ import { getBoardThemeIndex } from '../utils/boardTheme';
import ArticleOutline from './ArticleOutline';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile', '/user/'];
export function isNeutralSidebarRoute(pathname: string): boolean {
return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix));

View File

@@ -0,0 +1,77 @@
import type { MouseEvent, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { userPath } from '../utils/userPath';
export type UserLinkUser = {
id?: number;
nickname?: string;
avatar?: string;
} | null | undefined;
interface Props {
user: UserLinkUser;
className?: string;
avatarClassName?: string;
nameClassName?: string;
showAvatar?: boolean;
showName?: boolean;
/** 嵌在可点击父级内时阻止冒泡(如帖子列表行) */
stopPropagation?: boolean;
children?: ReactNode;
title?: string;
}
/** 统一用户入口:点击进入 /user/:id 公开主页 */
export default function UserLink({
user,
className,
avatarClassName,
nameClassName,
showAvatar = false,
showName = true,
stopPropagation = false,
children,
title,
}: Props) {
const id = user?.id && user.id > 0 ? user.id : 0;
const nick = user?.nickname?.trim() || '匿名';
const initial = nick[0] || '?';
const tip = title || nick;
const onClick = stopPropagation
? (e: MouseEvent) => { e.stopPropagation(); }
: undefined;
const body = children ?? (
<>
{showAvatar && (
<span className={cn('user-link-avatar', avatarClassName)} aria-hidden>
{user?.avatar
? <img src={user.avatar} alt="" loading="lazy" decoding="async" />
: initial}
</span>
)}
{showName && <span className={cn('user-link-name', nameClassName)}>{nick}</span>}
</>
);
if (!id) {
return (
<span className={cn('user-link user-link--static', className)} title={tip}>
{body}
</span>
);
}
return (
<Link
to={userPath(id)}
className={cn('user-link', className)}
title={tip}
onClick={onClick}
>
{body}
</Link>
);
}

View File

@@ -0,0 +1,67 @@
import Image from '@tiptap/extension-image';
import { mergeAttributes } from '@tiptap/core';
/** 单图展示形态(对齐 Notion / Medium 常见选项) */
export type ImageDisplay = 'default' | 'wide' | 'float-left' | 'float-right';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
articleImage: {
setImageDisplay: (display: ImageDisplay) => ReturnType;
};
}
}
/**
* 文章图片:在 TipTap Image 上增加 data-display
* 支持通栏 / 左绕排 / 右绕排。
*/
export const ArticleImage = Image.extend({
name: 'image',
addAttributes() {
return {
...this.parent?.(),
display: {
default: 'default' satisfies ImageDisplay,
parseHTML: (el) =>
(el.getAttribute('data-display') as ImageDisplay) || 'default',
renderHTML: (attrs) => {
const display = (attrs.display as ImageDisplay) || 'default';
if (display === 'default') return {};
return {
'data-display': display,
class: `article-img article-img--${display}`,
};
},
},
};
},
renderHTML({ HTMLAttributes }) {
const display = (HTMLAttributes['data-display'] as ImageDisplay) || 'default';
const cls = [
HTMLAttributes.class,
'article-img',
display !== 'default' ? `article-img--${display}` : '',
]
.filter(Boolean)
.join(' ');
return [
'img',
mergeAttributes(HTMLAttributes, {
class: cls || undefined,
draggable: false,
}),
];
},
addCommands() {
return {
...this.parent?.(),
setImageDisplay: (display) => ({ commands }) =>
commands.updateAttributes(this.name, { display }),
};
},
});

View File

@@ -0,0 +1,144 @@
import Paragraph from '@tiptap/extension-paragraph';
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import type { Node as ProseMirrorNode, NodeType } from '@tiptap/pm/model';
import type { Transaction } from '@tiptap/pm/state';
function isFloatImage(node: ProseMirrorNode): boolean {
if (node.type.name !== 'image') return false;
const display = node.attrs.display as string | undefined;
return display === 'float-left' || display === 'float-right';
}
function isBlankParagraph(node: ProseMirrorNode): boolean {
if (node.type.name !== 'paragraph') return false;
if (node.content.size === 0) return true;
let blank = true;
node.forEach(child => {
if (child.type.name === 'hardBreak') return;
if (child.isText && !(child.text || '').replace(/\u00a0/g, ' ').trim()) return;
blank = false;
});
return blank;
}
function isHardClearBlock(node: ProseMirrorNode): boolean {
const name = node.type.name;
if (name === 'image') return !isFloatImage(node);
return name === 'imageGroup'
|| name === 'heading'
|| name === 'horizontalRule'
|| name === 'codeBlock'
|| name === 'blockquote'
|| name === 'table'
|| name === 'bulletList'
|| name === 'orderedList';
}
/**
* 计算顶层块是否应带 clearFloat。
* - 双空行后自动打开
* - 一旦打开,只要仍在绕排图之后就保持(避免源码往返丢空段后失效)
*/
function computeClearFloatFlags(doc: ProseMirrorNode): boolean[] {
const flags: boolean[] = [];
let seenFloat = false;
let blankRun = 0;
doc.forEach(node => {
if (isFloatImage(node)) {
seenFloat = true;
blankRun = 0;
flags.push(false);
return;
}
if (isHardClearBlock(node)) {
seenFloat = false;
blankRun = 0;
flags.push(false);
return;
}
if (!seenFloat) {
flags.push(false);
blankRun = 0;
return;
}
if (isBlankParagraph(node)) {
blankRun += 1;
flags.push(false);
return;
}
const already = Boolean(node.attrs.clearFloat);
flags.push(blankRun >= 2 || already);
blankRun = 0;
});
return flags;
}
function syncClearFloatAttrs(doc: ProseMirrorNode, paragraphType: NodeType, tr: Transaction): boolean {
const flags = computeClearFloatFlags(doc);
let modified = false;
let index = 0;
doc.forEach((node, offset) => {
const should = flags[index] ?? false;
index += 1;
if (node.type !== paragraphType) return;
const current = Boolean(node.attrs.clearFloat);
if (current === should) return;
tr.setNodeMarkup(offset, undefined, { ...node.attrs, clearFloat: should });
modified = true;
});
return modified;
}
/** 段落:支持 data-clear-float源码往返可保留「写到绕排图下方」 */
export const ClearFloatParagraph = Paragraph.extend({
addAttributes() {
return {
...this.parent?.(),
clearFloat: {
default: false,
parseHTML: (el) => el.hasAttribute('data-clear-float'),
renderHTML: (attrs) => (
attrs.clearFloat
? { 'data-clear-float': '', class: 'article-clear-float' }
: {}
),
},
};
},
});
const syncKey = new PluginKey('clearFloatSync');
/** 根据双空行自动写入/保持段落 clearFloat 属性 */
export const ClearFloatSync = Extension.create({
name: 'clearFloatSync',
addProseMirrorPlugins() {
return [
new Plugin({
key: syncKey,
appendTransaction(transactions, _oldState, newState) {
if (!transactions.some(tr => tr.docChanged)) return null;
if (transactions.some(tr => tr.getMeta(syncKey))) return null;
const paragraphType = newState.schema.nodes.paragraph;
if (!paragraphType) return null;
const tr = newState.tr;
if (!syncClearFloatAttrs(newState.doc, paragraphType, tr)) return null;
tr.setMeta(syncKey, true);
return tr;
},
}),
];
},
});

View File

@@ -0,0 +1,371 @@
import { Node, mergeAttributes } from '@tiptap/core';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { NodeSelection } from '@tiptap/pm/state';
import type { EditorState } from '@tiptap/pm/state';
import {
ReactNodeViewRenderer,
NodeViewWrapper,
NodeViewContent,
type NodeViewProps,
} from '@tiptap/react';
import { Columns2, Columns3, LayoutGrid, Plus, Ungroup } from 'lucide-react';
import { api } from '../../api/client';
import { notify } from '@/lib/notify';
export type ImageGroupLayout = 'cols-2' | 'cols-3' | 'cols-4';
const LAYOUTS: { key: ImageGroupLayout; label: string; icon: typeof Columns2; hint: string }[] = [
{ key: 'cols-2', label: '两列', icon: Columns2, hint: '并排两张' },
{ key: 'cols-3', label: '三列', icon: Columns3, hint: '并排三张' },
{ key: 'cols-4', label: '四列', icon: LayoutGrid, hint: '四宫格' },
];
/** 按张数推荐默认布局 */
export function suggestImageGroupLayout(count: number): ImageGroupLayout {
if (count >= 4) return 'cols-4';
if (count === 3) return 'cols-3';
return 'cols-2';
}
function findImageGroupDepth($pos: {
depth: number;
node: (d: number) => { type: { name: string } };
}): number {
for (let d = $pos.depth; d > 0; d -= 1) {
if ($pos.node(d).type.name === 'imageGroup') return d;
}
return -1;
}
/** 空段落可夹在连续图片之间,合并时一并吃掉 */
function isEmptyParagraph(node: ProseMirrorNode): boolean {
return node.type.name === 'paragraph' && node.content.size === 0;
}
/** 定位一张可作为合并起点的图片位置 */
function findAnchorImagePos(state: EditorState): number | null {
const { selection, doc } = state;
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
return selection.from;
}
const { $from, from, to } = selection;
if (findImageGroupDepth($from) >= 0) return null;
let firstImagePos: number | null = null;
doc.nodesBetween(from, Math.max(to, from + 1), (node, pos) => {
if (node.type.name === 'image' && firstImagePos == null) {
firstImagePos = pos;
return false;
}
return undefined;
});
if (firstImagePos != null) return firstImagePos;
if ($from.nodeBefore?.type.name === 'image') {
return $from.pos - $from.nodeBefore.nodeSize;
}
if ($from.nodeAfter?.type.name === 'image') {
return $from.pos;
}
// 光标在图片之间的段落时:沿祖先层级找相邻图片块
for (let depth = $from.depth; depth >= 1; depth -= 1) {
const parent = $from.node(depth);
const index = $from.index(depth);
for (let i = index - 1; i >= 0; i -= 1) {
const n = parent.child(i);
if (n.type.name === 'image') return $from.posAtIndex(i, depth);
if (!isEmptyParagraph(n)) break;
}
for (let i = index + 1; i < parent.childCount; i += 1) {
const n = parent.child(i);
if (n.type.name === 'image') return $from.posAtIndex(i, depth);
if (!isEmptyParagraph(n)) break;
}
}
return null;
}
/**
* 以某张图为锚点,向两侧扩展「连续图片块」
* (允许中间夹空段落;富文本难以框选多张 atom 图片)
*/
function collectConsecutiveImageRun(
state: EditorState,
imagePos: number,
): { from: number; to: number; images: ProseMirrorNode[] } | null {
const node = state.doc.nodeAt(imagePos);
if (!node || node.type.name !== 'image') return null;
const $pos = state.doc.resolve(imagePos);
const parent = $pos.parent;
const index = $pos.index();
if (parent.child(index) !== node) return null;
let start = index;
while (start > 0) {
const prev = parent.child(start - 1);
if (prev.type.name === 'image' || isEmptyParagraph(prev)) start -= 1;
else break;
}
while (start < index && isEmptyParagraph(parent.child(start))) start += 1;
let end = index;
while (end < parent.childCount - 1) {
const next = parent.child(end + 1);
if (next.type.name === 'image' || isEmptyParagraph(next)) end += 1;
else break;
}
while (end > index && isEmptyParagraph(parent.child(end))) end -= 1;
const images: ProseMirrorNode[] = [];
for (let i = start; i <= end; i += 1) {
const child = parent.child(i);
if (child.type.name === 'image') images.push(child);
}
if (images.length < 2) return null;
let from = $pos.start();
for (let i = 0; i < start; i += 1) from += parent.child(i).nodeSize;
let to = from;
for (let i = start; i <= end; i += 1) to += parent.child(i).nodeSize;
return { from, to, images };
}
function ImageGroupView({ selected, editor, node, getPos }: NodeViewProps) {
const layout = (node.attrs.layout as ImageGroupLayout) || 'cols-2';
const count = node.childCount;
const setLayout = (next: ImageGroupLayout) => {
const pos = getPos();
if (typeof pos !== 'number') {
editor.chain().focus().setImageGroupLayout(next).run();
return;
}
editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
if (dispatch) tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: next });
return true;
})
.run();
};
const unwrap = () => {
const pos = getPos();
if (typeof pos !== 'number') {
editor.chain().focus().unwrapImageGroup().run();
return;
}
editor
.chain()
.focus()
.command(({ tr, dispatch }) => {
if (dispatch) tr.replaceWith(pos, pos + node.nodeSize, node.content);
return true;
})
.run();
};
const addImage = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.multiple = true;
input.onchange = async () => {
const files = [...(input.files ?? [])];
if (!files.length) return;
const urls: string[] = [];
for (const file of files) {
try {
const { url } = await api.uploadPostImage(file);
urls.push(url);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '图片上传失败');
}
}
if (!urls.length) return;
const pos = getPos();
if (typeof pos !== 'number') return;
editor
.chain()
.focus()
.command(({ tr, dispatch, state }) => {
const imageType = state.schema.nodes.image;
if (!imageType || !dispatch) return false;
let cur = pos + node.nodeSize - 1;
for (const src of urls) {
const img = imageType.create({ src });
tr.insert(cur, img);
cur += img.nodeSize;
}
const nextLayout = suggestImageGroupLayout(count + urls.length);
tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: nextLayout });
return true;
})
.run();
};
input.click();
};
return (
<NodeViewWrapper
className={`image-group image-group--${layout}${selected ? ' image-group--selected' : ''}`}
data-image-group=""
data-layout={layout}
>
<div className="image-group__toolbar" contentEditable={false}>
<span className="image-group__toolbar-label"> · {count} </span>
<div className="image-group__toolbar-actions">
{LAYOUTS.map(item => {
const Icon = item.icon;
return (
<button
key={item.key}
type="button"
className={`image-group__layout-btn${layout === item.key ? ' is-active' : ''}`}
title={item.hint}
onMouseDown={e => e.preventDefault()}
onClick={() => setLayout(item.key)}
>
<Icon size={14} />
<span>{item.label}</span>
</button>
);
})}
<button
type="button"
className="image-group__layout-btn"
title="向本组追加图片"
onMouseDown={e => e.preventDefault()}
onClick={addImage}
>
<Plus size={14} />
<span></span>
</button>
<button
type="button"
className="image-group__layout-btn"
title="拆开为单独图片"
onMouseDown={e => e.preventDefault()}
onClick={unwrap}
>
<Ungroup size={14} />
<span></span>
</button>
</div>
</div>
<NodeViewContent className="image-group__grid" as="div" />
</NodeViewWrapper>
);
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
imageGroup: {
insertImageGroup: (srcs: string[], layout?: ImageGroupLayout) => ReturnType;
setImageGroupLayout: (layout: ImageGroupLayout) => ReturnType;
unwrapImageGroup: () => ReturnType;
wrapImagesInGroup: () => ReturnType;
};
}
}
/** TipTap 图组:多图并排 / 宫格布局 */
export const ImageGroup = Node.create({
name: 'imageGroup',
group: 'block',
content: 'image+',
defining: true,
isolating: true,
addAttributes() {
return {
layout: {
default: 'cols-2' satisfies ImageGroupLayout,
parseHTML: (el) => (el.getAttribute('data-layout') as ImageGroupLayout) || 'cols-2',
renderHTML: (attrs) => ({ 'data-layout': attrs.layout || 'cols-2' }),
},
};
},
parseHTML() {
return [{ tag: 'div[data-image-group]' }];
},
renderHTML({ HTMLAttributes }) {
const layout = HTMLAttributes['data-layout'] || 'cols-2';
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-image-group': '',
'data-layout': layout,
class: `image-group image-group--${layout}`,
}),
0,
];
},
addNodeView() {
return ReactNodeViewRenderer(ImageGroupView);
},
addCommands() {
return {
insertImageGroup: (srcs, layout) => ({ chain }) => {
if (!srcs.length) return false;
const nextLayout = layout || suggestImageGroupLayout(srcs.length);
return chain()
.insertContent({
type: this.name,
attrs: { layout: nextLayout },
content: srcs.map(src => ({ type: 'image', attrs: { src } })),
})
.run();
},
setImageGroupLayout: (layout) => ({ commands }) =>
commands.updateAttributes(this.name, { layout }),
unwrapImageGroup: () => ({ tr, state, dispatch }) => {
const { $from } = state.selection;
const depth = findImageGroupDepth($from);
if (depth < 0) return false;
const pos = $from.before(depth);
const node = $from.node(depth);
tr.replaceWith(pos, pos + node.nodeSize, node.content);
if (dispatch) dispatch(tr);
return true;
},
/**
* 将光标/选区附近的连续图片包成图组。
* TipTap 图片是 atom无法像文本那样拖选多张因此自动扩展相邻图片。
*/
wrapImagesInGroup: () => ({ tr, state, dispatch }) => {
const anchor = findAnchorImagePos(state);
if (anchor == null) return false;
const run = collectConsecutiveImageRun(state, anchor);
if (!run) return false;
const groupType = state.schema.nodes.imageGroup;
if (!groupType) return false;
const group = groupType.create(
{ layout: suggestImageGroupLayout(run.images.length) },
run.images,
);
if (dispatch) {
tr.replaceWith(run.from, run.to, group);
// 选中新建图组,便于立刻改列数
tr.setSelection(NodeSelection.create(tr.doc, run.from));
}
return true;
},
};
},
});

View File

@@ -12,6 +12,7 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
page_size_default: 30,
password_min_len: 6,
avatar_max_mb: 2,
signature_max: 200,
open_posts_in_new_tab: true,
open_content_links_in_new_tab: true,
};

View File

@@ -29,8 +29,15 @@ function fetchBranding(): Promise<SiteBranding> {
return inflight;
}
/** 浏览器标签标题:站点名 - 副标题(标语) */
export function formatDocumentTitle(brand: SiteBranding): string {
const name = brand.name.trim();
const subtitle = brand.slogan.trim();
return subtitle ? `${name} - ${subtitle}` : name;
}
function applyDocumentBrand(brand: SiteBranding) {
const title = brand.name_en ? `${brand.name} ${brand.name_en}` : brand.name;
const title = formatDocumentTitle(brand);
if (document.title !== title) document.title = title;
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'rea
import PageLoader from '../components/PageLoader';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Moon, Sun, Search, Plus, PanelRight, X } from 'lucide-react';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -54,6 +54,7 @@ export default function MainLayout() {
title?: string;
} | null>(null);
const [asideOpen, setAsideOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
const asideEverLoaded = useRef(false);
@@ -64,16 +65,34 @@ export default function MainLayout() {
const asideDrawerRef = useRef<HTMLElement>(null);
const asideCloseRef = useRef<HTMLButtonElement>(null);
const sidebarDrawerRef = useRef<HTMLElement>(null);
const sidebarCloseRef = useRef<HTMLButtonElement>(null);
const boardBarRef = useRef<HTMLDivElement>(null);
const closeAside = useCallback(() => setAsideOpen(false), []);
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
const openAside = useCallback(() => {
setSidebarOpen(false);
setAsideOpen(true);
}, []);
const openSidebar = useCallback(() => {
setAsideOpen(false);
setSidebarOpen(true);
}, []);
useOverlayA11y(asideOpen && hideAside && !isCompose, closeAside, asideDrawerRef, {
initialFocusRef: asideCloseRef,
});
useOverlayA11y(sidebarOpen && isMobile && !isCompose, closeSidebar, sidebarDrawerRef, {
initialFocusRef: sidebarCloseRef,
});
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
useEffect(() => { setAsideOpen(false); }, [loc.pathname, loc.search]);
useEffect(() => {
setAsideOpen(false);
setSidebarOpen(false);
}, [loc.pathname, loc.search]);
useEffect(() => {
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
}, [loc.pathname]);
@@ -81,11 +100,14 @@ export default function MainLayout() {
if (!hideAside) setAsideOpen(false);
}, [hideAside]);
useEffect(() => {
if (!asideOpen) return;
if (!isMobile) setSidebarOpen(false);
}, [isMobile]);
useEffect(() => {
if (!asideOpen && !sidebarOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [asideOpen]);
}, [asideOpen, sidebarOpen]);
const refreshBoards = useCallback(() => {
return Promise.all([
@@ -237,6 +259,19 @@ export default function MainLayout() {
<div className="app-frame">
<header className="app-header">
<div className="header-inner">
{isMobile && !isCompose && (
<button
type="button"
className="header-icon-btn"
onClick={openSidebar}
aria-label={isPostDetail ? '打开目录与导航' : '打开导航菜单'}
aria-expanded={sidebarOpen}
aria-controls="sidebar-drawer"
title="导航"
>
<Menu size={18} aria-hidden />
</button>
)}
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
<SiteBrandMark branding={branding} className="header-logo-mark" />
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
@@ -284,7 +319,7 @@ export default function MainLayout() {
<button
type="button"
className="header-icon-btn"
onClick={() => setAsideOpen(true)}
onClick={openAside}
aria-label="打开社区动态"
aria-expanded={asideOpen}
aria-controls="aside-drawer"
@@ -320,7 +355,8 @@ export default function MainLayout() {
className="w-40"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onClick={() => nav('/profile')}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav(`/user/${user.id}`)}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/profile')}></DropdownMenuItem>
<DropdownMenuItem onClick={() => nav('/favorites')}></DropdownMenuItem>
{user.role === 'admin' && (
<>
@@ -419,6 +455,51 @@ export default function MainLayout() {
</div>
</div>
{sidebarOpen && isMobile && !isCompose && (
<div className="sidebar-drawer-root">
<button
type="button"
className="aside-drawer-backdrop"
aria-label="关闭导航菜单"
tabIndex={-1}
onClick={closeSidebar}
/>
<aside
id="sidebar-drawer"
ref={sidebarDrawerRef}
className="sidebar-drawer"
role="dialog"
aria-modal="true"
aria-label={isPostDetail ? '目录与导航' : '导航菜单'}
>
<div className="aside-drawer-head">
<span>{isPostDetail ? '目录与导航' : '导航'}</span>
<button
ref={sidebarCloseRef}
type="button"
className="header-icon-btn"
aria-label="关闭"
onClick={closeSidebar}
>
<X size={18} aria-hidden />
</button>
</div>
<div className="aside-drawer-body sidebar-drawer-body">
<Sidebar
boards={boards}
activeBoard={boardId}
onSelectBoard={setBoardId}
boardsLoading={boardsLoading}
outlineMode={isPostDetail}
outlineHeadings={postOutline?.headings ?? []}
outlineScrollRoot={postOutline?.scrollRoot ?? null}
outlineTitle={postOutline?.title}
/>
</div>
</aside>
</div>
)}
{asideOpen && hideAside && !isCompose && (
<div className="aside-drawer-root">
<button

View File

@@ -323,85 +323,90 @@ export default function ComposePage() {
return (
<div className="compose-page">
<div className="compose-canvas">
<header className="compose-header">
<button
type="button"
className="compose-back"
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"
disabled={publishing}
onClick={handleSubmit}
>
<Send size={16} />
{publishing ? (isEdit ? '保存中…' : '发布中…') : (isEdit ? '保存修改' : '发布帖子')}
</button>
</div>
</header>
<div className="compose-meta">
{!isEdit ? (
<div className="compose-board-pills">
{boards.map(b => (
<button
key={b.id}
type="button"
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
onClick={() => setBoardId(String(b.id))}
>
{b.name}
</button>
))}
</div>
) : currentBoard && (
<div className="compose-board-pills">
<span className="compose-board-pill active">{currentBoard.name}</span>
</div>
)}
<TagInput
value={tags}
onChange={setTags}
placeholder="输入标签后回车"
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
/>
</div>
<div className="compose-writing">
<input
className="compose-title"
type="text"
placeholder="输入文章标题…"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
/>
{currentBoard && (
<div className="compose-subtitle">
{isEdit ? '编辑于' : '发布至'} <strong>{currentBoard.name}</strong>
{editWindowHint && (
<span className="compose-edit-window"> · {editWindowHint}</span>
<div className="compose-shell">
<header className="compose-header">
<div className="compose-header-left">
<button
type="button"
className="compose-back"
onClick={() => requestLeave(() => {
if (isEdit) nav(`/post/${editId}`);
else nav(-1);
})}
>
<ArrowLeft size={16} />
<span></span>
</button>
<h1 className="compose-header-title">{isEdit ? '编辑帖子' : '写新帖'}</h1>
{(draftHint || editWindowHint) && (
<span className="compose-draft-hint" title={editWindowHint || draftHint}>
{editWindowHint || draftHint}
</span>
)}
</div>
)}
<ArticleEditor
value={content}
onChange={setContent}
placeholder="开始写作。所见即所得,选中文字后使用工具栏设置格式。"
/>
<div className="compose-header-actions">
<button
type="button"
className="compose-publish-btn"
disabled={publishing}
onClick={handleSubmit}
>
<Send size={16} />
{publishing ? (isEdit ? '保存中…' : '发布中…') : (isEdit ? '保存修改' : '发布')}
</button>
</div>
</header>
<section className="compose-context" aria-label="发布设置">
<div className="compose-context-row">
<span className="compose-context-label"></span>
{!isEdit ? (
<div className="compose-board-pills" role="listbox" aria-label="选择板块">
{boards.map(b => (
<button
key={b.id}
type="button"
role="option"
aria-selected={String(b.id) === boardId}
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
onClick={() => setBoardId(String(b.id))}
>
{b.name}
</button>
))}
</div>
) : currentBoard ? (
<div className="compose-board-pills">
<span className="compose-board-pill active">{currentBoard.name}</span>
</div>
) : null}
</div>
<div className="compose-context-row compose-context-row--tags">
<span className="compose-context-label"></span>
<TagInput
value={tags}
onChange={setTags}
placeholder="添加标签,回车确认"
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
/>
</div>
</section>
<div className="compose-document">
<input
className="compose-title"
type="text"
placeholder="输入文章标题…"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
/>
<ArticleEditor
value={content}
onChange={setContent}
placeholder="开始写作。按回车分段,选中文字后用工具栏设置格式。"
/>
</div>
</div>
</div>
<UnsavedChangesDialog

View File

@@ -5,6 +5,7 @@ import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import BoardBadge from '@/components/BoardBadge';
import UserLink from '@/components/UserLink';
import { Spinner } from '@/components/ui/spinner';
import {
AlertDialog,
@@ -340,11 +341,18 @@ export default function PostDetailPage() {
{post.title}
</h1>
<div className="post-detail-author-row">
<div className="post-avatar post-avatar-lg">
{post.user?.avatar ? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" /> : authorInitial}
</div>
<UserLink
user={post.user}
showAvatar={false}
showName={false}
className="post-avatar post-avatar-lg user-link--avatar-only"
>
{post.user?.avatar
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
: authorInitial}
</UserLink>
<div className="post-detail-author-info">
<span className="post-detail-author-name">{post.user?.nickname}</span>
<UserLink user={post.user} className="post-detail-author-name" />
<span className="post-detail-meta-line">
{formatDateTime(post.created_at)}
{showEdited && (

View File

@@ -1,26 +1,52 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, Camera, LayoutDashboard, Settings, Upload, X } from 'lucide-react';
import {
ArrowLeft,
Camera,
Check,
Copy,
FileText,
Hash,
Heart,
LayoutDashboard,
MessageCircle,
PenLine,
Settings,
Star,
Upload,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { useAuth } from '../hooks/useAuth';
import { api } from '../api/client';
import type { PostItem, UserActivityStats } from '../api/types';
import { useForumLimits } from '../hooks/useForumLimits';
import AvatarCropDialog from '../components/AvatarCropDialog';
import PostListItem from '../components/PostListItem';
import FeedPagination from '../components/FeedPagination';
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
import { loginPath } from '../utils/authRedirect';
import { openForumPost } from '../utils/openPost';
import { formatDateTime } from '../utils/content';
import { userPath } from '../utils/userPath';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
});
const sigSchema = (maxLen: number) => z.object({
signature: z.string().max(maxLen > 0 ? maxLen : 512, `签名不能超过 ${maxLen || 512}`),
});
const pwdSchema = (minLen: number) => z.object({
old_password: z.string().min(1, '请输入当前密码'),
new_password: z.string().min(minLen, `新密码至少 ${minLen}`),
@@ -31,12 +57,22 @@ const pwdSchema = (minLen: number) => z.object({
});
type NickValues = z.infer<typeof nickSchema>;
type SigValues = z.infer<ReturnType<typeof sigSchema>>;
type PwdValues = z.infer<ReturnType<typeof pwdSchema>>;
type ProfileTab = 'posts' | 'settings' | 'security';
function parseTab(raw: string | null): ProfileTab {
if (raw === 'settings' || raw === 'security' || raw === 'posts') return raw;
return 'posts';
}
export default function ProfilePage() {
const nav = useNavigate();
const [params, setParams] = useSearchParams();
const tab = parseTab(params.get('tab'));
const { user, loading: authLoading, refresh } = useAuth();
const [nickLoading, setNickLoading] = useState(false);
const [sigLoading, setSigLoading] = useState(false);
const [pwdLoading, setPwdLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
@@ -45,21 +81,44 @@ export default function ProfilePage() {
const [cropImageSrc, setCropImageSrc] = useState<string | null>(null);
const [cropFileName, setCropFileName] = useState('');
const [dragOver, setDragOver] = useState(false);
const [idCopied, setIdCopied] = useState(false);
const [stats, setStats] = useState<UserActivityStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postsLoading, setPostsLoading] = useState(false);
const [postPage, setPostPage] = useState(1);
const [postTotal, setPostTotal] = useState(0);
const fileRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0);
const copyTimer = useRef<ReturnType<typeof setTimeout>>();
const { limits } = useForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
const nickForm = useForm<NickValues>({
resolver: zodResolver(nickSchema),
values: { nickname: user?.nickname ?? '' },
});
const sigMax = limits.signature_max > 0 ? limits.signature_max : 200;
const sigForm = useForm<SigValues>({
resolver: zodResolver(sigSchema(sigMax)),
values: { signature: user?.signature ?? '' },
});
const pwdForm = useForm<PwdValues>({
resolver: zodResolver(pwdSchema(limits.password_min_len)),
defaultValues: { old_password: '', new_password: '', confirm_password: '' },
});
const setTab = (next: ProfileTab) => {
const nextParams = new URLSearchParams(params);
if (next === 'posts') nextParams.delete('tab');
else nextParams.set('tab', next);
setParams(nextParams, { replace: true });
};
useEffect(() => {
if (!authLoading && !user) {
nav(loginPath('/profile'));
@@ -78,6 +137,42 @@ export default function ProfilePage() {
};
}, [cropImageSrc]);
useEffect(() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
}, []);
const loadStats = useCallback(() => {
setStatsLoading(true);
api.profileStats()
.then(d => setStats(d.stats))
.catch(() => setStats(null))
.finally(() => setStatsLoading(false));
}, []);
useEffect(() => {
if (!user) return;
loadStats();
}, [user, loadStats]);
useEffect(() => {
if (!user || tab !== 'posts') return;
let cancelled = false;
setPostsLoading(true);
api.posts({ user_id: user.id, page: postPage, size: pageSize, sort: 'latest' })
.then(d => {
if (cancelled) return;
setPosts(Array.isArray(d.posts) ? d.posts : []);
setPostTotal(d.total ?? 0);
})
.catch(e => {
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载帖子失败');
})
.finally(() => {
if (!cancelled) setPostsLoading(false);
});
return () => { cancelled = true; };
}, [user, tab, postPage, pageSize]);
const closeCropDialog = useCallback((open: boolean) => {
if (!open) {
setCropOpen(false);
@@ -109,6 +204,19 @@ export default function ProfilePage() {
}
};
const onUpdateSig = async (values: SigValues) => {
setSigLoading(true);
try {
await api.updateSignature(values.signature);
await refresh();
notify.success('签名已更新');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '更新失败');
} finally {
setSigLoading(false);
}
};
const onUpdatePwd = async (values: PwdValues) => {
setPwdLoading(true);
try {
@@ -132,7 +240,7 @@ export default function ProfilePage() {
};
const openCropForFile = (file: File) => {
const err = validateAvatarFile(file, limits.avatar_max_mb);
const err = validateAvatarFile(file);
if (err) {
notify.error(err);
if (fileRef.current) fileRef.current.value = '';
@@ -204,7 +312,26 @@ export default function ProfilePage() {
if (file) openCropForFile(file);
};
const copyUserId = async () => {
try {
await navigator.clipboard.writeText(String(user.id));
setIdCopied(true);
notify.success('已复制用户 ID');
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setIdCopied(false), 1600);
} catch {
notify.error('复制失败,请手动选择');
}
};
const displayAvatar = avatarPreview ?? user.avatar;
const joinedAt = user.created_at ? formatDateTime(user.created_at) : '';
const tabs: { key: ProfileTab; label: string; count?: number }[] = [
{ key: 'posts', label: '我的帖子', count: stats?.post_count },
{ key: 'settings', label: '资料设置' },
{ key: 'security', label: '安全设置' },
];
return (
<div className="page-wrap">
@@ -254,12 +381,58 @@ export default function ProfilePage() {
</span>
</div>
</button>
<div className="profile-header-info">
<div className="profile-header-main">
<h1 className="profile-display-name">{user.nickname}</h1>
<div className="profile-name-row">
<h2 className="profile-display-name">{user.nickname}</h2>
{user.role === 'admin' && <Badge variant="green"></Badge>}
</div>
<div className="profile-username">@{user.username}</div>
<p className="profile-avatar-tip"></p>
{user.role === 'admin' && <Badge variant="green" className="mt-1.5"></Badge>}
<div className="profile-id-row">
<span className="profile-id-chip" title="用户 ID">
<Hash size={13} aria-hidden />
UID {user.id}
</span>
<button
type="button"
className="profile-id-copy"
onClick={copyUserId}
aria-label="复制用户 ID"
>
{idCopied ? <Check size={14} /> : <Copy size={14} />}
{idCopied ? '已复制' : '复制'}
</button>
<button
type="button"
className="profile-id-copy"
onClick={() => nav(userPath(user.id))}
>
</button>
</div>
{user.signature?.trim() ? (
<p className="profile-signature">{user.signature}</p>
) : (
<p className="profile-signature profile-signature--empty"></p>
)}
<dl className="profile-meta-list">
<div>
<dt></dt>
<dd>{user.username}</dd>
</div>
<div>
<dt></dt>
<dd>{user.email || '未设置'}</dd>
</div>
{joinedAt && (
<div>
<dt></dt>
<dd>{joinedAt}</dd>
</div>
)}
</dl>
<p className="profile-avatar-tip"></p>
</div>
{pendingAvatar && (
<div className="profile-avatar-actions">
@@ -279,12 +452,36 @@ export default function ProfilePage() {
</div>
)}
</div>
<div className="profile-stat-grid" aria-label="活动统计">
<button type="button" className="profile-stat" onClick={() => setTab('posts')}>
<FileText size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.post_count ?? 0)}</strong>
<span></span>
</button>
<div className="profile-stat">
<MessageCircle size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.comment_count ?? 0)}</strong>
<span></span>
</div>
<button type="button" className="profile-stat" onClick={() => nav('/favorites')}>
<Star size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.favorite_count ?? 0)}</strong>
<span></span>
</button>
<div className="profile-stat">
<Heart size={16} aria-hidden />
<strong>{statsLoading ? '—' : (stats?.like_received ?? 0)}</strong>
<span></span>
</div>
</div>
</div>
<AvatarCropDialog
open={cropOpen}
imageSrc={cropImageSrc}
fileName={cropFileName}
maxMb={limits.avatar_max_mb}
onOpenChange={closeCropDialog}
onConfirm={onCropConfirm}
/>
@@ -308,96 +505,190 @@ export default function ProfilePage() {
</div>
)}
<div className="section-card">
<div className="section-card-title"></div>
<Form {...nickForm}>
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.username} disabled />
</FormControl>
</FormItem>
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.email || '未设置'} disabled />
</FormControl>
</FormItem>
<FormField
control={nickForm.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input maxLength={64} placeholder="显示名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer">
<span className="profile-form-hint">
JPGPNGGIFWebP {limits.avatar_max_mb}MB
</span>
<Button type="submit" loading={nickLoading}></Button>
</div>
</form>
</Form>
<div className="profile-tabs" role="tablist" aria-label="个人中心分区">
{tabs.map(t => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
className={`profile-tab${tab === t.key ? ' active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
{typeof t.count === 'number' && (
<span className="profile-tab-count">{t.count}</span>
)}
</button>
))}
</div>
<div className="section-card">
<div className="section-card-title"></div>
<Form {...pwdForm}>
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
<FormField
control={pwdForm.control}
name="old_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="输入当前密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="new_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder={`至少 ${limits.password_min_len}`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="再次输入新密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer profile-form-footer--end">
<Button type="submit" variant="destructive" loading={pwdLoading}>
</Button>
{tab === 'posts' && (
<div className="profile-panel">
{postsLoading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : posts.length === 0 ? (
<div className="empty-state">
<PenLine className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<Button onClick={() => nav('/compose')}></Button>
</div>
</form>
</Form>
</div>
) : (
<>
<div className="content-surface">
{posts.map(post => (
<PostListItem
key={post.id}
post={post}
onSelect={(id) => openForumPost(nav, id, limits.open_posts_in_new_tab)}
/>
))}
</div>
{totalPages > 1 && (
<FeedPagination
page={postPage}
totalPages={totalPages}
postTotal={postTotal}
loading={postsLoading}
onPageChange={setPostPage}
/>
)}
</>
)}
</div>
)}
{tab === 'settings' && (
<div className="section-card">
<div className="section-card-title"></div>
<Form {...nickForm}>
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
<FormItem>
<FormLabel> ID</FormLabel>
<FormControl>
<Input value={String(user.id)} disabled />
</FormControl>
</FormItem>
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.username} disabled />
</FormControl>
</FormItem>
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.email || '未设置'} disabled />
</FormControl>
</FormItem>
<FormField
control={nickForm.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input maxLength={64} placeholder="显示名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer">
<span className="profile-form-hint">
ID JPG / PNG / GIF / WebP {limits.avatar_max_mb}MB
</span>
<Button type="submit" loading={nickLoading}></Button>
</div>
</form>
</Form>
<div className="profile-form-divider" />
<Form {...sigForm}>
<form onSubmit={sigForm.handleSubmit(onUpdateSig)} className="profile-form">
<FormField
control={sigForm.control}
name="signature"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
rows={3}
maxLength={sigMax}
placeholder="写一句介绍自己的话,会显示在公开主页"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer">
<span className="profile-form-hint">
{(sigForm.watch('signature') || '').length}/{sigMax}
</span>
<Button type="submit" loading={sigLoading}></Button>
</div>
</form>
</Form>
</div>
)}
{tab === 'security' && (
<div className="section-card">
<div className="section-card-title"></div>
<Form {...pwdForm}>
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
<FormField
control={pwdForm.control}
name="old_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="输入当前密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="new_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder={`至少 ${limits.password_min_len}`} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="再次输入新密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="profile-form-footer profile-form-footer--end">
<Button type="submit" variant="destructive" loading={pwdLoading}>
</Button>
</div>
</form>
</Form>
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
ArrowLeft,
FileText,
Hash,
Heart,
MessageCircle,
PenLine,
Settings,
Star,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem, UserActivityStats, UserPublic } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import { useForumLimits } from '../hooks/useForumLimits';
import PostListItem from '../components/PostListItem';
import FeedPagination from '../components/FeedPagination';
import { openForumPost } from '../utils/openPost';
import { formatDateTime } from '../utils/content';
export default function UserProfilePage() {
const { id: idParam } = useParams();
const userId = Number(idParam);
const nav = useNavigate();
const { user: me } = useAuth();
const { limits } = useForumLimits();
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
const [profile, setProfile] = useState<UserPublic | null>(null);
const [stats, setStats] = useState<UserActivityStats | null>(null);
const [loading, setLoading] = useState(true);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postsLoading, setPostsLoading] = useState(false);
const [postPage, setPostPage] = useState(1);
const [postTotal, setPostTotal] = useState(0);
const isSelf = !!me && me.id === userId;
const totalPages = Math.max(1, Math.ceil(postTotal / pageSize));
useEffect(() => {
if (!userId || Number.isNaN(userId)) {
notify.error('无效用户');
nav('/');
return;
}
setLoading(true);
setPostPage(1);
api.userProfile(userId)
.then(d => {
setProfile(d.user);
setStats(d.stats);
})
.catch(e => {
notify.error(e instanceof Error ? e.message : '用户不存在');
nav('/');
})
.finally(() => setLoading(false));
}, [userId, nav]);
useEffect(() => {
if (!userId || Number.isNaN(userId) || !profile) return;
let cancelled = false;
setPostsLoading(true);
api.posts({ user_id: userId, page: postPage, size: pageSize, sort: 'latest' })
.then(d => {
if (cancelled) return;
setPosts(Array.isArray(d.posts) ? d.posts : []);
setPostTotal(d.total ?? 0);
})
.catch(e => {
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载帖子失败');
})
.finally(() => {
if (!cancelled) setPostsLoading(false);
});
return () => { cancelled = true; };
}, [userId, profile, postPage, pageSize]);
if (loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!profile) return null;
const joinedAt = profile.created_at ? formatDateTime(profile.created_at) : '';
const signature = profile.signature?.trim() || '';
return (
<div className="page-wrap">
<div className="page-inner-wide page-inner-wide--profile">
<Button variant="ghost" className="mb-3" onClick={() => nav(-1)}>
<ArrowLeft />
</Button>
<div className="profile-header-card profile-header-card--public">
<div className="profile-avatar-lg" aria-hidden>
{profile.avatar
? <img src={profile.avatar} alt="" loading="lazy" decoding="async" />
: profile.nickname[0]}
</div>
<div className="profile-header-info">
<div className="profile-header-main">
<div className="profile-name-row">
<h1 className="profile-display-name">{profile.nickname}</h1>
{profile.role === 'admin' && <Badge variant="green"></Badge>}
{profile.banned && <Badge variant="destructive"></Badge>}
</div>
<div className="profile-username">@{profile.username}</div>
<div className="profile-id-row">
<span className="profile-id-chip" title="用户 ID">
<Hash size={13} aria-hidden />
UID {profile.id}
</span>
</div>
{signature ? (
<p className="profile-signature">{signature}</p>
) : (
<p className="profile-signature profile-signature--empty"></p>
)}
<dl className="profile-meta-list">
{joinedAt && (
<div>
<dt></dt>
<dd>{joinedAt}</dd>
</div>
)}
</dl>
</div>
{isSelf && (
<div className="profile-avatar-actions">
<Button size="sm" variant="outline" onClick={() => nav('/profile?tab=settings')}>
<Settings size={14} />
</Button>
</div>
)}
</div>
<div className="profile-stat-grid" aria-label="活动统计">
<div className="profile-stat">
<FileText size={16} aria-hidden />
<strong>{stats?.post_count ?? 0}</strong>
<span></span>
</div>
<div className="profile-stat">
<MessageCircle size={16} aria-hidden />
<strong>{stats?.comment_count ?? 0}</strong>
<span></span>
</div>
<div className="profile-stat">
<Heart size={16} aria-hidden />
<strong>{stats?.like_received ?? 0}</strong>
<span></span>
</div>
{isSelf && (
<button type="button" className="profile-stat" onClick={() => nav('/favorites')}>
<Star size={16} aria-hidden />
<strong>{stats?.favorite_count ?? 0}</strong>
<span></span>
</button>
)}
</div>
</div>
<div className="section-card-title profile-posts-heading">
{isSelf ? '我的帖子' : `${profile.nickname} 的帖子`}
{postTotal > 0 && <span className="profile-tab-count">{postTotal}</span>}
</div>
<div className="profile-panel">
{postsLoading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : posts.length === 0 ? (
<div className="empty-state">
<PenLine className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p>{isSelf ? '还没有发布过帖子' : '暂无公开帖子'}</p>
{isSelf && <Button onClick={() => nav('/compose')}></Button>}
</div>
) : (
<>
<div className="content-surface">
{posts.map(post => (
<PostListItem
key={post.id}
post={post}
onSelect={(id) => openForumPost(nav, id, limits.open_posts_in_new_tab)}
/>
))}
</div>
{totalPages > 1 && (
<FeedPagination
page={postPage}
totalPages={totalPages}
postTotal={postTotal}
loading={postsLoading}
onPageChange={setPostPage}
/>
)}
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -84,7 +84,13 @@ export default function AdminCommentsPage() {
{c.post?.title ?? `#${c.post_id}`}
</button>
</td>
<td>{c.user?.nickname || c.guest_nick || '游客'}</td>
<td>
{c.user_id && c.user ? (
<button type="button" className="admin-text-link" onClick={() => nav(`/user/${c.user_id}`)}>
{c.user.nickname}
</button>
) : (c.guest_nick || '游客')}
</td>
<td className="max-w-[200px] truncate">{c.content}</td>
<td>{c.is_private ? <Badge variant="secondary"></Badge> : '—'}</td>
<td>{new Date(c.created_at).toLocaleString('zh-CN')}</td>

View File

@@ -73,7 +73,13 @@ export default function AdminDashboardPage() {
{p.title}
</button>
</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>
{p.user?.id ? (
<button type="button" className="admin-text-link" onClick={() => nav(`/user/${p.user!.id}`)}>
{p.user.nickname}
</button>
) : '—'}
</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
</tr>

View File

@@ -145,7 +145,13 @@ export default function AdminPostsPage() {
{edited && <Badge variant="secondary" className="ml-1"></Badge>}
</td>
<td>{p.board?.name ?? '—'}</td>
<td>{p.user?.nickname ?? '—'}</td>
<td>
{p.user?.id ? (
<button type="button" className="admin-text-link" onClick={() => nav(`/user/${p.user!.id}`)}>
{p.user.nickname}
</button>
) : '—'}
</td>
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
<td>{p.comment_count ?? 0}</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>

View File

@@ -78,10 +78,11 @@ const SETTING_SECTIONS: SettingSection[] = [
{
id: 'user',
title: '用户账号',
summary: '注册、改密头像上传限制',
summary: '注册、改密头像与签名限制',
rows: [
{ key: 'password_min_len', label: '密码最短', unit: '位', min: 4 },
{ key: 'avatar_max_mb', label: '头像上限', unit: 'MB', min: 1 },
{ key: 'signature_max', label: '签名上限', unit: '字', min: 0 },
],
},
];

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
@@ -8,6 +9,7 @@ import { useAdminGuard } from '../../layouts/AdminLayout';
import type { User } from '../../api/types';
export default function AdminUsersPage() {
const nav = useNavigate();
const { ready } = useAdminGuard();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
@@ -79,7 +81,11 @@ export default function AdminUsersPage() {
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.username}</td>
<td>{u.nickname}</td>
<td>
<button type="button" className="admin-text-link" onClick={() => nav(`/user/${u.id}`)}>
{u.nickname}
</button>
</td>
<td className="admin-table-email">{u.email || '—'}</td>
<td>
{u.role === 'admin'

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,12 @@ export const AVATAR_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image
/** 头像输出尺寸 */
export const AVATAR_OUTPUT_SIZE = 512;
/**
* 原图体积软上限:仅防止浏览器加载过大文件卡死。
* 实际上传限额看裁剪后的文件(见 validateAvatarOutput
*/
export const AVATAR_SOURCE_MAX_MB = 20;
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
@@ -15,13 +21,21 @@ function loadImage(src: string): Promise<HTMLImageElement> {
});
}
/** 校验头像文件,返回错误信息或 null */
export function validateAvatarFile(file: File, maxMb: number): string | null {
/** 选择/拖入原图时:只校验格式与可读性上限,不按上传限额卡死 */
export function validateAvatarFile(file: File): string | null {
if (!AVATAR_MIME_TYPES.includes(file.type)) {
return '仅支持 JPG、PNG、GIF、WebP 格式';
}
if (file.size > AVATAR_SOURCE_MAX_MB * 1024 * 1024) {
return `原图过大(超过 ${AVATAR_SOURCE_MAX_MB}MB请换一张较小的图片`;
}
return null;
}
/** 裁剪完成后:按实际上传体积校验 */
export function validateAvatarOutput(file: File, maxMb: number): string | null {
if (file.size > maxMb * 1024 * 1024) {
return `头像不能超过 ${maxMb}MB`;
return `裁剪后头像仍超过 ${maxMb}MB,请缩小裁剪区域或换图`;
}
return null;
}

View File

@@ -50,16 +50,50 @@ function splitParagraphBreaks(html: string): string {
/** 为 Turndown 注册通用正文规则(不含 members-only */
function addTurndownContentRules(service: TurndownService): void {
service.addRule('imageGroup', {
filter: (node) =>
node.nodeName === 'DIV' && (node as HTMLElement).hasAttribute('data-image-group'),
replacement: (_content, node) => {
const el = node as HTMLElement;
const layout = el.getAttribute('data-layout') || 'cols-2';
const imgs = [...el.querySelectorAll(':scope > img, :scope .image-group__grid > img')]
.map(img => {
const src = img.getAttribute('src') || '';
const alt = img.getAttribute('alt') || '';
return src ? `<img src="${src}" alt="${alt}">` : '';
})
.filter(Boolean)
.join('');
if (!imgs) return '';
return `\n\n<div data-image-group data-layout="${layout}" class="image-group image-group--${layout}">${imgs}</div>\n\n`;
},
});
service.addRule('image', {
filter: 'img',
replacement: (_content, node) => {
const el = node as HTMLImageElement;
// 已由图组规则处理的子图跳过
if (el.closest('[data-image-group]')) return '';
const alt = el.getAttribute('alt') ?? '';
const src = el.getAttribute('src') ?? '';
return src ? `![${alt}](${src})` : '';
if (!src) return '';
const display = el.getAttribute('data-display');
if (display && display !== 'default') {
return `\n\n<img src="${src}" alt="${alt}" data-display="${display}" class="article-img article-img--${display}">\n\n`;
}
return `![${alt}](${src})`;
},
});
// 清浮动段落:保留为 HTML避免空行被 Markdown 折叠后绕排失效
service.addRule('clearFloatParagraph', {
filter: (node) =>
node.nodeName === 'P' && (node as HTMLElement).hasAttribute('data-clear-float'),
replacement: (content) =>
`\n\n<p data-clear-float class="article-clear-float">${content}</p>\n\n`,
});
service.addRule('underline', {
filter: ['u'],
replacement: (content) => `<u>${content}</u>`,

View File

@@ -6,7 +6,13 @@ import { enhanceHeadingAnchors } from './postHeadings';
/** DOMPurify 配置:允许会员专属自定义标签与链接 target */
export const POST_CONTENT_PURIFY_CONFIG: Config = {
ADD_TAGS: ['members-only'],
ADD_ATTR: ['data-locked', 'data-length', 'target', 'rel', 'data-code-copy', 'data-lang'],
ADD_ATTR: [
'data-locked', 'data-length', 'target', 'rel',
'data-code-copy', 'data-lang', 'data-full',
'data-image-group', 'data-layout', 'data-display',
'data-clear-float',
'class',
],
};
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>`;
@@ -81,8 +87,37 @@ export function renderPostContentHtml(
doc.querySelectorAll('img').forEach(img => {
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
const rawSrc = img.getAttribute('src') || '';
const full = img.getAttribute('data-full') || rawSrc;
const thumb = toPostImageThumbSrc(full) || toPostImageThumbSrc(rawSrc);
if (thumb && full) {
// 正文加载缩略图,点击灯箱用原图
if (!img.getAttribute('data-full')) img.setAttribute('data-full', full);
if (rawSrc !== thumb) img.setAttribute('src', thumb);
img.classList.add('post-content-img--zoomable');
img.setAttribute('role', 'button');
img.setAttribute('tabindex', '0');
img.setAttribute('title', img.getAttribute('title') || '点击查看原图');
}
});
// 规范化图组 class保证阅读态宫格样式生效
doc.querySelectorAll('div[data-image-group]').forEach(el => {
const layout = el.getAttribute('data-layout') || 'cols-2';
el.classList.add('image-group', `image-group--${layout}`);
});
// 单图展示形态 class
doc.querySelectorAll('img[data-display]').forEach(img => {
const display = img.getAttribute('data-display');
if (!display || display === 'default') return;
img.classList.add('article-img', `article-img--${display}`);
});
// 绕排图后:连续 ≥2 个空段落,则其后首个有内容块清除浮动(写到图下)
markClearFloatAfterBlankRuns(doc.body);
if (opts?.openLinksInNewTab) {
doc.querySelectorAll('a[href]').forEach(a => {
const href = a.getAttribute('href') || '';
@@ -100,3 +135,110 @@ export function renderPostContentHtml(
return doc.body.innerHTML;
}
function isFloatDisplayImage(el: Element): boolean {
if (el.tagName !== 'IMG') return false;
const display = el.getAttribute('data-display') || '';
return display === 'float-left' || display === 'float-right'
|| el.classList.contains('article-img--float-left')
|| el.classList.contains('article-img--float-right');
}
/** 空段落 / 仅含 br、空白 */
function isBlankParagraph(el: Element): boolean {
if (el.tagName !== 'P') return false;
const text = (el.textContent || '').replace(/\u00a0/g, ' ').trim();
if (text.length > 0) return false;
return !el.querySelector('img, video, iframe, table, pre, blockquote, members-only');
}
/**
* 浮动绕排后若作者连按多次回车再写字,给后续块加 clear
* 避免「明明写在图下却仍贴在图右侧」。
* 阅读态与编辑器 DOM 均可调用。
*/
export function markClearFloatAfterBlankRuns(root: HTMLElement): void {
const blocks = [...root.children];
let seenFloat = false;
let blankRun = 0;
for (const el of blocks) {
// 已持久化的清浮动标记始终生效
if (el.hasAttribute('data-clear-float')) {
el.classList.add('article-clear-float');
}
if (isFloatDisplayImage(el)) {
seenFloat = true;
blankRun = 0;
continue;
}
// 通栏块本身会清浮动,重置状态
if (
el.tagName === 'IMG'
|| el.classList.contains('image-group')
|| /^H[1-6]$/.test(el.tagName)
|| el.tagName === 'HR'
|| el.tagName === 'PRE'
|| el.tagName === 'TABLE'
|| el.tagName === 'BLOCKQUOTE'
) {
seenFloat = isFloatDisplayImage(el);
blankRun = 0;
continue;
}
if (!seenFloat) {
if (!el.hasAttribute('data-clear-float')) {
el.classList.remove('article-clear-float');
}
blankRun = 0;
continue;
}
if (isBlankParagraph(el)) {
blankRun += 1;
continue;
}
// 双空行 或 已有 data-clear-float保持写到图下
if (blankRun >= 2 || el.hasAttribute('data-clear-float')) {
el.classList.add('article-clear-float');
if (!el.hasAttribute('data-clear-float')) {
el.setAttribute('data-clear-float', '');
}
} else {
el.classList.remove('article-clear-float');
}
blankRun = 0;
}
}
/**
* 将帖子上传图 URL 转为缩略图地址。
* /uploads/posts/a.jpg → /media/thumb/posts/a.jpg
*/
export function toPostImageThumbSrc(src: string): string | null {
const path = extractUploadPath(src);
if (!path) return null;
if (path.startsWith('/media/thumb/')) return path;
if (!path.startsWith('/uploads/posts/')) return null;
return `/media/thumb/${path.slice('/uploads/'.length)}`;
}
/** 提取同源相对路径(忽略 query / hash */
function extractUploadPath(src: string): string | null {
const raw = (src || '').trim();
if (!raw || raw.startsWith('data:') || raw.startsWith('blob:')) return null;
try {
if (raw.startsWith('http://') || raw.startsWith('https://')) {
const u = new URL(raw);
if (typeof window !== 'undefined' && u.origin !== window.location.origin) return null;
return u.pathname;
}
} catch {
return null;
}
const path = raw.split('?')[0].split('#')[0];
return path.startsWith('/') ? path : null;
}

View File

@@ -0,0 +1,4 @@
/** 用户公开主页路径 */
export function userPath(id: number | string): string {
return `/user/${id}`;
}

View File

@@ -41,6 +41,7 @@ export default defineConfig({
proxy: {
'/api': apiTarget,
'/uploads': apiTarget,
'/media': apiTarget,
},
},
});