支持公开用户主页、帖子图缩略图与编辑器图组排版。
新增用户签名与活动统计、图片灯箱;正文按需生成缩略图;TipTap 支持多图分组与环绕排版,并注入站点标题避免刷新闪烁。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
158
docs/introduction.md
Normal file
158
docs/introduction.md
Normal 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
|
||||||
@@ -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") ||
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
try {
|
const urls: string[] = [];
|
||||||
const { url } = await api.uploadPostImage(file);
|
for (const file of files) {
|
||||||
resolve(url);
|
try {
|
||||||
} catch (e: unknown) {
|
const { url } = await api.uploadPostImage(file);
|
||||||
notify.error(e instanceof Error ? e.message : '图片上传失败');
|
urls.push(url);
|
||||||
resolve(null);
|
} catch (e: unknown) {
|
||||||
|
notify.error(e instanceof Error ? e.message : '图片上传失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
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\n\n`, handleMarkdownChange);
|
if (urls.length === 1) {
|
||||||
|
insertAtCursor(textarea, markdownSource, `\n\n\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: <LockKeyhole size={15} />,
|
icon: <ImageIcon size={15} />,
|
||||||
title: '登录可见',
|
title: '上传图片',
|
||||||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
hint: '可多选;多张自动并排成图组',
|
||||||
active: editor.isActive('membersOnly'),
|
action: setImage,
|
||||||
className: 'article-tool-btn--members',
|
},
|
||||||
action: wrapMembersOnly,
|
{
|
||||||
|
icon: <Columns2 size={15} />,
|
||||||
|
title: '合并为图组',
|
||||||
|
hint: '点击一张图后合并其附近连续图片(无需框选多张)',
|
||||||
|
active: groupActive,
|
||||||
|
action: wrapSelectedAsGroup,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [editor, openLinkDialog, setImage, wrapMembersOnly]);
|
|
||||||
|
if (imageActive && !groupActive) {
|
||||||
|
tools.push(
|
||||||
|
{
|
||||||
|
icon: <StretchHorizontal size={15} />,
|
||||||
|
title: '通栏大图',
|
||||||
|
active: currentDisplay === 'wide',
|
||||||
|
action: () => setImageDisplay(currentDisplay === 'wide' ? 'default' : 'wide'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <PanelLeft size={15} />,
|
||||||
|
title: '左绕排',
|
||||||
|
hint: '图片居左,文字环绕',
|
||||||
|
active: currentDisplay === 'float-left',
|
||||||
|
action: () => setImageDisplay(currentDisplay === 'float-left' ? 'default' : 'float-left'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <PanelRight size={15} />,
|
||||||
|
title: '右绕排',
|
||||||
|
hint: '图片居右,文字环绕',
|
||||||
|
active: currentDisplay === 'float-right',
|
||||||
|
action: () => setImageDisplay(currentDisplay === 'float-right' ? 'default' : 'float-right'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tools.push({
|
||||||
|
icon: <LockKeyhole size={15} />,
|
||||||
|
title: '登录可见',
|
||||||
|
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||||
|
active: editor.isActive('membersOnly'),
|
||||||
|
className: 'article-tool-btn--members',
|
||||||
|
action: wrapMembersOnly,
|
||||||
|
});
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}, [editor, openLinkDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
|
||||||
|
|
||||||
const buildMarkdownTools = useCallback((): ToolBtn[] => [
|
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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
{c.user?.avatar ? (
|
<UserLink
|
||||||
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
|
user={c.user ?? { id: c.user_id, nickname: nick }}
|
||||||
) : (
|
showAvatar={false}
|
||||||
commentInitial(c)
|
showName={false}
|
||||||
)}
|
className={`waline-comment-avatar user-link--avatar-only${!c.user?.avatar ? ' guest' : ''}`}
|
||||||
</div>
|
>
|
||||||
|
{c.user?.avatar ? (
|
||||||
|
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||||
|
) : (
|
||||||
|
commentInitial(c)
|
||||||
|
)}
|
||||||
|
</UserLink>
|
||||||
|
) : (
|
||||||
|
<div className={`waline-comment-avatar ${!c.user?.avatar ? 'guest' : ''}`}>
|
||||||
|
{c.user?.avatar ? (
|
||||||
|
<img src={c.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||||
|
) : (
|
||||||
|
commentInitial(c)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="waline-comment-main">
|
<div className="waline-comment-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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
58
frontend/src/components/ImageLightbox.tsx
Normal file
58
frontend/src/components/ImageLightbox.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
<>
|
||||||
className={className}
|
<div
|
||||||
onClick={handleClick}
|
className={className}
|
||||||
dangerouslySetInnerHTML={{ __html: prepared.html }}
|
onClick={handleClick}
|
||||||
/>
|
onKeyDown={handleKeyDown}
|
||||||
|
dangerouslySetInnerHTML={{ __html: prepared.html }}
|
||||||
|
/>
|
||||||
|
<ImageLightbox
|
||||||
|
src={lightboxSrc}
|
||||||
|
alt={lightboxAlt}
|
||||||
|
open={!!lightboxSrc}
|
||||||
|
onClose={() => setLightboxSrc(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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)}
|
|
||||||
>
|
>
|
||||||
<span className="widget-item-avatar" aria-hidden>
|
{item.user_id ? (
|
||||||
{item.avatar
|
<UserLink
|
||||||
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
user={{ id: item.user_id, nickname: item.author, avatar: item.avatar }}
|
||||||
: (item.author?.[0] || '?')}
|
showAvatar={false}
|
||||||
</span>
|
showName={false}
|
||||||
<span className="widget-item-title">{item.excerpt}</span>
|
stopPropagation
|
||||||
<span className="widget-item-time">{item.created_at}</span>
|
className="widget-item-avatar user-link--avatar-only"
|
||||||
</button>
|
>
|
||||||
|
{item.avatar
|
||||||
|
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||||
|
: (item.author?.[0] || '?')}
|
||||||
|
</UserLink>
|
||||||
|
) : (
|
||||||
|
<span className="widget-item-avatar" aria-hidden>
|
||||||
|
{item.avatar
|
||||||
|
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||||
|
: (item.author?.[0] || '?')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="widget-item-comment-main"
|
||||||
|
onClick={() => onPostClick(item.post_id)}
|
||||||
|
>
|
||||||
|
<span className="widget-item-title">{item.excerpt}</span>
|
||||||
|
<span className="widget-item-time">{item.created_at}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
77
frontend/src/components/UserLink.tsx
Normal file
77
frontend/src/components/UserLink.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/components/editor/ArticleImageExtension.tsx
Normal file
67
frontend/src/components/editor/ArticleImageExtension.tsx
Normal 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 }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
144
frontend/src/components/editor/ClearFloatParagraph.ts
Normal file
144
frontend/src/components/editor/ClearFloatParagraph.ts
Normal 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;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
371
frontend/src/components/editor/ImageGroupExtension.tsx
Normal file
371
frontend/src/components/editor/ImageGroupExtension.tsx
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"]');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -323,85 +323,90 @@ export default function ComposePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="compose-page">
|
<div className="compose-page">
|
||||||
<div className="compose-canvas">
|
<div className="compose-canvas">
|
||||||
<header className="compose-header">
|
<div className="compose-shell">
|
||||||
<button
|
<header className="compose-header">
|
||||||
type="button"
|
<div className="compose-header-left">
|
||||||
className="compose-back"
|
<button
|
||||||
onClick={() => requestLeave(() => {
|
type="button"
|
||||||
if (isEdit) nav(`/post/${editId}`);
|
className="compose-back"
|
||||||
else nav(-1);
|
onClick={() => requestLeave(() => {
|
||||||
})}
|
if (isEdit) nav(`/post/${editId}`);
|
||||||
>
|
else nav(-1);
|
||||||
<ArrowLeft size={16} />
|
})}
|
||||||
<span>返回</span>
|
>
|
||||||
</button>
|
<ArrowLeft size={16} />
|
||||||
<div className="compose-header-actions">
|
<span>返回</span>
|
||||||
{(draftHint || editWindowHint) && (
|
</button>
|
||||||
<span className="compose-draft-hint" title={editWindowHint || draftHint}>
|
<h1 className="compose-header-title">{isEdit ? '编辑帖子' : '写新帖'}</h1>
|
||||||
{editWindowHint || draftHint}
|
{(draftHint || editWindowHint) && (
|
||||||
</span>
|
<span className="compose-draft-hint" title={editWindowHint || draftHint}>
|
||||||
)}
|
{editWindowHint || draftHint}
|
||||||
<button
|
</span>
|
||||||
type="button"
|
|
||||||
className="compose-publish-btn"
|
|
||||||
disabled={publishing}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
<Send size={16} />
|
|
||||||
{publishing ? (isEdit ? '保存中…' : '发布中…') : (isEdit ? '保存修改' : '发布帖子')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="compose-meta">
|
|
||||||
{!isEdit ? (
|
|
||||||
<div className="compose-board-pills">
|
|
||||||
{boards.map(b => (
|
|
||||||
<button
|
|
||||||
key={b.id}
|
|
||||||
type="button"
|
|
||||||
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
|
|
||||||
onClick={() => setBoardId(String(b.id))}
|
|
||||||
>
|
|
||||||
{b.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : currentBoard && (
|
|
||||||
<div className="compose-board-pills">
|
|
||||||
<span className="compose-board-pill active">{currentBoard.name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<TagInput
|
|
||||||
value={tags}
|
|
||||||
onChange={setTags}
|
|
||||||
placeholder="输入标签后回车"
|
|
||||||
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="compose-writing">
|
|
||||||
<input
|
|
||||||
className="compose-title"
|
|
||||||
type="text"
|
|
||||||
placeholder="输入文章标题…"
|
|
||||||
value={title}
|
|
||||||
onChange={e => setTitle(e.target.value)}
|
|
||||||
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
|
|
||||||
/>
|
|
||||||
{currentBoard && (
|
|
||||||
<div className="compose-subtitle">
|
|
||||||
{isEdit ? '编辑于' : '发布至'} <strong>{currentBoard.name}</strong>
|
|
||||||
{editWindowHint && (
|
|
||||||
<span className="compose-edit-window"> · {editWindowHint}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="compose-header-actions">
|
||||||
<ArticleEditor
|
<button
|
||||||
value={content}
|
type="button"
|
||||||
onChange={setContent}
|
className="compose-publish-btn"
|
||||||
placeholder="开始写作。所见即所得,选中文字后使用工具栏设置格式。"
|
disabled={publishing}
|
||||||
/>
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
{publishing ? (isEdit ? '保存中…' : '发布中…') : (isEdit ? '保存修改' : '发布')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="compose-context" aria-label="发布设置">
|
||||||
|
<div className="compose-context-row">
|
||||||
|
<span className="compose-context-label">板块</span>
|
||||||
|
{!isEdit ? (
|
||||||
|
<div className="compose-board-pills" role="listbox" aria-label="选择板块">
|
||||||
|
{boards.map(b => (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={String(b.id) === boardId}
|
||||||
|
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
|
||||||
|
onClick={() => setBoardId(String(b.id))}
|
||||||
|
>
|
||||||
|
{b.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : currentBoard ? (
|
||||||
|
<div className="compose-board-pills">
|
||||||
|
<span className="compose-board-pill active">{currentBoard.name}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="compose-context-row compose-context-row--tags">
|
||||||
|
<span className="compose-context-label">标签</span>
|
||||||
|
<TagInput
|
||||||
|
value={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
placeholder="添加标签,回车确认"
|
||||||
|
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="compose-document">
|
||||||
|
<input
|
||||||
|
className="compose-title"
|
||||||
|
type="text"
|
||||||
|
placeholder="输入文章标题…"
|
||||||
|
value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
|
||||||
|
/>
|
||||||
|
<ArticleEditor
|
||||||
|
value={content}
|
||||||
|
onChange={setContent}
|
||||||
|
placeholder="开始写作。按回车分段,选中文字后用工具栏设置格式。"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UnsavedChangesDialog
|
<UnsavedChangesDialog
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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,96 +505,190 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="section-card">
|
<div className="profile-tabs" role="tablist" aria-label="个人中心分区">
|
||||||
<div className="section-card-title">基本资料</div>
|
{tabs.map(t => (
|
||||||
<Form {...nickForm}>
|
<button
|
||||||
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
|
key={t.key}
|
||||||
<FormItem>
|
type="button"
|
||||||
<FormLabel>用户名</FormLabel>
|
role="tab"
|
||||||
<FormControl>
|
aria-selected={tab === t.key}
|
||||||
<Input value={user.username} disabled />
|
className={`profile-tab${tab === t.key ? ' active' : ''}`}
|
||||||
</FormControl>
|
onClick={() => setTab(t.key)}
|
||||||
</FormItem>
|
>
|
||||||
<FormItem>
|
{t.label}
|
||||||
<FormLabel>邮箱</FormLabel>
|
{typeof t.count === 'number' && (
|
||||||
<FormControl>
|
<span className="profile-tab-count">{t.count}</span>
|
||||||
<Input value={user.email || '未设置'} disabled />
|
)}
|
||||||
</FormControl>
|
</button>
|
||||||
</FormItem>
|
))}
|
||||||
<FormField
|
|
||||||
control={nickForm.control}
|
|
||||||
name="nickname"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>昵称</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input maxLength={64} placeholder="显示名称" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="profile-form-footer">
|
|
||||||
<span className="profile-form-hint">
|
|
||||||
支持 JPG、PNG、GIF、WebP,头像不超过 {limits.avatar_max_mb}MB
|
|
||||||
</span>
|
|
||||||
<Button type="submit" loading={nickLoading}>保存</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="section-card">
|
{tab === 'posts' && (
|
||||||
<div className="section-card-title">修改密码</div>
|
<div className="profile-panel">
|
||||||
<Form {...pwdForm}>
|
{postsLoading ? (
|
||||||
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
|
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||||
<FormField
|
) : posts.length === 0 ? (
|
||||||
control={pwdForm.control}
|
<div className="empty-state">
|
||||||
name="old_password"
|
<PenLine className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||||
render={({ field }) => (
|
<p>还没有发布过帖子</p>
|
||||||
<FormItem>
|
<Button onClick={() => nav('/compose')}>去发帖</Button>
|
||||||
<FormLabel>当前密码</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" placeholder="输入当前密码" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={pwdForm.control}
|
|
||||||
name="new_password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>新密码</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" placeholder={`至少 ${limits.password_min_len} 位`} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={pwdForm.control}
|
|
||||||
name="confirm_password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>确认新密码</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" placeholder="再次输入新密码" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="profile-form-footer profile-form-footer--end">
|
|
||||||
<Button type="submit" variant="destructive" loading={pwdLoading}>
|
|
||||||
修改密码
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
) : (
|
||||||
</Form>
|
<>
|
||||||
</div>
|
<div className="content-surface">
|
||||||
|
{posts.map(post => (
|
||||||
|
<PostListItem
|
||||||
|
key={post.id}
|
||||||
|
post={post}
|
||||||
|
onSelect={(id) => openForumPost(nav, id, limits.open_posts_in_new_tab)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<FeedPagination
|
||||||
|
page={postPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
postTotal={postTotal}
|
||||||
|
loading={postsLoading}
|
||||||
|
onPageChange={setPostPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'settings' && (
|
||||||
|
<div className="section-card">
|
||||||
|
<div className="section-card-title">基本资料</div>
|
||||||
|
<Form {...nickForm}>
|
||||||
|
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>用户 ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input value={String(user.id)} disabled />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>用户名</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input value={user.username} disabled />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>邮箱</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input value={user.email || '未设置'} disabled />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
<FormField
|
||||||
|
control={nickForm.control}
|
||||||
|
name="nickname"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>昵称</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input maxLength={64} placeholder="显示名称" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="profile-form-footer">
|
||||||
|
<span className="profile-form-hint">
|
||||||
|
用户名与 ID 不可修改;头像支持 JPG / PNG / GIF / WebP,裁剪后不超过 {limits.avatar_max_mb}MB
|
||||||
|
</span>
|
||||||
|
<Button type="submit" loading={nickLoading}>保存昵称</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div className="profile-form-divider" />
|
||||||
|
|
||||||
|
<Form {...sigForm}>
|
||||||
|
<form onSubmit={sigForm.handleSubmit(onUpdateSig)} className="profile-form">
|
||||||
|
<FormField
|
||||||
|
control={sigForm.control}
|
||||||
|
name="signature"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>个人签名</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
maxLength={sigMax}
|
||||||
|
placeholder="写一句介绍自己的话,会显示在公开主页"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="profile-form-footer">
|
||||||
|
<span className="profile-form-hint">
|
||||||
|
{(sigForm.watch('signature') || '').length}/{sigMax}
|
||||||
|
</span>
|
||||||
|
<Button type="submit" loading={sigLoading}>保存签名</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'security' && (
|
||||||
|
<div className="section-card">
|
||||||
|
<div className="section-card-title">修改密码</div>
|
||||||
|
<Form {...pwdForm}>
|
||||||
|
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
|
||||||
|
<FormField
|
||||||
|
control={pwdForm.control}
|
||||||
|
name="old_password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>当前密码</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="输入当前密码" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={pwdForm.control}
|
||||||
|
name="new_password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>新密码</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder={`至少 ${limits.password_min_len} 位`} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={pwdForm.control}
|
||||||
|
name="confirm_password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>确认新密码</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="再次输入新密码" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="profile-form-footer profile-form-footer--end">
|
||||||
|
<Button type="submit" variant="destructive" loading={pwdLoading}>
|
||||||
|
修改密码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
211
frontend/src/pages/UserProfilePage.tsx
Normal file
211
frontend/src/pages/UserProfilePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? `` : '';
|
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 ``;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 清浮动段落:保留为 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>`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
4
frontend/src/utils/userPath.ts
Normal file
4
frontend/src/utils/userPath.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/** 用户公开主页路径 */
|
||||||
|
export function userPath(id: number | string): string {
|
||||||
|
return `/user/${id}`;
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/api': apiTarget,
|
'/api': apiTarget,
|
||||||
'/uploads': apiTarget,
|
'/uploads': apiTarget,
|
||||||
|
'/media': apiTarget,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
32
handler/thumb.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -2,14 +2,28 @@ 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"`
|
||||||
Avatar string `json:"avatar"`
|
Signature string `json:"signature"`
|
||||||
Role Role `json:"role"`
|
Avatar string `json:"avatar"`
|
||||||
|
Role Role `json:"role"`
|
||||||
Banned bool `json:"banned"`
|
Banned bool `json:"banned"`
|
||||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 Provider(Gitea 等外部站点 SSO)
|
// OIDC Provider(Gitea 等外部站点 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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
182
service/thumb.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user