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

新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;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

158
docs/introduction.md Normal file
View File

@@ -0,0 +1,158 @@
# 姜十三论坛:为小圈子而生的轻量论坛
> 轻量 · 好看 · 单文件部署
> 面向内部交流,不堆功能,把「能聊、好看、好装」做到位。
---
## 它是什么
**姜十三论坛Jiang13 Forum** 是一款面向小圈子、团队与兴趣社群的现代化论坛软件。
它不做「大而全」的社区平台,而是聚焦一件事:让几个人到几百人的内部交流,有一个干净、顺手、自己能掌控的地方。
技术上,它是一个编译为 **单个 Go 二进制** 的 Web 应用:前端 SPA单页应用通过 `go:embed` 内嵌,数据库用内置 **SQLite**,拷贝到服务器就能跑——没有复杂的中间件矩阵,也没有「先装一堆依赖再祈祷能起来」的仪式感。
---
## 为什么做它
市面上的论坛方案往往落在两端:
- **重型产品**Discourse、phpBB 等):功能完整,但部署与运维成本高,对小团队过重。
- **即时通讯 / 文档工具**:适合聊天与协作,却缺少「发帖—回复—沉淀」的社区节奏。
小圈子真正需要的,常常是中间态:
- 有板块、帖子、楼层回复,讨论可追溯;
- 界面清爽、信息密度够高,桌面与手机都好用;
- 部署简单到「一个文件 + 一份配置」;
- 数据在自己手里,备份一眼能懂。
姜十三论坛正是为这个缺口而生。
---
## 谁适合用
| 场景 | 说明 |
|------|------|
| 团队 / 工作室内部论坛 | 需求讨论、进度同步、知识沉淀 |
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
| 开源 / 私有项目配套社区 | 与 Gitea 等工具通过 OIDC开放身份连接做 SSO单点登录 |
| 想自己托管的个人站长 | 单机即可,无需云数据库与一堆微服务 |
如果你需要百万用户级的公网社区、复杂插件生态或企业级工单流,它可能不是最优选;如果你要的是 **小而美、自己能装、自己能管**,它会对胃口。
---
## 用起来是什么感觉
### 浏览:高密度、不臃肿
前台采用熟悉的 **三栏布局**
- **左栏**:板块导航,可折叠;
- **中间**:帖子 Feed信息流支持虚拟滚动长列表依然流畅
- **右栏**:热门帖与最新评论,社区动态一目了然。
Feed 可按 **最新发帖 / 最新回复 / 热门讨论** 切换。浅色与暗色主题一键切换,并会记住你的偏好。平板与手机上侧栏会自动收起,触控浏览同样顺手。
整体气质更接近 V2EX / NGA 一类的信息密度——一屏能看更多内容,而不是大留白的营销站。
### 发帖:富文本,够用就好
发帖使用 **TipTap** 富文本编辑器:标题、排版、标签、板块选择、正文图片本地上传,日常写帖够用。帖子支持置顶;编辑后保留 **修订历史**,可做 diff差异对比。管理员还可配置普通用户的 **编辑时限**,避免无限制改稿。
### 讨论:楼层回复,聊得清楚
评论是楼层式的:可回复指定楼层、引用原文、@ 高亮。点赞、收藏、热门帖,把活跃内容自然推到前面。
### 管理:后台也是 SPA
管理后台统一在 `/admin`,与前台同一套 React 体验:
- 仪表盘、板块与帖子管理
- 用户禁言、删帖删评
- 论坛参数、限流、敏感词
- SQLite **一键备份**
权限模型很简单:**普通用户 / 管理员**;站点 **第一个注册用户自动成为管理员**,省去安装向导里的一堆步骤。
---
## 部署:一个文件,真正开箱
这大概是姜十三论坛最「硬核」的卖点之一。
```text
编译 → 得到一个 jiang13或 jiang13.exe
放到目录里运行 → 自动生成 app.ini
打开浏览器注册 → 第一个账号就是管理员
```
要点:
- **单二进制**:静态资源已内嵌,不必再配 Nginx 专门反代前端文件;
- **零外部数据库**SQLite 落在数据目录,备份就是拷贝文件;
- **app.ini 配置**:风格类似 Gitea端口、数据目录、站点根地址一眼能改
- **系统服务**:内置 Linux systemd / Windows Service 安装与启停;
- **跨平台**Windows / Linux / macOS 均可编译与运行。
典型启动后访问 `http://localhost:3000`,注册即可开始。
---
## 技术素描(给好奇的人)
| 层级 | 技术 |
|------|------|
| 后端 | Go · Gin · GORM · SQLite |
| 前端 | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
| 构建 | Vite 构建 SPA再由 `go:embed` 打进二进制 |
| 认证 | bcrypt + JWT Cookie可选 OIDC Provider对接 Gitea 等 SSO |
对运维者:数据目录结构清晰(数据库、日志、上传、敏感词、备份)。对开发者:前后端分离开发,`dev` 模式下 Vite HMR热模块替换可秒级预览前端改动。
---
## 现在走到哪一步
项目仍在积极开发中,核心体验已经可用:
- ✅ 三栏布局、主题切换、虚拟滚动、Feed 排序
- ✅ 发帖 / 评论 / 点赞收藏 / 修订历史
- ✅ React 管理后台与论坛参数配置
- ✅ OIDC Provider可作 Gitea 等站点的登录源)
- ✅ 单二进制部署与系统服务
计划中的方向包括通知动态优化、搜索增强、邮件提醒等——完整列表见仓库 [ROADMAP.md](../ROADMAP.md)。
---
## 一句话总结
**姜十三论坛** = 小圈子论坛该有的功能 × 清新好用的界面 × 真正能单文件带走的部署方式。
如果你正在给团队或同好找一个「自己的小论坛」,不妨编译跑一下:注册第一个账号,从发第一帖开始。
---
## 快速上手
```bash
# Windows
.\build.bat
# Linux / macOS
make build
# 运行
./dist/jiang13 # 或 Windows: .\dist\jiang13.exe
```
浏览器打开 `http://localhost:3000/register`,第一个注册用户即为管理员。
更多细节(配置项、系统服务、前端开发)见项目 [README](../README.md)。欢迎通过 Issue / PR 参与共建。
**许可证:** MIT

View File

@@ -2,9 +2,11 @@ package embed_static
import ( import (
"embed" "embed"
"html"
"html/template" "html/template"
"io/fs" "io/fs"
"net/http" "net/http"
"regexp"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -16,6 +18,16 @@ var staticFS embed.FS
//go:embed templates/* //go:embed templates/*
var templatesFS embed.FS var templatesFS embed.FS
var (
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
spaBrandTitleFn func() string
)
// SetSPADocumentTitle 注册站点标题提供者ServeSPA 会注入到入口 HTML避免刷新闪烁
func SetSPADocumentTitle(fn func() string) {
spaBrandTitleFn = fn
}
// SetupEmbed 配置内嵌资源SPA 前端 + 后台 HTML 模板 // SetupEmbed 配置内嵌资源SPA 前端 + 后台 HTML 模板
func SetupEmbed(r *gin.Engine) error { func SetupEmbed(r *gin.Engine) error {
tmpl, err := LoadTemplates() tmpl, err := LoadTemplates()
@@ -44,6 +56,12 @@ func ServeSPA(c *gin.Context) {
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build") c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
return return
} }
if spaBrandTitleFn != nil {
if title := strings.TrimSpace(spaBrandTitleFn()); title != "" {
escaped := html.EscapeString(title)
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
}
}
c.Data(http.StatusOK, "text/html; charset=utf-8", data) c.Data(http.StatusOK, "text/html; charset=utf-8", data)
} }
@@ -52,6 +70,7 @@ func IsSPARoute(path string) bool {
if strings.HasPrefix(path, "/api") || if strings.HasPrefix(path, "/api") ||
strings.HasPrefix(path, "/admin") || strings.HasPrefix(path, "/admin") ||
strings.HasPrefix(path, "/uploads") || strings.HasPrefix(path, "/uploads") ||
strings.HasPrefix(path, "/media") ||
strings.HasPrefix(path, "/legacy") || strings.HasPrefix(path, "/legacy") ||
strings.HasPrefix(path, "/assets") || strings.HasPrefix(path, "/assets") ||
strings.HasPrefix(path, "/oauth") || strings.HasPrefix(path, "/oauth") ||

View File

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

View File

@@ -22,6 +22,7 @@ const RegisterPage = lazy(() => import('./pages/RegisterPage'));
const ComposePage = lazy(() => import('./pages/ComposePage')); const ComposePage = lazy(() => import('./pages/ComposePage'));
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage')); const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
const ProfilePage = lazy(() => import('./pages/ProfilePage')); const ProfilePage = lazy(() => import('./pages/ProfilePage'));
const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
const FavoritesPage = lazy(() => import('./pages/FavoritesPage')); const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
const ProjectsPage = lazy(() => import('./pages/ProjectsPage')); const ProjectsPage = lazy(() => import('./pages/ProjectsPage'));
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage')); const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
@@ -51,6 +52,7 @@ const router = createBrowserRouter(
<Route path="/post/:id/edit" element={<ComposePage />} /> <Route path="/post/:id/edit" element={<ComposePage />} />
<Route path="/compose" element={<ComposePage />} /> <Route path="/compose" element={<ComposePage />} />
<Route path="/profile" element={<ProfilePage />} /> <Route path="/profile" element={<ProfilePage />} />
<Route path="/user/:id" element={<UserProfilePage />} />
<Route path="/favorites" element={<FavoritesPage />} /> <Route path="/favorites" element={<FavoritesPage />} />
<Route path="/projects" element={<ProjectsPage />} /> <Route path="/projects" element={<ProjectsPage />} />
</Route> </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 = ''; const BASE = '';
@@ -156,11 +156,21 @@ export const api = {
}), }),
adminBackup: () => adminBackup: () =>
request<{ message: string; filename: string; download: string }>('/api/admin/backup', { method: 'POST' }), 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) => { updateNickname: (nickname: string) => {
const fd = new FormData(); const fd = new FormData();
fd.append('nickname', nickname); fd.append('nickname', nickname);
return request('/api/profile/nickname', { method: 'POST', body: fd, headers: {} }); 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) => { updatePassword: (oldPassword: string, newPassword: string) => {
const fd = new FormData(); const fd = new FormData();
fd.append('old_password', oldPassword); fd.append('old_password', oldPassword);

View File

@@ -3,6 +3,7 @@ export interface User {
username: string; username: string;
email?: string; email?: string;
nickname: string; nickname: string;
signature?: string;
avatar: string; avatar: string;
role: 'user' | 'admin'; role: 'user' | 'admin';
banned?: boolean; banned?: boolean;
@@ -13,6 +14,27 @@ export interface User {
updated_at?: string; 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 { export interface Board {
id: number; id: number;
name: string; name: string;
@@ -119,6 +141,7 @@ export interface ForumLimits {
page_size_default: number; page_size_default: number;
password_min_len: number; password_min_len: number;
avatar_max_mb: number; avatar_max_mb: number;
signature_max: number;
open_posts_in_new_tab: boolean; open_posts_in_new_tab: boolean;
open_content_links_in_new_tab: boolean; open_content_links_in_new_tab: boolean;
} }
@@ -133,6 +156,7 @@ export interface ForumLimitsPublic {
page_size_default: number; page_size_default: number;
password_min_len: number; password_min_len: number;
avatar_max_mb: number; avatar_max_mb: number;
signature_max: number;
open_posts_in_new_tab: boolean; open_posts_in_new_tab: boolean;
open_content_links_in_new_tab: boolean; open_content_links_in_new_tab: boolean;
} }
@@ -247,6 +271,7 @@ export interface Paginated<T> {
export interface RecentComment { export interface RecentComment {
id: number; id: number;
post_id: number; post_id: number;
user_id?: number;
author: string; author: string;
avatar: string; avatar: string;
excerpt: string; excerpt: string;

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import {
} from '../utils/comment'; } from '../utils/comment';
import { isTimeDiffSignificant } from '../utils/content'; import { isTimeDiffSignificant } from '../utils/content';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';
import UserLink from './UserLink';
function canManageComment(c: Comment, user?: User | null): boolean { function canManageComment(c: Comment, user?: User | null): boolean {
if (!user) return false; if (!user) return false;
@@ -97,20 +98,40 @@ function CommentItem({
id={`floor-${c.floor}`} id={`floor-${c.floor}`}
className={`waline-comment ${nested ? 'nested' : ''} ${isHighlighted ? 'highlight' : ''}`} className={`waline-comment ${nested ? 'nested' : ''} ${isHighlighted ? 'highlight' : ''}`}
> >
<div className={`waline-comment-avatar ${guest && !c.user?.avatar ? 'guest' : ''}`}> {!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 ? ( {c.user?.avatar ? (
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" /> <img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
) : ( ) : (
commentInitial(c) commentInitial(c)
)} )}
</div> </div>
)}
<div className="waline-comment-main"> <div className="waline-comment-main">
<div className="waline-comment-head"> <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"> <a href={c.guest_url} target="_blank" rel="noopener noreferrer" className="waline-comment-author">
{nick} {nick}
</a> </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> <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 { useNavigate } from 'react-router-dom';
import { renderPostContentHtml } from '../utils/postContent'; import { renderPostContentHtml } from '../utils/postContent';
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings'; import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
import { loginPath, registerPath } from '../utils/authRedirect'; import { loginPath, registerPath } from '../utils/authRedirect';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import ImageLightbox from './ImageLightbox';
interface Props { interface Props {
html: string; html: string;
@@ -14,7 +15,7 @@ interface Props {
onHeadingsChange?: (headings: PostHeading[]) => void; onHeadingsChange?: (headings: PostHeading[]) => void;
} }
/** 帖子正文渲染(含会员专属区块、代码块美化) */ /** 帖子正文渲染(含会员专属区块、代码块美化、图片灯箱 */
export default function PostContent({ export default function PostContent({
html, html,
isLoggedIn, isLoggedIn,
@@ -23,6 +24,8 @@ export default function PostContent({
}: Props) { }: Props) {
const nav = useNavigate(); const nav = useNavigate();
const { limits } = useForumLimits(); const { limits } = useForumLimits();
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const [lightboxAlt, setLightboxAlt] = useState('');
const prepared = useMemo(() => { const prepared = useMemo(() => {
const rendered = renderPostContentHtml(html, isLoggedIn, { const rendered = renderPostContentHtml(html, isLoggedIn, {
@@ -38,6 +41,13 @@ export default function PostContent({
onHeadingsChange?.(prepared.headings); onHeadingsChange?.(prepared.headings);
}, [prepared.headings, onHeadingsChange]); }, [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 handleClick = useCallback(async (e: React.MouseEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest('[data-members-login]')) { if (target.closest('[data-members-login]')) {
@@ -50,6 +60,12 @@ export default function PostContent({
nav(registerPath()); nav(registerPath());
return; 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]'); const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
if (copyBtn) { if (copyBtn) {
e.preventDefault(); e.preventDefault();
@@ -68,13 +84,30 @@ export default function PostContent({
notify.error('复制失败'); 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 ( return (
<>
<div <div
className={className} className={className}
onClick={handleClick} onClick={handleClick}
onKeyDown={handleKeyDown}
dangerouslySetInnerHTML={{ __html: prepared.html }} 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 { MessageCircle, ThumbsUp } from 'lucide-react';
import BoardBadge from '@/components/BoardBadge'; import BoardBadge from '@/components/BoardBadge';
import PinnedIcon from '@/components/PinnedIcon'; import PinnedIcon from '@/components/PinnedIcon';
import UserLink from '@/components/UserLink';
import type { PostItem } from '../api/types'; import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar'; import type { FeedSort } from './FeedSortBar';
import { formatTime } from '../utils/content'; import { formatTime } from '../utils/content';
@@ -22,13 +23,33 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
const commentCount = post.comment_count ?? 0; const commentCount = post.comment_count ?? 0;
const likeCount = post.like_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 ( return (
<button type="button" className="post-row" onClick={() => onSelect(post.id)}> <div
<div className="post-avatar"> 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 {post.user?.avatar
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" /> ? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
: initial} : initial}
</div> </UserLink>
<div className="post-body"> <div className="post-body">
<div className="post-title"> <div className="post-title">
{post.pinned && <PinnedIcon className="mr-1.5" />} {post.pinned && <PinnedIcon className="mr-1.5" />}
@@ -36,7 +57,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
</div> </div>
<div className="post-meta"> <div className="post-meta">
{post.board && <BoardBadge board={post.board} />} {post.board && <BoardBadge board={post.board} />}
<span>{post.user?.nickname || '匿名'}</span> <UserLink user={post.user} stopPropagation className="post-meta-user" />
<span>{timeLabel}</span> <span>{timeLabel}</span>
</div> </div>
</div> </div>
@@ -50,7 +71,7 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
{likeCount} {likeCount}
</span> </span>
</div> </div>
</button> </div>
); );
} }

View File

@@ -9,6 +9,7 @@ import type { PostRevision } from '../api/types';
import PostContent from './PostContent'; import PostContent from './PostContent';
import { formatDateTime } from '../utils/content'; import { formatDateTime } from '../utils/content';
import { moveTabIndex, useOverlayA11y } from '../hooks/useOverlayA11y'; import { moveTabIndex, useOverlayA11y } from '../hooks/useOverlayA11y';
import UserLink from './UserLink';
import { import {
type PostSnapshot, type PostSnapshot,
htmlToDiffText, htmlToDiffText,
@@ -273,7 +274,14 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
</div> </div>
<span className="post-revision-item-title">{entry.rev.title}</span> <span className="post-revision-item-title">{entry.rev.title}</span>
<span className="post-revision-item-meta"> <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> </span>
</button> </button>
</li> </li>
@@ -291,7 +299,12 @@ export default function PostRevisionPanel({ postId, currentPost, open, onClose,
<div className="post-revision-main-head"> <div className="post-revision-main-head">
<div> <div>
<span className="post-revision-main-editor"> <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>
<span className="post-revision-main-time"> <span className="post-revision-main-time">
{formatDateTime(selected.rev.created_at)} {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 type { PostItem, RecentComment, TagCount } from '../api/types';
import { useSiteBranding } from '../hooks/useSiteBranding'; import { useSiteBranding } from '../hooks/useSiteBranding';
import TagCloud from './TagCloud'; import TagCloud from './TagCloud';
import UserLink from './UserLink';
interface Props { interface Props {
hot: PostItem[]; hot: PostItem[];
@@ -110,21 +111,39 @@ export default function RightPanel({
) : commentList.length === 0 ? ( ) : commentList.length === 0 ? (
<div className="widget-empty"></div> <div className="widget-empty"></div>
) : commentList.map(item => ( ) : commentList.map(item => (
<button <div
key={item.id} key={item.id}
type="button"
className="widget-item widget-item--comment" className="widget-item widget-item--comment"
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author} title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
onClick={() => onPostClick(item.post_id)}
> >
{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> <span className="widget-item-avatar" aria-hidden>
{item.avatar {item.avatar
? <img src={item.avatar} alt="" loading="lazy" decoding="async" /> ? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
: (item.author?.[0] || '?')} : (item.author?.[0] || '?')}
</span> </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-title">{item.excerpt}</span>
<span className="widget-item-time">{item.created_at}</span> <span className="widget-item-time">{item.created_at}</span>
</button> </button>
</div>
))} ))}
</div> </div>
</div> </div>

View File

@@ -14,7 +14,7 @@ import { getBoardThemeIndex } from '../utils/boardTheme';
import ArticleOutline from './ArticleOutline'; import ArticleOutline from './ArticleOutline';
// 内容页不参与左侧栏高亮(非 feed 浏览上下文) // 内容页不参与左侧栏高亮(非 feed 浏览上下文)
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile']; const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile', '/user/'];
export function isNeutralSidebarRoute(pathname: string): boolean { export function isNeutralSidebarRoute(pathname: string): boolean {
return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix)); 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, page_size_default: 30,
password_min_len: 6, password_min_len: 6,
avatar_max_mb: 2, avatar_max_mb: 2,
signature_max: 200,
open_posts_in_new_tab: true, open_posts_in_new_tab: true,
open_content_links_in_new_tab: true, open_content_links_in_new_tab: true,
}; };

View File

@@ -29,8 +29,15 @@ function fetchBranding(): Promise<SiteBranding> {
return inflight; 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) { 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; if (document.title !== title) document.title = title;
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]'); 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 PageLoader from '../components/PageLoader';
import FeedPageSkeleton from '../components/FeedPageSkeleton'; import FeedPageSkeleton from '../components/FeedPageSkeleton';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom'; 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 { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -54,6 +54,7 @@ export default function MainLayout() {
title?: string; title?: string;
} | null>(null); } | null>(null);
const [asideOpen, setAsideOpen] = useState(false); const [asideOpen, setAsideOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside()); const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0); const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
const asideEverLoaded = useRef(false); const asideEverLoaded = useRef(false);
@@ -64,16 +65,34 @@ export default function MainLayout() {
const asideDrawerRef = useRef<HTMLElement>(null); const asideDrawerRef = useRef<HTMLElement>(null);
const asideCloseRef = useRef<HTMLButtonElement>(null); const asideCloseRef = useRef<HTMLButtonElement>(null);
const sidebarDrawerRef = useRef<HTMLElement>(null);
const sidebarCloseRef = useRef<HTMLButtonElement>(null);
const boardBarRef = useRef<HTMLDivElement>(null); const boardBarRef = useRef<HTMLDivElement>(null);
const closeAside = useCallback(() => setAsideOpen(false), []); 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, { useOverlayA11y(asideOpen && hideAside && !isCompose, closeAside, asideDrawerRef, {
initialFocusRef: asideCloseRef, initialFocusRef: asideCloseRef,
}); });
useOverlayA11y(sidebarOpen && isMobile && !isCompose, closeSidebar, sidebarDrawerRef, {
initialFocusRef: sidebarCloseRef,
});
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]); useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]); useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
useEffect(() => { setAsideOpen(false); }, [loc.pathname, loc.search]); useEffect(() => {
setAsideOpen(false);
setSidebarOpen(false);
}, [loc.pathname, loc.search]);
useEffect(() => { useEffect(() => {
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null); if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
}, [loc.pathname]); }, [loc.pathname]);
@@ -81,11 +100,14 @@ export default function MainLayout() {
if (!hideAside) setAsideOpen(false); if (!hideAside) setAsideOpen(false);
}, [hideAside]); }, [hideAside]);
useEffect(() => { useEffect(() => {
if (!asideOpen) return; if (!isMobile) setSidebarOpen(false);
}, [isMobile]);
useEffect(() => {
if (!asideOpen && !sidebarOpen) return;
const prev = document.body.style.overflow; const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; }; return () => { document.body.style.overflow = prev; };
}, [asideOpen]); }, [asideOpen, sidebarOpen]);
const refreshBoards = useCallback(() => { const refreshBoards = useCallback(() => {
return Promise.all([ return Promise.all([
@@ -237,6 +259,19 @@ export default function MainLayout() {
<div className="app-frame"> <div className="app-frame">
<header className="app-header"> <header className="app-header">
<div className="header-inner"> <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, '/')}> <button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
<SiteBrandMark branding={branding} className="header-logo-mark" /> <SiteBrandMark branding={branding} className="header-logo-mark" />
{!isMobile && <span className="header-logo-text">{branding.name}</span>} {!isMobile && <span className="header-logo-text">{branding.name}</span>}
@@ -284,7 +319,7 @@ export default function MainLayout() {
<button <button
type="button" type="button"
className="header-icon-btn" className="header-icon-btn"
onClick={() => setAsideOpen(true)} onClick={openAside}
aria-label="打开社区动态" aria-label="打开社区动态"
aria-expanded={asideOpen} aria-expanded={asideOpen}
aria-controls="aside-drawer" aria-controls="aside-drawer"
@@ -320,7 +355,8 @@ export default function MainLayout() {
className="w-40" className="w-40"
onCloseAutoFocus={(e) => e.preventDefault()} 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> <DropdownMenuItem onClick={() => nav('/favorites')}></DropdownMenuItem>
{user.role === 'admin' && ( {user.role === 'admin' && (
<> <>
@@ -419,6 +455,51 @@ export default function MainLayout() {
</div> </div>
</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 && ( {asideOpen && hideAside && !isCompose && (
<div className="aside-drawer-root"> <div className="aside-drawer-root">
<button <button

View File

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

View File

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

View File

@@ -1,26 +1,52 @@
import { useState, useRef, useEffect, useCallback } from 'react'; 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 { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from '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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify'; import { notify } from '@/lib/notify';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { api } from '../api/client'; import { api } from '../api/client';
import type { PostItem, UserActivityStats } from '../api/types';
import { useForumLimits } from '../hooks/useForumLimits'; import { useForumLimits } from '../hooks/useForumLimits';
import AvatarCropDialog from '../components/AvatarCropDialog'; import AvatarCropDialog from '../components/AvatarCropDialog';
import PostListItem from '../components/PostListItem';
import FeedPagination from '../components/FeedPagination';
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop'; import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
import { loginPath } from '../utils/authRedirect'; import { loginPath } from '../utils/authRedirect';
import { openForumPost } from '../utils/openPost';
import { formatDateTime } from '../utils/content';
import { userPath } from '../utils/userPath';
const nickSchema = z.object({ const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64), 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({ const pwdSchema = (minLen: number) => z.object({
old_password: z.string().min(1, '请输入当前密码'), old_password: z.string().min(1, '请输入当前密码'),
new_password: z.string().min(minLen, `新密码至少 ${minLen}`), new_password: z.string().min(minLen, `新密码至少 ${minLen}`),
@@ -31,12 +57,22 @@ const pwdSchema = (minLen: number) => z.object({
}); });
type NickValues = z.infer<typeof nickSchema>; type NickValues = z.infer<typeof nickSchema>;
type SigValues = z.infer<ReturnType<typeof sigSchema>>;
type PwdValues = z.infer<ReturnType<typeof pwdSchema>>; 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() { export default function ProfilePage() {
const nav = useNavigate(); const nav = useNavigate();
const [params, setParams] = useSearchParams();
const tab = parseTab(params.get('tab'));
const { user, loading: authLoading, refresh } = useAuth(); const { user, loading: authLoading, refresh } = useAuth();
const [nickLoading, setNickLoading] = useState(false); const [nickLoading, setNickLoading] = useState(false);
const [sigLoading, setSigLoading] = useState(false);
const [pwdLoading, setPwdLoading] = useState(false); const [pwdLoading, setPwdLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false); const [avatarLoading, setAvatarLoading] = useState(false);
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null); const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
@@ -45,21 +81,44 @@ export default function ProfilePage() {
const [cropImageSrc, setCropImageSrc] = useState<string | null>(null); const [cropImageSrc, setCropImageSrc] = useState<string | null>(null);
const [cropFileName, setCropFileName] = useState(''); const [cropFileName, setCropFileName] = useState('');
const [dragOver, setDragOver] = useState(false); 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 fileRef = useRef<HTMLInputElement>(null);
const dragCounter = useRef(0); const dragCounter = useRef(0);
const copyTimer = useRef<ReturnType<typeof setTimeout>>();
const { limits } = useForumLimits(); 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>({ const nickForm = useForm<NickValues>({
resolver: zodResolver(nickSchema), resolver: zodResolver(nickSchema),
values: { nickname: user?.nickname ?? '' }, 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>({ const pwdForm = useForm<PwdValues>({
resolver: zodResolver(pwdSchema(limits.password_min_len)), resolver: zodResolver(pwdSchema(limits.password_min_len)),
defaultValues: { old_password: '', new_password: '', confirm_password: '' }, 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(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
nav(loginPath('/profile')); nav(loginPath('/profile'));
@@ -78,6 +137,42 @@ export default function ProfilePage() {
}; };
}, [cropImageSrc]); }, [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) => { const closeCropDialog = useCallback((open: boolean) => {
if (!open) { if (!open) {
setCropOpen(false); 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) => { const onUpdatePwd = async (values: PwdValues) => {
setPwdLoading(true); setPwdLoading(true);
try { try {
@@ -132,7 +240,7 @@ export default function ProfilePage() {
}; };
const openCropForFile = (file: File) => { const openCropForFile = (file: File) => {
const err = validateAvatarFile(file, limits.avatar_max_mb); const err = validateAvatarFile(file);
if (err) { if (err) {
notify.error(err); notify.error(err);
if (fileRef.current) fileRef.current.value = ''; if (fileRef.current) fileRef.current.value = '';
@@ -204,7 +312,26 @@ export default function ProfilePage() {
if (file) openCropForFile(file); 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 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 ( return (
<div className="page-wrap"> <div className="page-wrap">
@@ -254,12 +381,58 @@ export default function ProfilePage() {
</span> </span>
</div> </div>
</button> </button>
<div className="profile-header-info"> <div className="profile-header-info">
<div className="profile-header-main"> <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> <div className="profile-username">@{user.username}</div>
<p className="profile-avatar-tip"></p> <div className="profile-id-row">
{user.role === 'admin' && <Badge variant="green" className="mt-1.5"></Badge>} <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> </div>
{pendingAvatar && ( {pendingAvatar && (
<div className="profile-avatar-actions"> <div className="profile-avatar-actions">
@@ -279,12 +452,36 @@ export default function ProfilePage() {
</div> </div>
)} )}
</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> </div>
<AvatarCropDialog <AvatarCropDialog
open={cropOpen} open={cropOpen}
imageSrc={cropImageSrc} imageSrc={cropImageSrc}
fileName={cropFileName} fileName={cropFileName}
maxMb={limits.avatar_max_mb}
onOpenChange={closeCropDialog} onOpenChange={closeCropDialog}
onConfirm={onCropConfirm} onConfirm={onCropConfirm}
/> />
@@ -308,10 +505,70 @@ export default function ProfilePage() {
</div> </div>
)} )}
<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>
{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>
) : (
<>
<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">
<div className="section-card-title"></div> <div className="section-card-title"></div>
<Form {...nickForm}> <Form {...nickForm}>
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form"> <form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
<FormItem>
<FormLabel> ID</FormLabel>
<FormControl>
<Input value={String(user.id)} disabled />
</FormControl>
</FormItem>
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
@@ -339,14 +596,47 @@ export default function ProfilePage() {
/> />
<div className="profile-form-footer"> <div className="profile-form-footer">
<span className="profile-form-hint"> <span className="profile-form-hint">
JPGPNGGIFWebP {limits.avatar_max_mb}MB ID JPG / PNG / GIF / WebP {limits.avatar_max_mb}MB
</span> </span>
<Button type="submit" loading={nickLoading}></Button> <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> </div>
</form> </form>
</Form> </Form>
</div> </div>
)}
{tab === 'security' && (
<div className="section-card"> <div className="section-card">
<div className="section-card-title"></div> <div className="section-card-title"></div>
<Form {...pwdForm}> <Form {...pwdForm}>
@@ -398,6 +688,7 @@ export default function ProfilePage() {
</form> </form>
</Form> </Form>
</div> </div>
)}
</div> </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}`} {c.post?.title ?? `#${c.post_id}`}
</button> </button>
</td> </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 className="max-w-[200px] truncate">{c.content}</td>
<td>{c.is_private ? <Badge variant="secondary"></Badge> : '—'}</td> <td>{c.is_private ? <Badge variant="secondary"></Badge> : '—'}</td>
<td>{new Date(c.created_at).toLocaleString('zh-CN')}</td> <td>{new Date(c.created_at).toLocaleString('zh-CN')}</td>

View File

@@ -73,7 +73,13 @@ export default function AdminDashboardPage() {
{p.title} {p.title}
</button> </button>
</td> </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>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td> <td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
</tr> </tr>

View File

@@ -145,7 +145,13 @@ export default function AdminPostsPage() {
{edited && <Badge variant="secondary" className="ml-1"></Badge>} {edited && <Badge variant="secondary" className="ml-1"></Badge>}
</td> </td>
<td>{p.board?.name ?? '—'}</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 className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
<td>{p.comment_count ?? 0}</td> <td>{p.comment_count ?? 0}</td>
<td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td> <td>{p.pinned ? <Badge variant="orange"></Badge> : '—'}</td>

View File

@@ -78,10 +78,11 @@ const SETTING_SECTIONS: SettingSection[] = [
{ {
id: 'user', id: 'user',
title: '用户账号', title: '用户账号',
summary: '注册、改密头像上传限制', summary: '注册、改密头像与签名限制',
rows: [ rows: [
{ key: 'password_min_len', label: '密码最短', unit: '位', min: 4 }, { key: 'password_min_len', label: '密码最短', unit: '位', min: 4 },
{ key: 'avatar_max_mb', label: '头像上限', unit: 'MB', min: 1 }, { 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 { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
@@ -8,6 +9,7 @@ import { useAdminGuard } from '../../layouts/AdminLayout';
import type { User } from '../../api/types'; import type { User } from '../../api/types';
export default function AdminUsersPage() { export default function AdminUsersPage() {
const nav = useNavigate();
const { ready } = useAdminGuard(); const { ready } = useAdminGuard();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -79,7 +81,11 @@ export default function AdminUsersPage() {
<tr key={u.id}> <tr key={u.id}>
<td>{u.id}</td> <td>{u.id}</td>
<td>{u.username}</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 className="admin-table-email">{u.email || '—'}</td>
<td> <td>
{u.role === 'admin' {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; export const AVATAR_OUTPUT_SIZE = 512;
/**
* 原图体积软上限:仅防止浏览器加载过大文件卡死。
* 实际上传限额看裁剪后的文件(见 validateAvatarOutput
*/
export const AVATAR_SOURCE_MAX_MB = 20;
function loadImage(src: string): Promise<HTMLImageElement> { function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); 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)) { if (!AVATAR_MIME_TYPES.includes(file.type)) {
return '仅支持 JPG、PNG、GIF、WebP 格式'; 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) { if (file.size > maxMb * 1024 * 1024) {
return `头像不能超过 ${maxMb}MB`; return `裁剪后头像仍超过 ${maxMb}MB,请缩小裁剪区域或换图`;
} }
return null; return null;
} }

View File

@@ -50,16 +50,50 @@ function splitParagraphBreaks(html: string): string {
/** 为 Turndown 注册通用正文规则(不含 members-only */ /** 为 Turndown 注册通用正文规则(不含 members-only */
function addTurndownContentRules(service: TurndownService): void { 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', { service.addRule('image', {
filter: 'img', filter: 'img',
replacement: (_content, node) => { replacement: (_content, node) => {
const el = node as HTMLImageElement; const el = node as HTMLImageElement;
// 已由图组规则处理的子图跳过
if (el.closest('[data-image-group]')) return '';
const alt = el.getAttribute('alt') ?? ''; const alt = el.getAttribute('alt') ?? '';
const src = el.getAttribute('src') ?? ''; 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', { service.addRule('underline', {
filter: ['u'], filter: ['u'],
replacement: (content) => `<u>${content}</u>`, replacement: (content) => `<u>${content}</u>`,

View File

@@ -6,7 +6,13 @@ import { enhanceHeadingAnchors } from './postHeadings';
/** DOMPurify 配置:允许会员专属自定义标签与链接 target */ /** DOMPurify 配置:允许会员专属自定义标签与链接 target */
export const POST_CONTENT_PURIFY_CONFIG: Config = { export const POST_CONTENT_PURIFY_CONFIG: Config = {
ADD_TAGS: ['members-only'], 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>`; 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 => { doc.querySelectorAll('img').forEach(img => {
if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy'); if (!img.getAttribute('loading')) img.setAttribute('loading', 'lazy');
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async'); 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) { if (opts?.openLinksInNewTab) {
doc.querySelectorAll('a[href]').forEach(a => { doc.querySelectorAll('a[href]').forEach(a => {
const href = a.getAttribute('href') || ''; const href = a.getAttribute('href') || '';
@@ -100,3 +135,110 @@ export function renderPostContentHtml(
return doc.body.innerHTML; 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: { proxy: {
'/api': apiTarget, '/api': apiTarget,
'/uploads': apiTarget, '/uploads': apiTarget,
'/media': apiTarget,
}, },
}, },
}); });

3
go.mod
View File

@@ -39,9 +39,10 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/image v0.44.0 // indirect
golang.org/x/net v0.25.0 // indirect golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect modernc.org/libc v1.22.5 // indirect

4
go.sum
View File

@@ -90,6 +90,8 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -99,6 +101,8 @@ golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=

View File

@@ -662,10 +662,12 @@ func (h *Handlers) APIPosts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", strconv.Itoa(h.Settings.PageSizeDefault()))) size, _ := strconv.Atoi(c.DefaultQuery("size", strconv.Itoa(h.Settings.PageSizeDefault())))
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64) boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
userID, _ := strconv.ParseUint(c.Query("user_id"), 10, 64)
keyword := c.Query("keyword") keyword := c.Query("keyword")
q := service.PostListQuery{ q := service.PostListQuery{
BoardID: uint(boardID), BoardID: uint(boardID),
UserID: uint(userID),
Page: page, Page: page,
Size: size, Size: size,
Keyword: keyword, Keyword: keyword,

View File

@@ -279,6 +279,44 @@ func (h *Handlers) APILogout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "已退出"}) c.JSON(http.StatusOK, gin.H{"message": "已退出"})
} }
// APIProfileStats 当前用户活动统计(发帖 / 评论 / 收藏 / 获赞)
func (h *Handlers) APIProfileStats(c *gin.Context) {
st, err := h.User.ActivityStats(h.currentUserID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"stats": st})
}
// APIUserPublic 公开用户主页(资料 + 公开统计)
func (h *Handlers) APIUserPublic(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效用户"})
return
}
user, err := h.User.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
st, err := h.User.ActivityStats(user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 收藏数仅本人可见
viewerID := h.currentUserID(c)
if viewerID != user.ID {
st.FavoriteCount = 0
}
c.JSON(http.StatusOK, gin.H{
"user": user.ToPublic(),
"stats": st,
})
}
func (h *Handlers) APIUpdateProfile(c *gin.Context) { func (h *Handlers) APIUpdateProfile(c *gin.Context) {
nickname := c.PostForm("nickname") nickname := c.PostForm("nickname")
if err := h.User.UpdateNickname(h.currentUserID(c), nickname); err != nil { if err := h.User.UpdateNickname(h.currentUserID(c), nickname); err != nil {
@@ -293,6 +331,20 @@ func (h *Handlers) APIUpdateProfile(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "昵称已更新", "user": userView}) c.JSON(http.StatusOK, gin.H{"message": "昵称已更新", "user": userView})
} }
func (h *Handlers) APIUpdateSignature(c *gin.Context) {
signature := c.PostForm("signature")
if err := h.User.UpdateSignature(h.currentUserID(c), signature); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, _ := h.User.GetByID(h.currentUserID(c))
var userView any
if user != nil {
userView = user.ToSelf()
}
c.JSON(http.StatusOK, gin.H{"message": "签名已更新", "user": userView})
}
func (h *Handlers) APIUpdatePassword(c *gin.Context) { func (h *Handlers) APIUpdatePassword(c *gin.Context) {
oldPass := c.PostForm("old_password") oldPass := c.PostForm("old_password")
newPass := c.PostForm("new_password") newPass := c.PostForm("new_password")

32
handler/thumb.go Normal file
View File

@@ -0,0 +1,32 @@
package handler
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"git.iioio.com/freefire/jiang13-forum/service"
)
// ServeImageThumb 帖子图片缩略图(按需生成并缓存)
// GET /media/thumb/posts/xxx.jpg → 最长边 1280 的 JPEG 预览
func (h *Handlers) ServeImageThumb(c *gin.Context) {
rel := strings.TrimPrefix(c.Param("filepath"), "/")
uploadsRoot := filepath.Join(h.Cfg.DataDir, "uploads")
thumbPath, err := service.EnsureUploadThumb(uploadsRoot, rel)
if err != nil {
// 生成失败时回退原图,避免正文裂图
orig := filepath.Join(uploadsRoot, filepath.FromSlash(rel))
if st, e := os.Stat(orig); e == nil && !st.IsDir() {
c.Header("Cache-Control", "public, max-age=3600")
c.File(orig)
return
}
c.Status(http.StatusNotFound)
return
}
c.Header("Cache-Control", "public, max-age=604800, immutable")
c.File(thumbPath)
}

View File

@@ -23,6 +23,7 @@ type User struct {
Email string `gorm:"index;size:128;default:''" json:"-"` Email string `gorm:"index;size:128;default:''" json:"-"`
Password string `gorm:"size:128;not null" json:"-"` Password string `gorm:"size:128;not null" json:"-"`
Nickname string `gorm:"size:64" json:"nickname"` Nickname string `gorm:"size:64" json:"nickname"`
Signature string `gorm:"size:512;default:''" json:"signature"` // 个人签名
Avatar string `gorm:"size:256" json:"avatar"` Avatar string `gorm:"size:256" json:"avatar"`
Role Role `gorm:"size:16;default:user" json:"role"` Role Role `gorm:"size:16;default:user" json:"role"`
Banned bool `gorm:"default:false" json:"banned"` Banned bool `gorm:"default:false" json:"banned"`

View File

@@ -2,12 +2,26 @@ package model
import "time" import "time"
// UserPublic 公开用户主页视图(无邮箱与登录信息)
type UserPublic struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Signature string `json:"signature"`
Avatar string `json:"avatar"`
Role Role `json:"role"`
Banned bool `json:"banned"`
BannedAt *time.Time `json:"banned_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// UserSelf 当前登录用户视图(含邮箱,不含登录 IP // UserSelf 当前登录用户视图(含邮箱,不含登录 IP
type UserSelf struct { type UserSelf struct {
ID uint `json:"id"` ID uint `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Signature string `json:"signature"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Role Role `json:"role"` Role Role `json:"role"`
Banned bool `json:"banned"` Banned bool `json:"banned"`
@@ -22,6 +36,7 @@ type UserAdmin struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Signature string `json:"signature"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Role Role `json:"role"` Role Role `json:"role"`
Banned bool `json:"banned"` Banned bool `json:"banned"`
@@ -32,6 +47,21 @@ type UserAdmin struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// ToPublic 转为公开主页视图
func (u *User) ToPublic() UserPublic {
return UserPublic{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Signature: u.Signature,
Avatar: u.Avatar,
Role: u.Role,
Banned: u.Banned,
BannedAt: u.BannedAt,
CreatedAt: u.CreatedAt,
}
}
// ToSelf 转为个人中心 /api/me 视图 // ToSelf 转为个人中心 /api/me 视图
func (u *User) ToSelf() UserSelf { func (u *User) ToSelf() UserSelf {
return UserSelf{ return UserSelf{
@@ -39,6 +69,7 @@ func (u *User) ToSelf() UserSelf {
Username: u.Username, Username: u.Username,
Email: u.Email, Email: u.Email,
Nickname: u.Nickname, Nickname: u.Nickname,
Signature: u.Signature,
Avatar: u.Avatar, Avatar: u.Avatar,
Role: u.Role, Role: u.Role,
Banned: u.Banned, Banned: u.Banned,
@@ -55,6 +86,7 @@ func (u *User) ToAdmin() UserAdmin {
Username: u.Username, Username: u.Username,
Email: u.Email, Email: u.Email,
Nickname: u.Nickname, Nickname: u.Nickname,
Signature: u.Signature,
Avatar: u.Avatar, Avatar: u.Avatar,
Role: u.Role, Role: u.Role,
Banned: u.Banned, Banned: u.Banned,

View File

@@ -36,6 +36,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
) )
settingsSvc.MigrateLegacyOIDCClient() settingsSvc.MigrateLegacyOIDCClient()
settingsSvc.SeedGiteaFromINI(cfg.GiteaBaseURL, cfg.GiteaToken, cfg.GiteaSyncEnabled) settingsSvc.SeedGiteaFromINI(cfg.GiteaBaseURL, cfg.GiteaToken, cfg.GiteaSyncEnabled)
// SPA 入口 HTML 注入后台配置的标签标题,避免刷新时先闪默认文案
embed_static.SetSPADocumentTitle(func() string {
return settingsSvc.SiteBranding().DocumentTitle()
})
authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc) authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
userSvc := service.NewUserService(filter, settingsSvc) userSvc := service.NewUserService(filter, settingsSvc)
boardSvc := service.NewBoardService() boardSvc := service.NewBoardService()
@@ -62,6 +66,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
} }
authMW := middleware.NewAuthMiddleware(authSvc) authMW := middleware.NewAuthMiddleware(authSvc)
// 缩略图使用独立前缀,避免与 Static("/uploads/*filepath") 路由冲突
r.GET("/media/thumb/*filepath", h.ServeImageThumb)
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads")) r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
// OIDC ProviderGitea 等外部站点 SSO // OIDC ProviderGitea 等外部站点 SSO
@@ -89,6 +95,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/posts/hot", h.APIHotPosts) pubAPI.GET("/posts/hot", h.APIHotPosts)
pubAPI.GET("/tags", h.APITags) pubAPI.GET("/tags", h.APITags)
pubAPI.GET("/comments/recent", h.APIRecentComments) pubAPI.GET("/comments/recent", h.APIRecentComments)
pubAPI.GET("/users/:id", h.APIUserPublic)
pubAPI.GET("/posts/:id", h.APIPostDetail) pubAPI.GET("/posts/:id", h.APIPostDetail)
pubAPI.GET("/posts/:id/comments", h.APIPostComments) pubAPI.GET("/posts/:id/comments", h.APIPostComments)
pubAPI.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, "comment"), h.APICreateComment) pubAPI.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, "comment"), h.APICreateComment)
@@ -102,7 +109,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
{ {
api.POST("/logout", h.APILogout) api.POST("/logout", h.APILogout)
api.GET("/favorites", h.APIFavorites) api.GET("/favorites", h.APIFavorites)
api.GET("/profile/stats", h.APIProfileStats)
api.POST("/profile/nickname", h.APIUpdateProfile) api.POST("/profile/nickname", h.APIUpdateProfile)
api.POST("/profile/signature", h.APIUpdateSignature)
api.POST("/profile/password", h.APIUpdatePassword) api.POST("/profile/password", h.APIUpdatePassword)
api.POST("/profile/avatar", h.APIUploadAvatar) api.POST("/profile/avatar", h.APIUploadAvatar)
api.POST("/uploads/image", h.APIUploadPostImage) api.POST("/uploads/image", h.APIUploadPostImage)

View File

@@ -209,6 +209,7 @@ func (s *CommentService) AdminDelete(commentID uint) error {
type RecentCommentItem struct { type RecentCommentItem struct {
ID uint `json:"id"` ID uint `json:"id"`
PostID uint `json:"post_id"` PostID uint `json:"post_id"`
UserID uint `json:"user_id,omitempty"`
Author string `json:"author"` Author string `json:"author"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Excerpt string `json:"excerpt"` Excerpt string `json:"excerpt"`
@@ -251,6 +252,7 @@ func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error
out = append(out, RecentCommentItem{ out = append(out, RecentCommentItem{
ID: c.ID, ID: c.ID,
PostID: c.PostID, PostID: c.PostID,
UserID: c.UserID,
Author: author, Author: author,
Avatar: avatar, Avatar: avatar,
Excerpt: excerpt, Excerpt: excerpt,

View File

@@ -21,6 +21,7 @@ func NewPostService(filter *SensitiveFilter, settings *ForumSettingsService) *Po
type PostListQuery struct { type PostListQuery struct {
BoardID uint BoardID uint
UserID uint // >0 时仅返回该用户的帖子
Page int Page int
Size int Size int
Keyword string Keyword string
@@ -210,6 +211,9 @@ func (s *PostService) List(q PostListQuery) ([]model.Post, int64, error) {
if q.BoardID > 0 { if q.BoardID > 0 {
db = db.Where("board_id = ?", q.BoardID) db = db.Where("board_id = ?", q.BoardID)
} }
if q.UserID > 0 {
db = db.Where("user_id = ?", q.UserID)
}
if q.Keyword != "" { if q.Keyword != "" {
kw := "%" + q.Keyword + "%" kw := "%" + q.Keyword + "%"
db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw) db = db.Where("title LIKE ? OR content_plain LIKE ? OR tags LIKE ?", kw, kw, kw)

View File

@@ -31,6 +31,7 @@ const (
SettingPasswordMinLen = "password_min_len" SettingPasswordMinLen = "password_min_len"
SettingAvatarMaxMB = "avatar_max_mb" SettingAvatarMaxMB = "avatar_max_mb"
SettingSignatureMax = "signature_max"
SettingOpenPostsInNewTab = "open_posts_in_new_tab" SettingOpenPostsInNewTab = "open_posts_in_new_tab"
SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab" SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
@@ -93,6 +94,7 @@ type ForumLimits struct {
PasswordMinLen int `json:"password_min_len"` PasswordMinLen int `json:"password_min_len"`
AvatarMaxMB int `json:"avatar_max_mb"` AvatarMaxMB int `json:"avatar_max_mb"`
SignatureMax int `json:"signature_max"`
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"` OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"` OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
@@ -109,6 +111,7 @@ type ForumLimitsPublic struct {
PageSizeDefault int `json:"page_size_default"` PageSizeDefault int `json:"page_size_default"`
PasswordMinLen int `json:"password_min_len"` PasswordMinLen int `json:"password_min_len"`
AvatarMaxMB int `json:"avatar_max_mb"` AvatarMaxMB int `json:"avatar_max_mb"`
SignatureMax int `json:"signature_max"`
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"` OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"` OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
@@ -143,6 +146,7 @@ var forumSettingDefs = []settingDef{
{SettingPasswordMinLen, "6", 4, 128}, {SettingPasswordMinLen, "6", 4, 128},
{SettingAvatarMaxMB, "2", 1, 20}, {SettingAvatarMaxMB, "2", 1, 20},
{SettingSignatureMax, "200", 0, 512},
{SettingOpenPostsInNewTab, "1", 0, 1}, {SettingOpenPostsInNewTab, "1", 0, 1},
{SettingOpenContentLinksInNewTab, "1", 0, 1}, {SettingOpenContentLinksInNewTab, "1", 0, 1},
@@ -196,6 +200,16 @@ type SiteBranding struct {
Favicon string `json:"favicon"` Favicon string `json:"favicon"`
} }
// DocumentTitle 浏览器标签标题:站点名 - 副标题(标语)
func (b SiteBranding) DocumentTitle() string {
name := strings.TrimSpace(b.Name)
subtitle := strings.TrimSpace(b.Slogan)
if subtitle != "" {
return name + " - " + subtitle
}
return name
}
// GiteaSyncConfig Gitea 仓库同步配置 // GiteaSyncConfig Gitea 仓库同步配置
type GiteaSyncConfig struct { type GiteaSyncConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@@ -337,6 +351,7 @@ func (s *ForumSettingsService) Limits() ForumLimits {
PasswordMinLen: s.PasswordMinLen(), PasswordMinLen: s.PasswordMinLen(),
AvatarMaxMB: s.AvatarMaxMB(), AvatarMaxMB: s.AvatarMaxMB(),
SignatureMax: s.SignatureMax(),
OpenPostsInNewTab: s.OpenPostsInNewTab(), OpenPostsInNewTab: s.OpenPostsInNewTab(),
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(), OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
@@ -355,6 +370,7 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
PageSizeDefault: limits.PageSizeDefault, PageSizeDefault: limits.PageSizeDefault,
PasswordMinLen: limits.PasswordMinLen, PasswordMinLen: limits.PasswordMinLen,
AvatarMaxMB: limits.AvatarMaxMB, AvatarMaxMB: limits.AvatarMaxMB,
SignatureMax: limits.SignatureMax,
OpenPostsInNewTab: limits.OpenPostsInNewTab, OpenPostsInNewTab: limits.OpenPostsInNewTab,
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab, OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
@@ -378,6 +394,7 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
SettingPageSizeDefault: in.PageSizeDefault, SettingPageSizeDefault: in.PageSizeDefault,
SettingPasswordMinLen: in.PasswordMinLen, SettingPasswordMinLen: in.PasswordMinLen,
SettingAvatarMaxMB: in.AvatarMaxMB, SettingAvatarMaxMB: in.AvatarMaxMB,
SettingSignatureMax: in.SignatureMax,
} }
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax { if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
return ErrInvalidSetting return ErrInvalidSetting
@@ -438,6 +455,7 @@ func (s *ForumSettingsService) PageSizeDefault() int { return s.getInt(SettingPa
func (s *ForumSettingsService) PasswordMinLen() int { return s.getInt(SettingPasswordMinLen, 6) } func (s *ForumSettingsService) PasswordMinLen() int { return s.getInt(SettingPasswordMinLen, 6) }
func (s *ForumSettingsService) AvatarMaxMB() int { return s.getInt(SettingAvatarMaxMB, 2) } func (s *ForumSettingsService) AvatarMaxMB() int { return s.getInt(SettingAvatarMaxMB, 2) }
func (s *ForumSettingsService) SignatureMax() int { return s.getInt(SettingSignatureMax, 200) }
func (s *ForumSettingsService) OpenPostsInNewTab() bool { func (s *ForumSettingsService) OpenPostsInNewTab() bool {
return s.getString(SettingOpenPostsInNewTab, "1") == "1" return s.getString(SettingOpenPostsInNewTab, "1") == "1"

182
service/thumb.go Normal file
View File

@@ -0,0 +1,182 @@
package service
import (
"errors"
"fmt"
"image"
"image/jpeg"
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/image/draw"
// 注册解码器
_ "image/gif"
_ "image/png"
_ "golang.org/x/image/webp"
)
const (
// PostThumbMaxSide 正文预览图最长边(像素)
PostThumbMaxSide = 1280
// PostThumbJPEGQuality 预览图 JPEG 质量
PostThumbJPEGQuality = 82
)
var thumbLocks sync.Map // 同一原图并发生成时串行化
// ThumbURLFromUpload 将 /uploads/posts/xxx.jpg 转为 /media/thumb/posts/xxx.jpg
func ThumbURLFromUpload(uploadURL string) string {
u := strings.TrimSpace(uploadURL)
if u == "" {
return ""
}
if strings.HasPrefix(u, "/media/thumb/") {
return u
}
if strings.HasPrefix(u, "/uploads/") {
return "/media/thumb/" + strings.TrimPrefix(u, "/uploads/")
}
return ""
}
// WarmPostImageThumb 上传后预热缩略图(失败忽略,首次访问仍会生成)
func WarmPostImageThumb(uploadsRoot, relativePath string) {
_, _ = EnsureUploadThumb(uploadsRoot, relativePath)
}
// EnsureUploadThumb 确保缩略图存在,返回磁盘路径
// relativePath 形如 posts/1_123.jpg相对 uploads 根目录)
func EnsureUploadThumb(uploadsRoot, relativePath string) (string, error) {
rel, err := sanitizeUploadRel(relativePath)
if err != nil {
return "", err
}
// 仅处理帖子正文图
if !strings.HasPrefix(rel, "posts/") {
return "", errors.New("仅支持帖子图片缩略图")
}
origPath := filepath.Join(uploadsRoot, filepath.FromSlash(rel))
if st, err := os.Stat(origPath); err != nil || st.IsDir() {
return "", errors.New("原图不存在")
}
thumbPath := filepath.Join(uploadsRoot, ".thumbs", filepath.FromSlash(rel)+".jpg")
if fresh, err := thumbFresherThan(thumbPath, origPath); err == nil && fresh {
return thumbPath, nil
}
lockKey := rel
muIface, _ := thumbLocks.LoadOrStore(lockKey, &sync.Mutex{})
mu := muIface.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
// 双检
if fresh, err := thumbFresherThan(thumbPath, origPath); err == nil && fresh {
return thumbPath, nil
}
if err := generateJPEGThumb(origPath, thumbPath, PostThumbMaxSide, PostThumbJPEGQuality); err != nil {
return "", err
}
return thumbPath, nil
}
func thumbFresherThan(thumbPath, origPath string) (bool, error) {
ts, err := os.Stat(thumbPath)
if err != nil {
return false, err
}
os_, err := os.Stat(origPath)
if err != nil {
return false, err
}
return !ts.ModTime().Before(os_.ModTime()), nil
}
func sanitizeUploadRel(relativePath string) (string, error) {
rel := strings.TrimSpace(relativePath)
rel = strings.TrimPrefix(rel, "/")
rel = strings.ReplaceAll(rel, "\\", "/")
if rel == "" || strings.Contains(rel, "..") {
return "", errors.New("非法路径")
}
cleaned := filepath.Clean(filepath.FromSlash(rel))
if cleaned == "." || strings.HasPrefix(cleaned, "..") {
return "", errors.New("非法路径")
}
return filepath.ToSlash(cleaned), nil
}
func generateJPEGThumb(srcPath, dstPath string, maxSide, quality int) error {
f, err := os.Open(srcPath)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return fmt.Errorf("解码图片失败: %w", err)
}
out := resizeToMax(img, maxSide)
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
return err
}
tmp := fmt.Sprintf("%s.%d.tmp", dstPath, time.Now().UnixNano())
dst, err := os.Create(tmp)
if err != nil {
return err
}
encErr := jpeg.Encode(dst, out, &jpeg.Options{Quality: quality})
closeErr := dst.Close()
if encErr != nil {
_ = os.Remove(tmp)
return encErr
}
if closeErr != nil {
_ = os.Remove(tmp)
return closeErr
}
if err := os.Rename(tmp, dstPath); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func resizeToMax(src image.Image, maxSide int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= 0 || h <= 0 {
return src
}
if w <= maxSide && h <= maxSide {
return src
}
var nw, nh int
if w >= h {
nw = maxSide
nh = int(float64(h) * float64(maxSide) / float64(w))
} else {
nh = maxSide
nw = int(float64(w) * float64(maxSide) / float64(h))
}
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
return dst
}

View File

@@ -49,5 +49,14 @@ func SaveUploadedImage(file *multipart.FileHeader, dir, urlPrefix, namePrefix st
} }
prefix := strings.TrimSuffix(urlPrefix, "/") prefix := strings.TrimSuffix(urlPrefix, "/")
return prefix + "/" + filename, nil url := prefix + "/" + filename
// 帖子正文图:后台预热缩略图,加速首次打开详情
if strings.Contains(prefix, "/posts") {
uploadsRoot := filepath.Dir(dir) // .../uploads/posts → .../uploads
rel := filepath.ToSlash(filepath.Join(filepath.Base(dir), filename))
go WarmPostImageThumb(uploadsRoot, rel)
}
return url, nil
} }

View File

@@ -28,6 +28,40 @@ func (s *UserService) GetByID(id uint) (*model.User, error) {
return &user, nil return &user, nil
} }
// UserActivityStats 个人主页活动统计
type UserActivityStats struct {
PostCount int64 `json:"post_count"`
CommentCount int64 `json:"comment_count"`
FavoriteCount int64 `json:"favorite_count"`
LikeReceived int64 `json:"like_received"`
}
// ActivityStats 统计用户发帖、评论、收藏与帖子获赞
func (s *UserService) ActivityStats(userID uint) (UserActivityStats, error) {
var st UserActivityStats
if userID == 0 {
return st, errors.New("无效用户")
}
if err := model.DB.Model(&model.Post{}).Where("user_id = ?", userID).Count(&st.PostCount).Error; err != nil {
return st, err
}
if err := model.DB.Model(&model.Comment{}).Where("user_id = ?", userID).Count(&st.CommentCount).Error; err != nil {
return st, err
}
if err := model.DB.Model(&model.PostFavorite{}).Where("user_id = ?", userID).Count(&st.FavoriteCount).Error; err != nil {
return st, err
}
var likeSum int64
if err := model.DB.Model(&model.Post{}).
Select("COALESCE(SUM(like_count), 0)").
Where("user_id = ?", userID).
Scan(&likeSum).Error; err != nil {
return st, err
}
st.LikeReceived = likeSum
return st, nil
}
// GetByUsername 按用户名查询 // GetByUsername 按用户名查询
func (s *UserService) GetByUsername(username string) (*model.User, error) { func (s *UserService) GetByUsername(username string) (*model.User, error) {
var user model.User var user model.User
@@ -47,6 +81,22 @@ func (s *UserService) UpdateNickname(userID uint, nickname string) error {
return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("nickname", nickname).Error return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("nickname", nickname).Error
} }
// UpdateSignature 修改个人签名
func (s *UserService) UpdateSignature(userID uint, signature string) error {
signature = strings.TrimSpace(signature)
maxLen := s.settings.SignatureMax()
if maxLen > 0 {
runes := []rune(signature)
if len(runes) > maxLen {
return fmt.Errorf("签名不能超过 %d 字", maxLen)
}
}
if signature != "" {
signature = s.filter.Filter(signature)
}
return model.DB.Model(&model.User{}).Where("id = ?", userID).Update("signature", signature).Error
}
// UpdatePassword 修改密码 // UpdatePassword 修改密码
func (s *UserService) UpdatePassword(userID uint, oldPass, newPass string) error { func (s *UserService) UpdatePassword(userID uint, oldPass, newPass string) error {
if err := ValidatePassword(newPass, s.settings.PasswordMinLen()); err != nil { if err := ValidatePassword(newPass, s.settings.PasswordMinLen()); err != nil {