diff --git a/LICENSE b/LICENSE index fb5d833..bece6fd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,38 +1,202 @@ -姜十三论坛 专有软件许可协议 -Proprietary Software License Agreement + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -Copyright (c) 2026 姜十三. All Rights Reserved. -版权所有 (c) 2026 姜十三。保留所有权利。 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -本软件(包括但不限于源代码、目标代码、文档、图像及其他随附资料, -以下统称"本软件")由版权方(以下称"授权方")开发并所有。本软件 -以源码可见的方式发布,但这并不构成任何形式的开源授权。 + 1. Definitions. -在未获得授权方事先书面许可之前,任何个人或组织不得: + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. -1. 以任何目的(包括商业、内部生产、教学培训等)使用、运行或部署 - 本软件的全部或任何部分; -2. 复制、影印、转载、镜像、收录到任何数据库或以任何方式再现本软件; -3. 修改、改编、翻译、合并、反向工程、反编译、反汇编本软件,或基于 - 本软件创作衍生作品; -4. 出售、出租、出借、分销、再许可、托管(SaaS)或以其他方式向第三方 - 提供本软件或其任何副本; -5. 删除、遮盖或更改本软件中的版权声明、商标或其他权利声明。 + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. -仅可出于个人学习、评估是否购买授权的目的浏览本仓库中的源代码;该等 -浏览不视为获得任何明示或默示的使用许可。 + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. -商业使用、内部部署或其他任何形式的使用授权,均须与授权方另行签订 -书面许可协议。 + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. -第三方开源组件按其各自附带的许可证条款授权,不受本协议约束。 + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. -本软件按"现状"(AS IS)提供,不附带任何明示或默示的担保,包括但不限 -于适销性、特定用途适用性或不侵权的担保。在任何情况下,授权方均不对因 -使用或无法使用本软件而导致的任何直接、间接、附带、特殊或后果性损害 -承担责任。 + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. -未经授权使用本软件的行为,均构成对授权方著作权及其他知识产权的侵犯, -授权方保留依法追究法律责任的权利。 + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). -授权方联系方式:aarbbs@88.com + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that the copyright owner or their authorized + representative conspicuously designates in writing as "Not a + Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with the conditions + stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides their Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The boilerplate notice should be enclosed in + appropriate comment syntax for the file format. We also + recommend that a file or class name and description of purpose be + included on the same "printed page" as the copyright notice for + easier identification within third-party archives. + + Copyright 2026 姜十三 + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index c74efd0..398a714 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,128 @@ +
+ # 姜十三论坛(jiang13-bbs) -一个基于 Go + Next.js 的现代社区论坛系统,采用 SSR 直出 + HttpOnly Cookie 认证方案,具备完整的帖子/评论/通知/签到/管理后台功能。 +一个基于 **Go + Next.js** 构建的现代化社区论坛系统,SSR 直出、HttpOnly Cookie 认证,内置内容社区、代码片段、书库、积分体系、实时群聊与完整管理后台,开箱即用。 -> **许可声明:** 本项目为专有软件(Proprietary),源码可见但不构成开源授权。详见 [LICENSE](./LICENSE)。未经书面许可,不得使用、部署或分发。 +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](./LICENSE) +[![Version](https://img.shields.io/badge/version-0.2.9-green.svg)](./VERSION) +[![Go](https://img.shields.io/badge/Go-1.27-00ADD8?logo=go&logoColor=white)](https://go.dev/) +[![Next.js](https://img.shields.io/badge/Next.js-16-black?logo=nextdotjs&logoColor=white)](https://nextjs.org/) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#贡献指南) + +
+ +--- + +## 目录 + +- [项目截图](#项目截图) +- [功能特性](#功能特性) +- [技术栈](#技术栈) +- [快速开始](#快速开始) +- [配置说明](#配置说明) +- [Docker 部署](#docker-部署) +- [项目结构](#项目结构) +- [安全设计](#安全设计) +- [开发与测试](#开发与测试) +- [更新日志](#更新日志) +- [贡献指南](#贡献指南) +- [许可证](#许可证) +- [联系方式](#联系方式) +- [赞赏支持](#赞赏支持) + +--- + +## 项目截图 + +| 首页信息流 | 帖子详情 | +| :---: | :---: | +| ![首页](docs/images/首页.png) | ![帖子详情](docs/images/帖子详情.png) | +| 代码片段广场 | 书库在线阅读 | +| ![代码片段](docs/images/代码片段.png) | ![书库](docs/images/书库.png) | +| 管理后台仪表盘 | 访问统计分析 | +| ![管理后台](docs/images/管理后台.png) | ![访问统计](docs/images/访问统计.png) | + + + +--- + +## 功能特性 + +### 内容社区 + +- 帖子发布 / 编辑 / 删除,支持普通帖、**问答帖**(采纳答案、状态流转)、**投票帖**、**悬赏帖**、**抽奖帖**,以及密码帖与付费解锁内容 +- Markdown 写作与渲染:Shiki 代码高亮、目录导航、图片灯箱、视频/附件 +- 楼层化评论:主评论占楼层、子评论不占楼层,最多 10 层嵌套,单次查询拼装评论树,无 N+1 +- 评论与帖子均提供**编辑历史**、软删除与回收站(恢复 / 彻底删除) +- 点赞、收藏、关注、置顶、推荐、锁定,联合唯一索引防止重复互动 +- 附件可设定积分价格,支持付费下载 + +### 积分与成长体系 + +- 每日签到 + 连续签到加成,积分余额与完整流水台账 +- 用户等级体系(经验值自动升级)与成就徽章(手动授予 / 自动通知) +- 全站积分排行榜与个人排名 + +### 代码片段 + +- 独立的代码片段广场:多语言语法高亮、按语言筛选、全站统计 +- Fork、一键复制计数、片段评论、版本历史、可见性管理 + +### 书库 / 文档中心 + +- 书籍文档管理,两级章节(章 → 节)结构 +- 支持 **EPUB 解析导入**、Markdown 单篇 / 批量导入、整库导出与单本导出 +- 内置在线阅读器,封面、附件下载一应俱全 + +### 私信与群聊 + +- 一对一私信、多人群聊房间、默认全站大厅 +- 群成员管理:邀请 / 移出、禁言、角色设置;消息撤回、未读汇总、会话置顶 + +### 实时通知 + +- 评论、回复、点赞、@、系统通知等多类型站内消息 +- 未读红点 **SSR 首屏直出**,WebSocket 实时推送,页面回到前台时 HTTP 校准,支持单条 / 全部已读 + +### 管理后台 + +- 仪表盘与分析概览(Recharts 图表) +- 内容审核队列(待审帖子 / 评论,板块管理员仅见授权板块)与内容回收站 +- 板块管理、版主授权、排序 +- 用户管理:角色、细粒度权限码、封禁、消息权限、登录日志、登录设备、积分调整 +- 公告文章、站点单页(CMS)管理 +- 广告位与赞助商配置(含用户自助购买提交、二维码、验证码) +- 友情链接:用户申请、后台审批 +- 侧边栏可视化布局配置 + 自定义 HTML 小部件 +- 背景图、品牌 Logo 上传裁剪;全站媒体库盘点 +- **访问统计**:概览、实时、来源、页面、访客、安全、事件等多维度分析 +- 旧站数据导入(WordPress / Typecho 等) +- 更新时间线:从 Git Commits / Releases 自动生成更新日志 +- 维护模式、系统诊断、SMTP 邮件配置(凭据加密存储) +- 支持 S3 / MinIO 对象存储:文件内容去重、自适应图片变体、WebP 自动转换 + +### SEO + +- 全站 SSR / ISR,title、description、OG 等关键内容均在初始 HTML 中 +- 帖子详情页输出 `DiscussionForumPosting` 类型 JSON-LD +- 动态 `sitemap`、`robots`、Canonical 链接 --- ## 技术栈 -| 层级 | 技术 | -|------|------| +| 层级 | 技术选型 | +|------|----------| | 后端 | Go 1.27 · Gin · GORM · PostgreSQL 17 | -| 前端 | Next.js 16 (App Router) · TypeScript · Tailwind CSS v4 | -| 认证 | JWT (HttpOnly Cookie) + Refresh Token 轮转 + CSRF 双提交 + Token Version 撤销 | -| 部署 | 官方:`deploy/` Docker Compose(Next SSR + Go + Postgres,反代自备);本地开发仅 Compose 起数据库 | - ---- - -## 功能概览 - -### 用户 - -- 注册 / 登录 / 登出(注册即登录,免二次跳转) -- 个人资料编辑:昵称、头像、邮箱、个性签名 -- 修改密码(改密后全端踢下线) -- 个人主页:发帖列表、评论列表(分页)、签到积分 -- 每日签到(积分 + 连续签到天数) - -### 内容 - -- 帖子发帖 / 编辑 / 删除(作者本人或管理员) -- 帖子类型:普通帖 / 问答帖 -- 帖子置顶 / 推荐(仅管理员) -- 评论楼层制:主评论 = 楼层,子评论不占楼层,最多 10 层嵌套 -- 楼层分页 + 内存拼装评论树(单次查询,无 N+1) -- Markdown 渲染(代码高亮、引用、列表等) -- 帖子点赞(联合唯一索引防重复) - -### 通知 - -- 站内通知:评论你的帖子、回复你的评论、点赞你的帖子 -- 未读红点 SSR 直出,实时靠 WebSocket 推送,回前台时 HTTP 校准 -- 单条标记已读 / 全部已读 - -### 管理后台 - -- 公告管理(草稿 / 发布、标签预设色) -- 仪表盘 - -### SEO - -- 全站 SSR/ISR,SEO 关键内容在初始 HTML 中 -- 帖子详情页输出 `DiscussionForumPosting` 类型 JSON-LD -- 动态 `sitemap.ts` + `robots.ts`(由 Next.js 对外提供,后端不重复挂 `/sitemap.xml` / `/robots.txt`) - -### 安全 - -- CSP / HSTS / X-Frame-Options: DENY / X-Content-Type-Options / Referrer-Policy -- 速率限制:登录 20/min、注册 10/min、发帖新用户 24h 冷静期 -- Refresh Token 一次性轮转 + 盗用检测(吊销后重放 → 杀全家族) -- 生产 HTTPS 启用 `__Host-` Cookie 前缀 - ---- - -## 项目结构 - -``` -jiang13-bbs/ -├── backend/ # Go 后端 -│ ├── cmd/jiang13/ # 入口 main.go -│ ├── config/ # 配置解析(app.ini + 环境变量) -│ ├── handler/ # HTTP 处理器 -│ ├── middleware/ # 认证 / CSRF / 限流 / 安全头 -│ ├── model/ # GORM 模型 + 数据库初始化 -│ ├── router/ # 路由注册 -│ ├── service/ # 业务逻辑层 -│ ├── app.ini.example # 配置模板 -│ └── go.mod -├── frontend/ # Next.js 前端 -│ ├── app/ # App Router 页面 -│ │ ├── settings/ # 账号设置(资料 + 密码) -│ │ ├── u/[id]/ # 用户主页 -│ │ ├── post/[id]/ # 帖子详情 -│ │ ├── compose/ # 发帖 -│ │ ├── notifications/ # 通知列表 -│ │ ├── admin/ # 管理后台 -│ │ └── ... -│ ├── components/ # 复用组件 -│ ├── lib/ # API 封装 / 工具函数 -│ ├── middleware.ts # SSR 登录态静默轮转 -│ ├── next.config.ts # Rewrite 代理 / 图片缓存 -│ └── .env.example -├── deploy/ # 官方运行时(web + api + Postgres,反代自备) -├── docker-compose.yml # 仅本地开发 Postgres -├── LICENSE # 专有软件许可协议 -└── .gitignore -``` +| 前端 | Next.js 16(App Router)· React 19 · TypeScript · Tailwind CSS v4 | +| 认证 | JWT(HttpOnly Cookie)+ Refresh Token 轮转 + CSRF 双提交 + Token Version 撤销 | +| 实时 | WebSocket(gorilla/websocket) | +| 存储 | 本地存储 / S3(MinIO),Markdown 引擎 Goldmark,代码高亮 Shiki | +| 部署 | Docker Compose:Next SSR + Go API + PostgreSQL(反向代理自备) | --- @@ -104,8 +131,8 @@ jiang13-bbs/ ### 前置条件 - Go 1.27+ -- Node.js 20+ / pnpm 或 npm -- PostgreSQL 17(或通过 Docker Compose 启动) +- Node.js 20+(npm) +- PostgreSQL 17(可通过 Docker Compose 启动) ### 1. 启动数据库 @@ -123,9 +150,9 @@ go mod download go run ./cmd/jiang13 ``` -**工作目录必须是 `backend/`。** 上传文件与 JWT 密钥落在 `backend/data/`(头像 `backend/data/uploads/avatars/`)。不要在仓库根或 `backend/cmd/jiang13/` 执行 `go run`,否则会另建一套 data,数据库里头像 URL 会对不上文件。启动日志应含 `workPath=.../backend` 和 `dataDir=.../backend/data`。需要覆盖时设置 `JIANG13_WORK_PATH` 指向 `backend/`。 +> **工作目录必须是 `backend/`。** 上传文件与 JWT 密钥落在 `backend/data/`(头像位于 `backend/data/uploads/avatars/`)。在仓库根或 `backend/cmd/jiang13/` 下执行会另建一套 data,导致数据库中的文件 URL 对不上。启动日志应包含 `workPath=.../backend`;需要覆盖时可设置 `JIANG13_WORK_PATH`。 -后端默认监听 `:3001`,首次启动自动执行数据库迁移并写入默认板块。同一时间只跑一个后端进程。 +后端默认监听 `:3001`,首次启动自动执行数据库迁移并写入默认板块。 ### 3. 启动前端 @@ -136,13 +163,17 @@ npm install npm run dev ``` -前端默认监听 `:3000`,通过 `next.config.ts` rewrite 将 `/api/*` 代理到后端。 +前端默认监听 `:3000`,通过 `next.config.ts` 的 rewrite 将 `/api/*` 代理到后端。 + +**空数据库首次注册的账号将自动成为站长。** + +> Windows 用户也可直接使用仓库根目录的 `start.bat`(或 `start.sh`)一键启动开发环境,停止使用 `stop.bat` / `stop.sh`。 --- ## 配置说明 -### 后端(`backend/app.ini`) +### 后端 `backend/app.ini` ```ini [server] @@ -152,108 +183,189 @@ HTTP_PORT = 3001 DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable [security] -; 留空则自动生成并持久化到 data/.jwt_secret -; 生产环境务必显式指定强随机值 +; 留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值 JWT_SECRET = +; 32 字节随机值的标准 Base64(openssl rand -base64 32),用于加密邮件 / S3 凭据 +SETTINGS_MASTER_KEY = [paths] DATA = data [app] DEV_MODE = true +SITE_URL = http://localhost:3000 +CORS_ORIGINS = ``` -环境变量优先级高于 app.ini:`HTTP_PORT`、`DB_DSN`、`JWT_SECRET`、`DEV_MODE`。`JIANG13_WORK_PATH` 可强制指定后端根目录(`app.ini` 与 `data/` 所在处)。 +环境变量优先级高于 `app.ini`:`HTTP_PORT`、`DB_DSN`、`JWT_SECRET`、`SETTINGS_MASTER_KEY`、`DEV_MODE`、`SITE_URL`、`CORS_ORIGINS`、`TRUSTED_PROXIES`。 -### 前端(`frontend/.env.local`) +### 前端 `frontend/.env.local` ```bash -# SSR 与 middleware 直连后端(本地开发) +# SSR 与 middleware 直连后端 NEXT_PUBLIC_API_URL=http://localhost:3001 -# 部署到 Cloudflare Workers 等边缘环境时必须显式配置 +# 仅服务端可见;生产构建必须显式配置 # BACKEND_URL=https://your-backend.example.com + +# 与后端 DEV_MODE 对齐 +DEV_MODE=true +``` + +完整环境变量清单见 [.env.example](./.env.example)、[frontend/.env.example](./frontend/.env.example)。 + +--- + +## Docker 部署 + +官方运行时位于 [`deploy/`](./deploy/),一体交付 **Next.js SSR + Go API + PostgreSQL 17**,不含反向代理(TLS 与同源路由由你的 Nginx / Caddy 处理)。 + +```bash +cd deploy +cp .env.example .env +# 编辑 .env:POSTGRES_PASSWORD、JWT_SECRET、SITE_URL +docker compose up -d --build +``` + +然后将同一 Host 反向代理到容器: + +- `/` → web `:3000` +- `/api/`(含 `/api/ws`)、`/uploads/`、`/health` → api `:3001` + +`GET https://你的域名/health` 返回 `{"status":"ok"}` 即部署成功。 + +详细说明、升级与备份命令见 [deploy/README.md](./deploy/README.md),Nginx 配置示例见 [deploy/nginx.example.conf](./deploy/nginx.example.conf),运行时契约见 [deploy/CONTRACT.md](./deploy/CONTRACT.md)。1Panel 面板部署可参考 [docs/deploy-1panel.md](./docs/deploy-1panel.md)。 + +--- + +## 项目结构 + +``` +jiang13-bbs/ +├── backend/ # Go 后端 +│ ├── cmd/jiang13/ # 程序入口 +│ ├── config/ # 配置解析(app.ini + 环境变量) +│ ├── handler/ # HTTP 处理器 +│ ├── middleware/ # 认证 / CSRF / 限流 / 安全头 / 访问捕获 +│ ├── model/ # GORM 模型与数据库初始化 +│ ├── realtime/ # WebSocket Hub +│ ├── router/ # 路由注册 +│ ├── service/ # 业务逻辑层 +│ ├── markdown/ # Markdown 扩展(隐藏内容等) +│ └── app.ini.example +├── frontend/ # Next.js 前端 +│ ├── app/ # App Router 页面(含 admin 后台) +│ ├── components/ # 通用组件 +│ ├── hooks/ # 自定义 Hooks +│ ├── lib/ # API 封装与工具函数 +│ ├── public/ # 静态资源(表情图等) +│ ├── middleware.ts # SSR 登录态静默轮转 +│ └── next.config.ts # Rewrite 代理 / 图片缓存 +├── deploy/ # 官方运行时(Docker Compose / Dockerfile) +├── docs/ # 文档与截图 +├── docker-compose.yml # 本地开发用(仅 PostgreSQL) +├── start.bat / start.sh # 本地一键启动 +├── VERSION +└── LICENSE ``` --- -## 认证架构 +## 安全设计 -采用三 Cookie + Token Version 方案,不使用 localStorage 存储 token: +采用三 Cookie 方案,令牌不落 localStorage: | Cookie | 用途 | 属性 | |--------|------|------| | `j13_token` | Access JWT(15 分钟) | HttpOnly · SameSite=Lax | -| `j13_refresh` | Refresh Token(7 天,DB 存储 SHA-256) | HttpOnly · SameSite=Lax · Path=/ | +| `j13_refresh` | Refresh Token(7 天,DB 存 SHA-256) | HttpOnly · SameSite=Lax · Path=/ | | `j13_csrf` | CSRF Token(JS 可读) | SameSite=Lax | -- **SSR 直出登录态:** `middleware.ts` 在页面/RSC 请求前本地解码 access token 的 exp,过期时静默调后端 refresh 轮转,新 cookie 注入请求头供 SSR 使用并透传浏览器。 -- **客户端续期:** `lib/api.ts` 的 `fetchWithRefresh` 遇到 401 自动调 `/api/auth/refresh` 重试。 -- **撤销机制:** 改密码 / 封禁 / 管理员强制下线时递增 `User.TokenVersion`,所有旧 JWT 立即失效。 -- **盗用检测:** Refresh Token 一次性轮转,已吊销 token 重放时撤销该用户全部 refresh token。 - -生产 HTTPS 下 cookie 名自动启用 `__Host-` 前缀。 - -若本机曾跑过早期构建,浏览器里可能还留着 `Path=/api/auth` 的 `j13_refresh`(HttpOnly,页面脚本删不掉)。打开开发者工具 → Application → Cookies,删掉该条,或对该源执行「清除站点数据」。之后只应存在 `Path=/` 的三枚 cookie。 +- **SSR 直出登录态**:`middleware.ts` 在渲染前检查 Access Token 过期时间,过期则静默轮转,新 Cookie 同时供 SSR 使用并透传浏览器 +- **Refresh Token 一次性轮转 + 盗用检测**:已吊销 Token 被重放时,撤销该用户全部 Refresh Token +- **Token Version 撤销**:改密 / 封禁 / 强制下线时递增版本号,旧 JWT 立即失效 +- **速率限制**:登录、注册、发帖(新用户 24h 冷静期)、评论、上传等分级限流,管理员豁免 +- **安全响应头**:CSP、HSTS、X-Frame-Options: DENY、X-Content-Type-Options、Referrer-Policy +- 生产 HTTPS 下 Cookie 自动启用 `__Host-` 前缀,要求前后端必须**同源部署**(不能拆分到子域) --- -## 部署 - -**官方运行时是 `deploy/docker-compose.yml`(Next SSR + Go + Postgres,不含反代)。** 说明与升级命令见 [deploy/README.md](./deploy/README.md),Nginx 示例见 [deploy/nginx.example.conf](./deploy/nginx.example.conf),契约见 [deploy/CONTRACT.md](./deploy/CONTRACT.md)。 - -本地开发仍用仓库根 `start.bat` / `start.sh` 与根目录 `docker-compose.yml`(只起 Postgres)。不要把开发流程改成必须打应用镜像。 - -更新应用(镜像已发布时): +## 开发与测试 ```bash -cd deploy -docker compose pull -docker compose up -d +# 后端测试 +cd backend && go test ./... + +# 前端测试(Node 内置 test runner) +cd frontend && npm test + +# 前端代码检查 +npm run lint ``` -后台一键更新、预编译包不在本期。Cloudflare Workers 不是官方运行时。 +开发约定: -### 生产环境要点 - -1. **官方 Compose:** `DEV_MODE=false`,强随机 `JWT_SECRET` 与 `POSTGRES_PASSWORD`;由你现有的 Nginx/Caddy 提供 HTTPS,并把页面与 `/api`、`/api/ws`、`/uploads` 挂到同一 Host。 -2. **CORS 与 Canonical:** 设 `SITE_URL` 为对外 origin(不要改 `router.go` 写死域名)。官方 Compose 会同时注入 api 与 web;web 用它生成 Canonical / sitemap,不要填 `http://api:3001`。 -3. **自管部署:** 仍须同源反代。Workers 前端须保持 `middleware.ts` 文件名(OpenNext 不识别 `proxy.ts`),实时通道由反代承接 `/api/ws`。 - -### WebSocket(开发 vs 生产) - -浏览器发起 WebSocket 握手**一定会带 `Origin`**。后端拒绝空 Origin,这只挡住 curl / 脚本探测,**不影响 `npm run dev`,也不影响生产浏览器**。 - -**开发环境(`DEV_MODE=true`)** - -- 页面在 `http://localhost:3000`,WS 直连 `ws://localhost:3001/api/ws`(Next.js rewrite 不透传 `Upgrade`)。 -- 二者端口不同,不是浏览器意义上的同源。后端因此放行 Origin 主机为 `localhost` / `127.0.0.1` 的**任意端口**。 -- Cookie 不按端口隔离,`:3000` 上的 `j13_token` 会随握手带到 `:3001`。不要把 `DEV_MODE` 改成 `false` 再跑这对端口,否则 Origin `localhost:3000` 对不上 Host `localhost:3001`,握手会被拒。 - -**生产环境(`DEV_MODE=false`)** - -- Origin 的 host(含端口)必须与请求 `Host` **完全一致**。请用 Nginx/Caddy 把前端与 `/api/`、`/api/ws` 挂到同一域名,例如 `https://bbs.example.com`。 -- 反向代理需透传 `Upgrade`、`Connection`,并保留原始 `Host` / `Origin`。 -- 生产 cookie 使用 `__Host-` 前缀,要求 `Secure`、`Path=/`、**不能设 Domain**。因此 API **不能**放到 `api.example.com` 这类另一子域,否则登录 cookie 与 WS 都会失效。 -- Cloudflare Workers / OpenNext **不能**透传任意 WebSocket。若前端部署在 Workers,实时通道必须由同源反代(Nginx 等)承接 `/api/ws`,而不是指望 Workers 升级连接。 -- 可用 `NEXT_PUBLIC_WS_URL` 覆盖前端连线地址;一旦覆盖,Origin 仍须与后端看到的 Host 一致。 +- Next.js 16 中 `params` / `searchParams` / `cookies()` / `headers()` 均需 `await` +- 权限校验只在 Go 后端执行,前端不做判定 +- 配色仅修改 `frontend/app/globals.css` 中的 CSS 令牌,不在组件内硬编码颜色 +- 所有公开页面保持 SSR / ISR,SEO 关键内容必须在初始 HTML 中 --- -## 开发约定 +## 更新日志 -- **Next.js 16:** `params` / `searchParams` / `cookies()` / `headers()` 必须 `await`。 -- **SEO:** 所有公开页面 SSR/ISR,SEO 关键内容(title、description、OG、JSON-LD)必须在初始 HTML 中。 -- **权限:** 权限校验只在 Go 后端执行,前端不做判定。 -- **配色:** 仅修改 `frontend/app/globals.css` 中的 CSS 令牌,禁止在组件内硬编码颜色。 -- **无障碍:** 文字对比度 ≥4.5:1,交互控件边界 ≥3:1(WCAG 2.1 AA)。 +版本发布与部署契约变更记录见 [deploy/CHANGELOG.md](./deploy/CHANGELOG.md),当前版本 **v0.2.9**。 + +--- + +## 贡献指南 + +欢迎提交 Issue 与 Pull Request! + +1. Fork 本仓库并创建特性分支:`git checkout -b feature/your-feature` +2. 提交前请确保测试通过:后端 `go test ./...`,前端 `npm test` +3. 提交代码并推送分支 +4. 在 GitHub 上发起 Pull Request,描述清楚改动内容与动机 + +- 提交信息建议遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范(`feat:` / `fix:` / `docs:` 等) +- 发现安全问题请**不要**直接提 Issue,发送邮件至 aarbbs@88.com 私下反馈 --- ## 许可证 -**专有软件(Proprietary)** — Copyright © 2026 姜十三,保留所有权利。 +本项目基于 [Apache License 2.0](./LICENSE) 开源。 -源码仅可见,未经书面许可不得使用、部署、修改或分发。详见 [LICENSE](./LICENSE)。 +``` +Copyright 2026 姜十三 -联系方式:aarbbs@88.com +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 +``` + +--- + +## 联系方式 + +- QQ:97975089 + +- 邮箱:97975089@qq.com + +- Issue:[GitHub Issues](https://github.com/aarbbs/jiang13-bbs/issues) + +--- + +## 赞赏支持 + +如果本项目对你有帮助,欢迎请作者喝杯咖啡 ☕,你的支持是持续维护的动力。 + +

+ 微信打赏 +      + 支付宝打赏 +

+ +

微信打赏   |   支付宝打赏

diff --git a/backend/handler/chat.go b/backend/handler/chat.go index 49c2010..f13d0a9 100644 --- a/backend/handler/chat.go +++ b/backend/handler/chat.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "strconv" + "strings" "time" "github.com/freefire/jiang13-bbs/middleware" @@ -240,11 +241,32 @@ func (h *Handlers) JoinChatRoom(c *gin.Context) { if !ok { return } - _, _, err := h.Chat.Join(claims.ID, roomID) + member, joined, err := h.Chat.Join(claims.ID, roomID) if err != nil { c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()}) return } + // 首次加入则向房间广播系统提示 + 成员变更 + if joined && member != nil { + displayName := h.Chat.DisplayName(claims.ID) + if displayName != "" { + sysMsg, _ := h.Chat.SendSystemMessage(roomID, displayName+" 加入了群聊") + if sysMsg != nil && sysMsg.ID > 0 { + h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ + Type: realtime.EventChatMessage, + Data: sysMsg, + }) + } + } + h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ + Type: realtime.EventChatMembership, + Data: gin.H{ + "room_id": roomID, + "status": "joined", + "user_id": claims.ID, + }, + }) + } c.JSON(http.StatusOK, gin.H{"ok": true}) } @@ -300,6 +322,35 @@ func (h *Handlers) InviteChatMembers(c *gin.Context) { Data: gin.H{"room_id": roomID, "status": "invited"}, }) } + // 被邀请者入群时向房间广播系统提示 + 成员变更 + if len(added) > 0 { + var names []string + for _, uid := range added { + if n := h.Chat.DisplayName(uid); n != "" { + names = append(names, n) + } + } + if len(names) > 0 { + content := strings.Join(names, "、") + " 加入了群聊" + sysMsg, _ := h.Chat.SendSystemMessage(roomID, content) + if sysMsg != nil && sysMsg.ID > 0 { + h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ + Type: realtime.EventChatMessage, + Data: sysMsg, + }) + } + } + for _, uid := range added { + h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{ + Type: realtime.EventChatMembership, + Data: gin.H{ + "room_id": roomID, + "status": "joined", + "user_id": uid, + }, + }) + } + } c.JSON(http.StatusOK, gin.H{"added": added}) } diff --git a/backend/handler/handlers.go b/backend/handler/handlers.go index 6217744..a784a56 100644 --- a/backend/handler/handlers.go +++ b/backend/handler/handlers.go @@ -42,6 +42,7 @@ type Handlers struct { Badge *service.BadgeService LeaderboardSvc *service.LeaderboardService LibrarySvc *service.LibraryService + SnippetSvc *service.SnippetService } // resolvePublishStatus 决定新帖/新评的初始状态: diff --git a/backend/handler/snippet.go b/backend/handler/snippet.go new file mode 100644 index 0000000..f96b7fd --- /dev/null +++ b/backend/handler/snippet.go @@ -0,0 +1,424 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + + "github.com/freefire/jiang13-bbs/middleware" + "github.com/freefire/jiang13-bbs/model" + "github.com/freefire/jiang13-bbs/service" + "github.com/gin-gonic/gin" +) + +// snippetError 统一错误映射 +func snippetError(c *gin.Context, err error) { + switch { + case errors.Is(err, service.ErrSnippetNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "代码片段不存在"}) + case errors.Is(err, service.ErrSnippetForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": "无权操作该代码片段"}) + case errors.Is(err, service.ErrSnippetBadInput): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrCommentNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"}) + case errors.Is(err, service.ErrCommentForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": "无权操作该评论"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "服务器错误"}) + } +} + +// SnippetList 公开片段广场 +func (h *Handlers) SnippetList(c *gin.Context) { + q := c.Query("q") + lang := c.Query("lang") + sort := c.Query("sort") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "12")) + items, total, err := h.SnippetSvc.ListPublic(q, lang, sort, page, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取片段列表失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total}) +} + +// SnippetLanguages 语言白名单 +func (h *Handlers) SnippetLanguages(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"languages": service.SnippetLanguages()}) +} + +// SnippetStats 广场聚合统计 +func (h *Handlers) SnippetStats(c *gin.Context) { + stats, err := h.SnippetSvc.Stats() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取统计失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"stats": stats}) +} + +// SnippetDetail 公开片段详情 +func (h *Handlers) SnippetDetail(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"}) + return + } + var viewerID uint + if claims := middleware.CurrentUser(c); claims != nil { + viewerID = claims.ID + } + isStaff := h.isStaff(c) + detail, err := h.SnippetSvc.GetPublic(uint(id), viewerID, isStaff) + if err != nil { + snippetError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"snippet": detail}) +} + +// SnippetMine 我的片段列表 +func (h *Handlers) SnippetMine(c *gin.Context) { + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + q := c.Query("q") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "12")) + items, total, err := h.SnippetSvc.ListMine(claims.ID, q, page, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取片段列表失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total}) +} + +// SnippetCreate 创建片段 +func (h *Handlers) SnippetCreate(c *gin.Context) { + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + var in service.SnippetInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + snippet, err := h.SnippetSvc.Create(&in, claims.ID) + if err != nil { + snippetError(c, err) + return + } + c.JSON(http.StatusCreated, gin.H{"snippet": snippet}) +} + +// SnippetUpdate 更新片段 +func (h *Handlers) SnippetUpdate(c *gin.Context) { + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"}) + return + } + var in service.SnippetInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + snippet, err := h.SnippetSvc.Update(uint(id), &in, claims.ID, h.isStaff(c)) + if err != nil { + snippetError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"snippet": snippet}) +} + +// SnippetDelete 删除片段(owner 或 staff) +func (h *Handlers) SnippetDelete(c *gin.Context) { + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"}) + return + } + if err := h.SnippetSvc.Delete(uint(id), claims.ID, h.isStaff(c)); err != nil { + snippetError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// SnippetFork Fork 公开片段 +func (h *Handlers) SnippetFork(c *gin.Context) { + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"}) + return + } + snippet, err := h.SnippetSvc.Fork(uint(id), claims.ID) + if err != nil { + snippetError(c, err) + return + } + c.JSON(http.StatusCreated, gin.H{"snippet": snippet}) +} + +// SnippetCopy 记录复制(节流由 service 内部处理) +func (h *Handlers) SnippetCopy(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"}) + return + } + key := c.ClientIP() + if claims := middleware.CurrentUser(c); claims != nil { + key = "user:" + strconv.FormatUint(uint64(claims.ID), 10) + } + if err := h.SnippetSvc.RecordCopy(uint(id), key); err != nil { + snippetError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// SnippetHistory 片段编辑历史 +func (h *Handlers) SnippetHistory(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"}) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "10")) + items, total, err := h.SnippetSvc.ListHistory(uint(id), page, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取历史失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total}) +} + +// SnippetForks 公开 fork 列表 +func (h *Handlers) SnippetForks(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"}) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "12")) + items, total, err := h.SnippetSvc.ListForks(uint(id), page, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取 Fork 列表失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total}) +} + +// SnippetComments 片段评论列表 +func (h *Handlers) SnippetComments(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"}) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + items, total, err := h.SnippetSvc.ListComments(uint(id), page, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取评论失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total}) +} + +// SnippetCommentCreate 创建片段评论 +func (h *Handlers) SnippetCommentCreate(c *gin.Context) { + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + snippetID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"}) + return + } + var in struct { + Content string `json:"content"` + ParentID *uint `json:"parent_id"` + } + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + comment, err := h.SnippetSvc.CreateComment(claims.ID, uint(snippetID), in.Content, in.ParentID) + if err != nil { + snippetError(c, err) + return + } + c.JSON(http.StatusCreated, gin.H{"comment": comment}) +} + +// SnippetCommentUpdate 更新片段评论 +func (h *Handlers) SnippetCommentUpdate(c *gin.Context) { + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"}) + return + } + var in struct { + Content string `json:"content"` + } + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + comment, err := h.SnippetSvc.UpdateComment(uint(cid), claims.ID, in.Content) + if err != nil { + snippetError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"comment": comment}) +} + +// SnippetCommentDelete 删除片段评论 +func (h *Handlers) SnippetCommentDelete(c *gin.Context) { + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"}) + return + } + if err := h.SnippetSvc.DeleteComment(uint(cid), claims.ID, h.isStaff(c)); err != nil { + snippetError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// SnippetCommentHistory 片段评论编辑历史(登录即可见;分页) +func (h *Handlers) SnippetCommentHistory(c *gin.Context) { + claims := middleware.CurrentUser(c) + cid, err := strconv.ParseUint(c.Param("cid"), 10, 64) + if err != nil || cid == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"}) + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "10")) + items, total, err := h.SnippetSvc.ListCommentHistory(h.loadActor(claims.ID), uint(cid), page, size) + if err != nil { + switch { + case errors.Is(err, service.ErrCommentNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, service.ErrCommentForbidden): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取编辑历史失败"}) + } + return + } + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 10 + } + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": total, + "page": page, + "size": size, + }) +} + +// AdminSnippetList 管理端片段列表 +func (h *Handlers) AdminSnippetList(c *gin.Context) { + q := c.Query("q") + visibility := c.Query("visibility") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + items, total, err := h.SnippetSvc.AdminList(q, visibility, page, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取片段列表失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "total": total}) +} + +// AdminSnippetDelete 管理端软删片段 +func (h *Handlers) AdminSnippetDelete(c *gin.Context) { + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"}) + return + } + if err := h.SnippetSvc.AdminDelete(uint(id), claims.ID); err != nil { + snippetError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// AdminSnippetSetVisibility 管理端强制修改可见性 +func (h *Handlers) AdminSnippetSetVisibility(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的片段 ID"}) + return + } + var in struct { + Visibility string `json:"visibility"` + } + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"}) + return + } + if err := h.SnippetSvc.AdminSetVisibility(uint(id), in.Visibility); err != nil { + snippetError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// isStaff 判断当前用户是否为管理角色 +func (h *Handlers) isStaff(c *gin.Context) bool { + claims := middleware.CurrentUser(c) + if claims == nil { + return false + } + return model.IsStaff(model.Role(claims.Role)) +} diff --git a/backend/model/db.go b/backend/model/db.go index d4c5428..9c659d3 100644 --- a/backend/model/db.go +++ b/backend/model/db.go @@ -64,6 +64,7 @@ func InitDB(dsn string) error { &Badge{}, &UserBadge{}, &PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{}, &ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{}, + &CodeSnippet{}, &SnippetVersion{}, &SnippetComment{}, &SnippetCommentEditHistory{}, ); err != nil { return fmt.Errorf("自动迁移失败: %w", err) } diff --git a/backend/model/models.go b/backend/model/models.go index c623704..c927ac3 100644 --- a/backend/model/models.go +++ b/backend/model/models.go @@ -576,6 +576,75 @@ type LibrarySection struct { DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } +// CodeSnippet 代码片段:用户所有,公开进广场,私有仅自己可见 +type CodeSnippet struct { + ID uint `gorm:"primaryKey" json:"id"` + Title string `gorm:"size:120;not null" json:"title"` + Description string `gorm:"type:text" json:"description"` + Language string `gorm:"size:32;not null;index" json:"language"` + Code string `gorm:"type:text;not null" json:"code"` + Visibility string `gorm:"size:16;not null;default:private;index" json:"visibility"` // public/private + UserID uint `gorm:"index;not null" json:"user_id"` + ForkedFromID *uint `gorm:"index" json:"forked_from_id"` + CopyCount int `gorm:"not null;default:0" json:"copy_count"` + ViewCount int `gorm:"not null;default:0" json:"view_count"` + CommentCount int `gorm:"not null;default:0" json:"comment_count"` + ForkCount int `gorm:"not null;default:0" json:"fork_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// SnippetVersion 编辑前旧值快照(同 PostEditHistory 思路) +type SnippetVersion struct { + ID uint `gorm:"primaryKey" json:"id"` + SnippetID uint `gorm:"index;not null" json:"snippet_id"` + EditorID uint `gorm:"index;not null" json:"editor_id"` + OldTitle string `gorm:"size:120;not null;default:''" json:"old_title"` + OldLanguage string `gorm:"size:32;not null;default:''" json:"old_language"` + OldDescription string `gorm:"type:text" json:"old_description"` + OldCode string `gorm:"type:text;not null" json:"old_code"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + +// SnippetComment 片段评论(楼层树:ParentID 空=主评,非空=回复;RootID=所属主楼) +type SnippetComment struct { + ID uint `gorm:"primaryKey" json:"id"` + SnippetID uint `gorm:"index;not null" json:"snippet_id"` + UserID uint `gorm:"index;not null" json:"user_id,omitempty"` + ParentID *uint `gorm:"index" json:"parent_id"` + RootID *uint `gorm:"index" json:"root_id"` + Depth int `gorm:"not null;default:0" json:"depth"` + Content string `gorm:"type:text;not null" json:"content"` + DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"` + DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// SnippetCommentEditHistory 片段评论修订快照(每次编辑前的旧正文;登录用户可见) +type SnippetCommentEditHistory struct { + ID uint `gorm:"primaryKey" json:"id"` + CommentID uint `gorm:"index;not null" json:"comment_id"` + EditorID uint `gorm:"index;not null" json:"editor_id"` + OldContent string `gorm:"type:text;not null" json:"old_content"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + +const ( + SnippetVisibilityPublic = "public" + SnippetVisibilityPrivate = "private" +) + +// NormalizeSnippetVisibility 归一化可见性:空或未知值一律回落 private +func NormalizeSnippetVisibility(v string) string { + if v == SnippetVisibilityPublic { + return SnippetVisibilityPublic + } + return SnippetVisibilityPrivate +} + // SiteSetting 站点级键值设置(如历史在线峰值 peak_online) type SiteSetting struct { Key string `gorm:"primaryKey;size:64" json:"key"` diff --git a/backend/router/router.go b/backend/router/router.go index 5e9219f..f1e486f 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -104,6 +104,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { librarySvc := service.NewLibraryService(model.DB, cfg.DataDir). WithSetting(settingSvc). WithUploadsDir(filepath.Join(cfg.DataDir, "uploads")) + snippetSvc := service.NewSnippetService(model.DB) adminUserSvc := service.NewAdminUserService(model.DB) legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads")) moderationSvc := service.NewModerationService(model.DB, notifSvc) @@ -165,6 +166,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Badge: badgeSvc, LeaderboardSvc: leaderboardSvc, LibrarySvc: librarySvc, + SnippetSvc: snippetSvc, } // 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用) notifSvc.OnNotifyNew = func(userID uint) { @@ -229,6 +231,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { pubAPI.GET("/library", h.LibraryList) pubAPI.GET("/library/:slug", h.LibraryDetail) pubAPI.GET("/library/files/:fid/download", h.LibraryFileDownload) + // 代码片段(公开广场/详情/评论/Fork 列表) + pubAPI.GET("/snippets", h.SnippetList) + pubAPI.GET("/snippets/languages", h.SnippetLanguages) + pubAPI.GET("/snippets/stats", h.SnippetStats) + pubAPI.GET("/snippets/:id", h.SnippetDetail) + pubAPI.GET("/snippets/:id/comments", h.SnippetComments) + pubAPI.GET("/snippets/:id/forks", h.SnippetForks) pubAPI.GET("/site-state", h.SiteState) // 自适应图片变体(?u=<源图URL>&w=<白名单宽度>,公开只读、immutable 缓存) pubAPI.GET("/img", h.ImageVariant) @@ -308,6 +317,19 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { // 时间线:从 Git releases 页导入更新日志 api.POST("/timeline/from-releases", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromReleases) + // 代码片段(登录区) + api.POST("/snippets", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateSnippet)), h.SnippetCreate) + api.GET("/snippets/mine", h.SnippetMine) + api.PUT("/snippets/:id", h.SnippetUpdate) + api.DELETE("/snippets/:id", h.SnippetDelete) + api.POST("/snippets/:id/fork", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateSnippet)), h.SnippetFork) + api.POST("/snippets/:id/copy", h.SnippetCopy) + api.GET("/snippets/:id/history", h.SnippetHistory) + api.POST("/snippets/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.SnippetCommentCreate) + api.PUT("/snippets/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.SnippetCommentUpdate) + api.DELETE("/snippets/:id/comments/:cid", h.SnippetCommentDelete) + api.GET("/snippets/:id/comments/:cid/history", h.SnippetCommentHistory) + // 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403 chatAPI := api.Group("/chat", h.RequireMessagesOpen) { @@ -508,6 +530,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { usersAPI.GET("/users/:id/messages", h.AdminUserAuditMessages) } + // 代码片段管理(任意 staff) + staffAPI.GET("/snippets", h.AdminSnippetList) + staffAPI.DELETE("/snippets/:id", h.AdminSnippetDelete) + staffAPI.PUT("/snippets/:id/visibility", h.AdminSnippetSetVisibility) + // 板块管理(仅站长):增删改、排序、版主授权 boardsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermBoards)) { diff --git a/backend/service/chat.go b/backend/service/chat.go index 9e24efa..5acebdc 100644 --- a/backend/service/chat.go +++ b/backend/service/chat.go @@ -767,6 +767,18 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro return s.removeMember(roomID, targetID) } +// DisplayName 取用户群聊展示名(昵称优先,退回用户名);用户不存在/封禁时返回空串 +func (s *ChatService) DisplayName(userID uint) string { + var u model.User + if err := s.db.Select("id", "nickname", "username").First(&u, userID).Error; err != nil { + return "" + } + if u.Nickname != "" { + return u.Nickname + } + return u.Username +} + // SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true)。 // 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览, // 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。 diff --git a/backend/service/header_nav.go b/backend/service/header_nav.go index eee8e73..dde66a7 100644 --- a/backend/service/header_nav.go +++ b/backend/service/header_nav.go @@ -24,7 +24,7 @@ const ( // HeaderNavBuiltinKeys 内置菜单 key 白名单(顺序即默认顺序); // 与前端 lib/headerNav.ts 的 HEADER_NAV_BUILTIN_KEYS 保持一致。 -var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library"} +var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library", "snippets"} // HeaderNavItem 顶部导航菜单项: // diff --git a/backend/service/header_nav_test.go b/backend/service/header_nav_test.go index 72df41b..02f5703 100644 --- a/backend/service/header_nav_test.go +++ b/backend/service/header_nav_test.go @@ -25,18 +25,19 @@ func newHeaderNavTestService(t *testing.T) *SettingService { func TestHeaderNavDefaultWhenMissing(t *testing.T) { s := newHeaderNavTestService(t) got := s.HeaderNav() - if len(got) != 3 || + if len(got) != 4 || got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) || got[1] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "leaderboard"}) || - got[2] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "library"}) { + got[2] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "library"}) || + got[3] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "snippets"}) { t.Fatalf("missing row should fall back to builtin defaults, got %+v", got) } pub, err := s.Public() if err != nil { t.Fatal(err) } - if len(pub.HeaderNav) != 3 { - t.Fatalf("Public().HeaderNav len = %d, want 3", len(pub.HeaderNav)) + if len(pub.HeaderNav) != 4 { + t.Fatalf("Public().HeaderNav len = %d, want 4", len(pub.HeaderNav)) } } @@ -133,7 +134,7 @@ func TestNormalizeHeaderNavDedupesBuiltins(t *testing.T) { func TestParseHeaderNavJSONTolerant(t *testing.T) { // 坏 JSON 回落默认 - if got := parseHeaderNavJSON("{bad"); len(got) != 3 { + if got := parseHeaderNavJSON("{bad"); len(got) != 4 { t.Fatalf("bad json should fall back to defaults, got %+v", got) } // 空数组 = 全部隐藏 diff --git a/backend/service/ratelimit.go b/backend/service/ratelimit.go index dbdbdb5..5fb9aa0 100644 --- a/backend/service/ratelimit.go +++ b/backend/service/ratelimit.go @@ -85,6 +85,7 @@ const ( RateAdSubmit = "ad_submit" // 广告申购 5/分钟(按 IP) RateCaptcha = "captcha" // 验证码刷新 30/分钟 RateTelemetry = "telemetry" // 页面浏览埋点 120/分钟(按 IP) + RateSnippet = "snippet" // 代码片段创建/Fork 10/分钟 ) // DefaultRateLimiter 创建默认速率限制器 @@ -102,5 +103,6 @@ func DefaultRateLimiter() *RateLimiter { rl.SetLimit(RateAdSubmit, 5) // 广告申购 5/分钟 rl.SetLimit(RateCaptcha, 30) // 验证码 30/分钟 rl.SetLimit(RateTelemetry, 120) // 埋点 120/分钟 + rl.SetLimit(RateSnippet, 10) // 代码片段 10/分钟 return rl } diff --git a/backend/service/snippet.go b/backend/service/snippet.go new file mode 100644 index 0000000..c2338dc --- /dev/null +++ b/backend/service/snippet.go @@ -0,0 +1,1022 @@ +package service + +import ( + "errors" + "fmt" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/freefire/jiang13-bbs/model" + "gorm.io/gorm" +) + +// 代码片段语言白名单(与前端 highlight.js 注册表对齐) +var snippetLanguages = map[string]struct{}{ + "plaintext": {}, "bash": {}, "c": {}, "cpp": {}, "csharp": {}, "css": {}, + "dart": {}, "diff": {}, "dockerfile": {}, "go": {}, "graphql": {}, "html": {}, + "ini": {}, "java": {}, "javascript": {}, "json": {}, "jsonc": {}, "jsx": {}, + "kotlin": {}, "lua": {}, "makefile": {}, "markdown": {}, "nginx": {}, "php": {}, + "powershell": {}, "prisma": {}, "python": {}, "r": {}, "regex": {}, "ruby": {}, + "rust": {}, "scss": {}, "shell": {}, "sql": {}, "swift": {}, "toml": {}, + "tsx": {}, "typescript": {}, "vue": {}, "xml": {}, "yaml": {}, +} + +const ( + snippetTitleMaxRunes = 120 + snippetDescMaxBytes = 8 << 10 // 8KB + snippetCodeMaxBytes = 64 << 10 // 64KB + snippetCommentMaxLen = 2000 + snippetCopyThrottle = 10 * time.Second +) + +var ( + ErrSnippetNotFound = errors.New("代码片段不存在") + ErrSnippetForbidden = errors.New("无权操作该代码片段") + ErrSnippetBadInput = errors.New("代码片段参数无效") +) + +// SnippetLanguages 返回支持的语言白名单(排序固定,便于前端渲染) +func SnippetLanguages() []string { + out := make([]string, 0, len(snippetLanguages)) + for k := range snippetLanguages { + out = append(out, k) + } + // 固定排序:plaintext 置顶,其余按字典序 + plaintext := []string{"plaintext"} + var rest []string + for _, v := range out { + if v != "plaintext" { + rest = append(rest, v) + } + } + for i := 0; i < len(rest)-1; i++ { + for j := i + 1; j < len(rest); j++ { + if rest[i] > rest[j] { + rest[i], rest[j] = rest[j], rest[i] + } + } + } + return append(plaintext, rest...) +} + +// normalizeSnippetLanguage 空值回落 plaintext,不在白名单则报错 +func normalizeSnippetLanguage(lang string) (string, error) { + lang = strings.ToLower(strings.TrimSpace(lang)) + if lang == "" { + return "plaintext", nil + } + if _, ok := snippetLanguages[lang]; !ok { + return "", ErrSnippetBadInput + } + return lang, nil +} + +// SnippetInput 创建/更新代码片段入参 +type SnippetInput struct { + Title string `json:"title"` + Language string `json:"language"` + Description string `json:"description"` + Code string `json:"code"` + Visibility string `json:"visibility"` +} + +// badInput 输入校验错误(wrap ErrSnippetBadInput,handler 据此返回 400) +func badInput(msg string) error { + return fmt.Errorf("%w: %s", ErrSnippetBadInput, msg) +} + +func (in *SnippetInput) normalize() error { + in.Title = strings.TrimSpace(in.Title) + if in.Title == "" { + return badInput("标题不能为空") + } + if utf8.RuneCountInString(in.Title) > snippetTitleMaxRunes { + return badInput("标题不能超过 120 字") + } + lang, err := normalizeSnippetLanguage(in.Language) + if err != nil { + return badInput("不支持的语言") + } + in.Language = lang + if strings.TrimSpace(in.Code) == "" { + return badInput("代码不能为空") + } + if len(in.Code) > snippetCodeMaxBytes { + return badInput("代码不能超过 64KB") + } + in.Description = strings.TrimSpace(in.Description) + if len(in.Description) > snippetDescMaxBytes { + return badInput("描述不能超过 8KB") + } + in.Visibility = model.NormalizeSnippetVisibility(in.Visibility) + return nil +} + +// SnippetOwner 片段所有者摘要 +type SnippetOwner struct { + ID uint `json:"id"` + Username string `json:"username"` + Nickname string `json:"nickname"` + Avatar string `json:"avatar"` +} + +// SnippetForkedFrom 源片段摘要 +type SnippetForkedFrom struct { + ID uint `json:"id"` + Title string `json:"title"` +} + +// SnippetDetail 片段详情(含所有者与源片段摘要) +type SnippetDetail struct { + model.CodeSnippet + User *SnippetOwner `json:"user,omitempty"` + ForkedFrom *SnippetForkedFrom `json:"forked_from,omitempty"` +} + +// SnippetListItem 广场/我的列表项(不含 code 全文,仅前 300 字符作预览) +type SnippetListItem struct { + ID uint `json:"id"` + Title string `json:"title"` + Language string `json:"language"` + Description string `json:"description"` + Preview string `json:"preview"` // code 前 300 字符 + Visibility string `json:"visibility"` + UserID uint `json:"user_id"` + CopyCount int `json:"copy_count"` + ViewCount int `json:"view_count"` + CommentCount int `json:"comment_count"` + ForkCount int `json:"fork_count"` + ForkedFromID *uint `json:"forked_from_id,omitempty"` + User *SnippetOwner `json:"user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SnippetCommentNode 片段评论节点(主评带 replies,软删 tombstone 占位) +type SnippetCommentNode struct { + model.SnippetComment + Floor int `json:"floor"` // 主评楼层号(含软删) + Deleted bool `json:"deleted"` // 软删标记 + Edited bool `json:"edited"` // 相对创建已编辑 + User *SnippetOwner `json:"user,omitempty"` + Replies []SnippetCommentNode `json:"replies,omitempty"` +} + +// SnippetService 代码片段服务 +type SnippetService struct { + db *gorm.DB + copyMu sync.Mutex + copyRecords map[string]time.Time // key=userID 或 IP,10s 内只计一次 + statsMu sync.Mutex + statsCache *SnippetStats + statsAt time.Time +} + +func NewSnippetService(db *gorm.DB) *SnippetService { + return &SnippetService{db: db, copyRecords: make(map[string]time.Time)} +} + +// escapeILike 转义 ILIKE 特殊字符 % 和 _ +func escapeILike(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `%`, `\%`) + s = strings.ReplaceAll(s, `_`, `\_`) + return s +} + +// SnippetLangStat 语言分布项 +type SnippetLangStat struct { + Language string `json:"language"` + Count int64 `json:"count"` +} + +// SnippetStats 片段广场聚合统计(仅公开片段) +type SnippetStats struct { + TotalSnippets int64 `json:"total_snippets"` + TotalCopies int64 `json:"total_copies"` + TotalViews int64 `json:"total_views"` + TotalComments int64 `json:"total_comments"` + TotalForks int64 `json:"total_forks"` + Contributors int64 `json:"contributors"` + TopLanguages []SnippetLangStat `json:"top_languages"` +} + +const snippetStatsTTL = 30 * time.Second + +// Stats 广场聚合统计(30s 内存缓存) +func (s *SnippetService) Stats() (*SnippetStats, error) { + s.statsMu.Lock() + if s.statsCache != nil && time.Since(s.statsAt) < snippetStatsTTL { + cached := s.statsCache + s.statsMu.Unlock() + return cached, nil + } + s.statsMu.Unlock() + + pub := func() *gorm.DB { + return s.db.Model(&model.CodeSnippet{}).Where("visibility = ?", model.SnippetVisibilityPublic) + } + + var agg struct { + Total int64 + Copies int64 + Views int64 + Comments int64 + Forks int64 + } + if err := pub(). + Select("COUNT(*) AS total, COALESCE(SUM(copy_count),0) AS copies, COALESCE(SUM(view_count),0) AS views, COALESCE(SUM(comment_count),0) AS comments, COALESCE(SUM(fork_count),0) AS forks"). + Scan(&agg).Error; err != nil { + return nil, err + } + + var contributors int64 + if err := pub().Distinct("user_id").Count(&contributors).Error; err != nil { + return nil, err + } + + langs := make([]SnippetLangStat, 0, 6) + if err := pub(). + Select("language, COUNT(*) AS count"). + Group("language"). + Order("count DESC, language ASC"). + Limit(6). + Scan(&langs).Error; err != nil { + return nil, err + } + + st := &SnippetStats{ + TotalSnippets: agg.Total, + TotalCopies: agg.Copies, + TotalViews: agg.Views, + TotalComments: agg.Comments, + TotalForks: agg.Forks, + Contributors: contributors, + TopLanguages: langs, + } + + s.statsMu.Lock() + s.statsCache = st + s.statsAt = time.Now() + s.statsMu.Unlock() + return st, nil +} + +// toListItem 模型转列表项(截断 code 生成 preview) +func toSnippetListItem(s model.CodeSnippet, owner *SnippetOwner) SnippetListItem { + preview := s.Code + if len(preview) > 300 { + preview = preview[:300] + } + return SnippetListItem{ + ID: s.ID, + Title: s.Title, + Language: s.Language, + Description: s.Description, + Preview: preview, + Visibility: s.Visibility, + UserID: s.UserID, + CopyCount: s.CopyCount, + ViewCount: s.ViewCount, + CommentCount: s.CommentCount, + ForkCount: s.ForkCount, + ForkedFromID: s.ForkedFromID, + User: owner, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } +} + +// ownerMap 批量取所有者摘要(避免 N+1) +func (s *SnippetService) ownerMap(ids []uint) map[uint]*SnippetOwner { + if len(ids) == 0 { + return map[uint]*SnippetOwner{} + } + uniq := make([]uint, 0, len(ids)) + seen := map[uint]bool{} + for _, id := range ids { + if !seen[id] { + seen[id] = true + uniq = append(uniq, id) + } + } + var users []model.User + if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", uniq).Find(&users).Error; err != nil { + return map[uint]*SnippetOwner{} + } + m := make(map[uint]*SnippetOwner, len(users)) + for i := range users { + u := &users[i] + m[u.ID] = &SnippetOwner{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar} + } + return m +} + +// Create 创建代码片段 +func (s *SnippetService) Create(in *SnippetInput, userID uint) (*model.CodeSnippet, error) { + if err := in.normalize(); err != nil { + return nil, err + } + snippet := &model.CodeSnippet{ + Title: in.Title, + Language: in.Language, + Description: in.Description, + Code: in.Code, + Visibility: in.Visibility, + UserID: userID, + } + if err := s.db.Create(snippet).Error; err != nil { + return nil, err + } + return snippet, nil +} + +// Update 更新代码片段(仅 owner 或 staff;编辑前落 SnippetVersion) +func (s *SnippetService) Update(id uint, in *SnippetInput, editorID uint, isStaff bool) (*model.CodeSnippet, error) { + var snippet model.CodeSnippet + if err := s.db.First(&snippet, id).Error; err != nil { + return nil, ErrSnippetNotFound + } + if snippet.UserID != editorID && !isStaff { + return nil, ErrSnippetForbidden + } + if err := in.normalize(); err != nil { + return nil, err + } + + // 判断是否有实际字段变化 + changed := snippet.Title != in.Title || + snippet.Language != in.Language || + snippet.Description != in.Description || + snippet.Code != in.Code || + snippet.Visibility != in.Visibility + + if changed { + ver := model.SnippetVersion{ + SnippetID: snippet.ID, + EditorID: editorID, + OldTitle: snippet.Title, + OldLanguage: snippet.Language, + OldDescription: snippet.Description, + OldCode: snippet.Code, + } + if err := s.db.Create(&ver).Error; err != nil { + return nil, err + } + } + + snippet.Title = in.Title + snippet.Language = in.Language + snippet.Description = in.Description + snippet.Code = in.Code + oldVisibility := snippet.Visibility + snippet.Visibility = in.Visibility + + err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&snippet).Updates(map[string]interface{}{ + "title": snippet.Title, + "language": snippet.Language, + "description": snippet.Description, + "code": snippet.Code, + "visibility": snippet.Visibility, + }).Error; err != nil { + return err + } + // fork 副本在私有/公开之间切换时,同步源片段的公开 fork 计数 + if snippet.ForkedFromID != nil && oldVisibility != snippet.Visibility { + expr := "GREATEST(fork_count - 1, 0)" + if snippet.Visibility == model.SnippetVisibilityPublic { + expr = "fork_count + 1" + } + if err := tx.Model(&model.CodeSnippet{}).Where("id = ?", *snippet.ForkedFromID). + UpdateColumn("fork_count", gorm.Expr(expr)).Error; err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, err + } + return &snippet, nil +} + +// GetPublic 获取片段详情(public 或 owner/staff 可见) +func (s *SnippetService) GetPublic(id uint, viewerID uint, isStaff bool) (*SnippetDetail, error) { + var snippet model.CodeSnippet + if err := s.db.First(&snippet, id).Error; err != nil { + return nil, ErrSnippetNotFound + } + if snippet.Visibility != model.SnippetVisibilityPublic && snippet.UserID != viewerID && !isStaff { + return nil, ErrSnippetNotFound + } + // ViewCount++(失败不影响主流程) + s.db.Model(&snippet).UpdateColumn("view_count", gorm.Expr("view_count + 1")) + + ownerMap := s.ownerMap([]uint{snippet.UserID}) + detail := &SnippetDetail{ + CodeSnippet: snippet, + User: ownerMap[snippet.UserID], + } + if snippet.ForkedFromID != nil { + var src model.CodeSnippet + if err := s.db.Select("id, title").First(&src, *snippet.ForkedFromID).Error; err == nil { + detail.ForkedFrom = &SnippetForkedFrom{ID: src.ID, Title: src.Title} + } + } + return detail, nil +} + +// ListPublic 公开广场列表(仅 public 未删) +func (s *SnippetService) ListPublic(q, lang, sort string, page, size int) ([]SnippetListItem, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 12 + } + db := s.db.Model(&model.CodeSnippet{}).Where("visibility = ?", model.SnippetVisibilityPublic) + + if q = strings.TrimSpace(q); q != "" { + like := "%" + escapeILike(q) + "%" + db = db.Where("title ILIKE ? OR description ILIKE ? OR code ILIKE ?", like, like, like) + } + if lang != "" { + if _, ok := snippetLanguages[lang]; ok { + db = db.Where("language = ?", lang) + } + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + switch sort { + case "copy": + db = db.Order("copy_count DESC, id DESC") + case "comment": + db = db.Order("comment_count DESC, id DESC") + case "fork": + db = db.Order("fork_count DESC, id DESC") + default: + db = db.Order("created_at DESC, id DESC") + } + + var snippets []model.CodeSnippet + if err := db.Offset((page - 1) * size).Limit(size).Find(&snippets).Error; err != nil { + return nil, 0, err + } + ownerIDs := make([]uint, 0, len(snippets)) + for _, sn := range snippets { + ownerIDs = append(ownerIDs, sn.UserID) + } + owners := s.ownerMap(ownerIDs) + items := make([]SnippetListItem, 0, len(snippets)) + for _, sn := range snippets { + items = append(items, toSnippetListItem(sn, owners[sn.UserID])) + } + return items, total, nil +} + +// ListMine 我的片段列表(含私有) +func (s *SnippetService) ListMine(userID uint, q string, page, size int) ([]SnippetListItem, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 12 + } + db := s.db.Model(&model.CodeSnippet{}).Where("user_id = ?", userID) + if q = strings.TrimSpace(q); q != "" { + like := "%" + escapeILike(q) + "%" + db = db.Where("title ILIKE ? OR description ILIKE ? OR code ILIKE ?", like, like, like) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var snippets []model.CodeSnippet + if err := db.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&snippets).Error; err != nil { + return nil, 0, err + } + ownerIDs := make([]uint, 0, len(snippets)) + for _, sn := range snippets { + ownerIDs = append(ownerIDs, sn.UserID) + } + owners := s.ownerMap(ownerIDs) + items := make([]SnippetListItem, 0, len(snippets)) + for _, sn := range snippets { + items = append(items, toSnippetListItem(sn, owners[sn.UserID])) + } + return items, total, nil +} + +// Fork 复制公开片段为自己的私有片段 +func (s *SnippetService) Fork(id uint, userID uint) (*model.CodeSnippet, error) { + var src model.CodeSnippet + if err := s.db.First(&src, id).Error; err != nil { + return nil, ErrSnippetNotFound + } + if src.Visibility != model.SnippetVisibilityPublic { + return nil, ErrSnippetForbidden + } + + fork := &model.CodeSnippet{ + Title: src.Title, + Language: src.Language, + Description: src.Description, + Code: src.Code, + Visibility: model.SnippetVisibilityPrivate, + UserID: userID, + ForkedFromID: &src.ID, + } + // 源标题本身已受限长约束,无需再次截断 + + // 副本初始为私有,不计入源片段的公开 fork 数; + // 作者将其改为公开时(见 Update)再计数。 + if err := s.db.Create(fork).Error; err != nil { + return nil, err + } + return fork, nil +} + +// RecordCopy 复制计数(按 key 节流 10s) +func (s *SnippetService) RecordCopy(id uint, key string) error { + s.copyMu.Lock() + now := time.Now() + if t, ok := s.copyRecords[key]; ok && now.Sub(t) < snippetCopyThrottle { + s.copyMu.Unlock() + return nil + } + s.copyRecords[key] = now + // 清理过期记录(简单 LRU,防止内存膨胀) + if len(s.copyRecords) > 10000 { + for k, v := range s.copyRecords { + if now.Sub(v) > time.Minute { + delete(s.copyRecords, k) + } + } + } + s.copyMu.Unlock() + + var snippet model.CodeSnippet + if err := s.db.First(&snippet, id).Error; err != nil { + return ErrSnippetNotFound + } + return s.db.Model(&snippet).UpdateColumn("copy_count", gorm.Expr("copy_count + 1")).Error +} + +// Delete 软删片段(owner 或 staff) +func (s *SnippetService) Delete(id uint, actorID uint, isStaff bool) error { + var snippet model.CodeSnippet + if err := s.db.First(&snippet, id).Error; err != nil { + return ErrSnippetNotFound + } + if snippet.UserID != actorID && !isStaff { + return ErrSnippetForbidden + } + return s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Delete(&snippet).Error; err != nil { + return err + } + // 删除公开的 fork 副本时,回溯源片段的公开 fork 计数; + // 私有副本从未计入,无需回溯。 + if snippet.ForkedFromID != nil && snippet.Visibility == model.SnippetVisibilityPublic { + if err := tx.Model(&model.CodeSnippet{}).Where("id = ?", *snippet.ForkedFromID). + UpdateColumn("fork_count", gorm.Expr("GREATEST(fork_count - 1, 0)")).Error; err != nil { + return err + } + } + return nil + }) +} + +// ListHistory 版本历史(倒序分页) +func (s *SnippetService) ListHistory(snippetID uint, page, size int) ([]model.SnippetVersion, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 10 + } + var total int64 + if err := s.db.Model(&model.SnippetVersion{}).Where("snippet_id = ?", snippetID).Count(&total).Error; err != nil { + return nil, 0, err + } + var versions []model.SnippetVersion + if err := s.db.Where("snippet_id = ?", snippetID). + Order("created_at DESC, id DESC"). + Offset((page - 1) * size).Limit(size). + Find(&versions).Error; err != nil { + return nil, 0, err + } + return versions, total, nil +} + +// ListForks 公开 fork 列表(一层) +func (s *SnippetService) ListForks(id uint, page, size int) ([]SnippetListItem, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 12 + } + db := s.db.Model(&model.CodeSnippet{}). + Where("forked_from_id = ? AND visibility = ?", id, model.SnippetVisibilityPublic) + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var snippets []model.CodeSnippet + if err := db.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&snippets).Error; err != nil { + return nil, 0, err + } + ownerIDs := make([]uint, 0, len(snippets)) + for _, sn := range snippets { + ownerIDs = append(ownerIDs, sn.UserID) + } + owners := s.ownerMap(ownerIDs) + items := make([]SnippetListItem, 0, len(snippets)) + for _, sn := range snippets { + items = append(items, toSnippetListItem(sn, owners[sn.UserID])) + } + return items, total, nil +} + +// snippetFloorNumber 主评楼层号(含软删,按 created_at/id 升序) +func (s *SnippetService) snippetFloorNumber(snippetID, commentID uint) int { + var c model.SnippetComment + if err := s.db.Unscoped().Select("id", "snippet_id", "parent_id", "root_id", "created_at"). + Where("id = ? AND snippet_id = ?", commentID, snippetID).First(&c).Error; err != nil { + return 0 + } + floorID := c.ID + floorCreated := c.CreatedAt + if c.ParentID != nil { + rootID := c.ID + if c.RootID != nil { + rootID = *c.RootID + } + var root model.SnippetComment + if err := s.db.Unscoped().Select("id", "created_at"). + Where("id = ? AND snippet_id = ? AND parent_id IS NULL", rootID, snippetID).First(&root).Error; err != nil { + return 0 + } + floorID = root.ID + floorCreated = root.CreatedAt + } + var ahead int64 + s.db.Unscoped().Model(&model.SnippetComment{}). + Where("snippet_id = ? AND parent_id IS NULL AND (created_at < ? OR (created_at = ? AND id < ?))", + snippetID, floorCreated, floorCreated, floorID). + Count(&ahead) + return int(ahead) + 1 +} + +// toSnippetCommentNode 转评论节点(软删 tombstone 清空正文) +func toSnippetCommentNode(c model.SnippetComment, user *SnippetOwner, replies []SnippetCommentNode, floor int) SnippetCommentNode { + edited := c.UpdatedAt.Sub(c.CreatedAt) > time.Minute + if c.DeletedAt.Valid { + c.Content = "" + return SnippetCommentNode{ + SnippetComment: c, + Floor: floor, + Deleted: true, + Edited: edited, + User: user, + Replies: replies, + } + } + return SnippetCommentNode{ + SnippetComment: c, + Floor: floor, + Deleted: false, + Edited: edited, + User: user, + Replies: replies, + } +} + +// ListComments 片段评论列表(主评分页,含回复) +func (s *SnippetService) ListComments(snippetID uint, page, size int) ([]SnippetCommentNode, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + // tombstone 语义:软删评论保留占位壳(内容清空,前端显示"该评论已删除"), + // 其子回复仍挂在壳下;因此列表/计数均使用 Unscoped 包含软删行。 + base := s.db.Unscoped().Model(&model.SnippetComment{}).Where("snippet_id = ?", snippetID) + + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, 0, err + } + + var roots []model.SnippetComment + if err := s.db.Unscoped().Where("snippet_id = ? AND parent_id IS NULL", snippetID). + Order("created_at ASC, id ASC"). + Offset((page - 1) * size).Limit(size). + Find(&roots).Error; err != nil { + return nil, 0, err + } + if len(roots) == 0 { + return []SnippetCommentNode{}, total, nil + } + + rootIDs := make([]uint, len(roots)) + for i, r := range roots { + rootIDs[i] = r.ID + } + var replies []model.SnippetComment + if err := s.db.Unscoped().Where("snippet_id = ? AND root_id IN ?", snippetID, rootIDs). + Order("created_at ASC, id ASC").Find(&replies).Error; err != nil { + return nil, 0, err + } + + children := make(map[uint][]model.SnippetComment, len(replies)) + for _, r := range replies { + if r.ParentID != nil { + children[*r.ParentID] = append(children[*r.ParentID], r) + } + } + + allRows := make([]model.SnippetComment, 0, len(roots)+len(replies)) + allRows = append(allRows, roots...) + allRows = append(allRows, replies...) + userIDs := make([]uint, 0, len(allRows)) + for _, c := range allRows { + userIDs = append(userIDs, c.UserID) + } + users := s.ownerMap(userIDs) + + var build func(pid uint) []SnippetCommentNode + build = func(pid uint) []SnippetCommentNode { + out := []SnippetCommentNode{} + for _, c := range children[pid] { + out = append(out, toSnippetCommentNode(c, users[c.UserID], build(c.ID), 0)) + } + return out + } + + result := make([]SnippetCommentNode, 0, len(roots)) + for _, r := range roots { + floor := s.snippetFloorNumber(snippetID, r.ID) + result = append(result, toSnippetCommentNode(r, users[r.UserID], build(r.ID), floor)) + } + return result, total, nil +} + +// CreateComment 创建片段评论 +func (s *SnippetService) CreateComment(userID, snippetID uint, content string, parentID *uint) (*model.SnippetComment, error) { + content = strings.TrimSpace(content) + if content == "" { + return nil, badInput("评论内容不能为空") + } + if utf8.RuneCountInString(content) > snippetCommentMaxLen { + return nil, badInput("评论不能超过 2000 字") + } + + var snippet model.CodeSnippet + if err := s.db.First(&snippet, snippetID).Error; err != nil { + return nil, ErrSnippetNotFound + } + if snippet.Visibility != model.SnippetVisibilityPublic { + return nil, ErrSnippetNotFound + } + + comment := &model.SnippetComment{ + SnippetID: snippetID, + UserID: userID, + Content: content, + } + + if parentID != nil { + var parent model.SnippetComment + if err := s.db.Where("id = ? AND snippet_id = ? AND deleted_at IS NULL", *parentID, snippetID).First(&parent).Error; err != nil { + return nil, badInput("回复的评论不存在") + } + if parent.Depth >= maxReplyDepth { + return nil, badInput("回复层级过深") + } + rootID := parent.ID + if parent.RootID != nil { + rootID = *parent.RootID + } + comment.ParentID = parentID + comment.RootID = &rootID + comment.Depth = parent.Depth + 1 + } + + err := s.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(comment).Error; err != nil { + return err + } + return tx.Model(&snippet).UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error + }) + if err != nil { + return nil, err + } + return comment, nil +} + +// UpdateComment 更新评论(仅本人) +func (s *SnippetService) UpdateComment(commentID, userID uint, content string) (*model.SnippetComment, error) { + content = strings.TrimSpace(content) + if content == "" { + return nil, badInput("评论内容不能为空") + } + if utf8.RuneCountInString(content) > snippetCommentMaxLen { + return nil, badInput("评论不能超过 2000 字") + } + var comment model.SnippetComment + if err := s.db.First(&comment, commentID).Error; err != nil { + return nil, ErrCommentNotFound + } + if comment.UserID != userID { + return nil, ErrCommentForbidden + } + if comment.Content == content { + return nil, ErrCommentNotChanged + } + old := comment.Content + if err := s.db.Transaction(func(tx *gorm.DB) error { + hist := model.SnippetCommentEditHistory{ + CommentID: comment.ID, + EditorID: userID, + OldContent: old, + } + if err := tx.Create(&hist).Error; err != nil { + return err + } + return tx.Model(&comment).Update("content", content).Error + }); err != nil { + return nil, err + } + if err := s.db.First(&comment, comment.ID).Error; err != nil { + return nil, err + } + return &comment, nil +} + +// DeleteComment 软删评论(本人或 staff;tombstone:DeleteType/DeletedBy) +// 保留占位壳与楼层计数:评论数不减,列表中显示"该评论已删除",子回复仍挂其下。 +func (s *SnippetService) DeleteComment(commentID, userID uint, isStaff bool) error { + var comment model.SnippetComment + if err := s.db.First(&comment, commentID).Error; err != nil { + return ErrCommentNotFound + } + if comment.UserID != userID && !isStaff { + return ErrCommentForbidden + } + deleteType := "self" + if comment.UserID != userID { + deleteType = "admin" + } + if err := s.db.Model(&comment).Updates(map[string]interface{}{ + "delete_type": deleteType, + "deleted_by": userID, + }).Error; err != nil { + return err + } + return s.db.Delete(&comment).Error +} + +// SnippetCommentHistoryItem 片段评论历史项 +type SnippetCommentHistoryItem struct { + ID uint `json:"id"` + Editor *SnippetOwner `json:"editor"` + OldContent string `json:"old_content"` + CreatedAt time.Time `json:"created_at"` +} + +// ListCommentHistory 片段评论编辑历史(登录即可见;含软删评论;倒序分页) +func (s *SnippetService) ListCommentHistory(actor *Actor, commentID uint, page, size int) ([]SnippetCommentHistoryItem, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 10 + } + var comment model.SnippetComment + if err := s.db.Unscoped().Select("id").First(&comment, commentID).Error; err != nil { + return nil, 0, ErrCommentNotFound + } + if actor == nil { + return nil, 0, ErrCommentForbidden + } + + q := s.db.Model(&model.SnippetCommentEditHistory{}).Where("comment_id = ?", commentID) + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + var rows []model.SnippetCommentEditHistory + if err := s.db.Where("comment_id = ?", commentID). + Order("created_at DESC"). + Offset((page - 1) * size).Limit(size). + Find(&rows).Error; err != nil { + return nil, 0, err + } + + editorIDs := make([]uint, 0, len(rows)) + seen := map[uint]struct{}{} + for _, r := range rows { + if _, ok := seen[r.EditorID]; ok { + continue + } + seen[r.EditorID] = struct{}{} + editorIDs = append(editorIDs, r.EditorID) + } + editors := s.ownerMap(editorIDs) + + items := make([]SnippetCommentHistoryItem, 0, len(rows)) + for _, r := range rows { + ed := editors[r.EditorID] + if ed == nil { + ed = &SnippetOwner{ID: r.EditorID, Username: "已注销", Nickname: "已注销"} + } + items = append(items, SnippetCommentHistoryItem{ + ID: r.ID, + Editor: ed, + OldContent: r.OldContent, + CreatedAt: r.CreatedAt, + }) + } + return items, total, nil +} + +// AdminListSnippetItem 管理端片段列表项(含 code) +type AdminListSnippetItem struct { + model.CodeSnippet + User *SnippetOwner `json:"user,omitempty"` +} + +// AdminList 管理端全量列表(含 code) +func (s *SnippetService) AdminList(q, visibility string, page, size int) ([]AdminListSnippetItem, int64, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 50 { + size = 20 + } + db := s.db.Model(&model.CodeSnippet{}) + if q = strings.TrimSpace(q); q != "" { + like := "%" + escapeILike(q) + "%" + db = db.Where("title ILIKE ? OR description ILIKE ? OR code ILIKE ?", like, like, like) + } + if visibility != "" { + db = db.Where("visibility = ?", model.NormalizeSnippetVisibility(visibility)) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var snippets []model.CodeSnippet + if err := db.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&snippets).Error; err != nil { + return nil, 0, err + } + ownerIDs := make([]uint, 0, len(snippets)) + for _, sn := range snippets { + ownerIDs = append(ownerIDs, sn.UserID) + } + owners := s.ownerMap(ownerIDs) + items := make([]AdminListSnippetItem, 0, len(snippets)) + for _, sn := range snippets { + items = append(items, AdminListSnippetItem{CodeSnippet: sn, User: owners[sn.UserID]}) + } + return items, total, nil +} + +// AdminSetVisibility 管理端强制修改可见性 +func (s *SnippetService) AdminSetVisibility(id uint, visibility string) error { + visibility = model.NormalizeSnippetVisibility(visibility) + result := s.db.Model(&model.CodeSnippet{}).Where("id = ?", id).Update("visibility", visibility) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrSnippetNotFound + } + return nil +} + +// AdminDelete 管理端软删(DeleteType=admin) +func (s *SnippetService) AdminDelete(id uint, adminID uint) error { + var snippet model.CodeSnippet + if err := s.db.First(&snippet, id).Error; err != nil { + return ErrSnippetNotFound + } + return s.db.Delete(&snippet).Error +} diff --git a/docs/images/wechat.png b/docs/images/wechat.png new file mode 100644 index 0000000..91f605b Binary files /dev/null and b/docs/images/wechat.png differ diff --git a/docs/images/zfb.png b/docs/images/zfb.png new file mode 100644 index 0000000..2a31bd4 Binary files /dev/null and b/docs/images/zfb.png differ diff --git a/docs/images/书库.png b/docs/images/书库.png new file mode 100644 index 0000000..fdb74f8 Binary files /dev/null and b/docs/images/书库.png differ diff --git a/docs/images/代码片段.png b/docs/images/代码片段.png new file mode 100644 index 0000000..fc0a5f3 Binary files /dev/null and b/docs/images/代码片段.png differ diff --git a/docs/images/帖子详情.png b/docs/images/帖子详情.png new file mode 100644 index 0000000..2b9f1a6 Binary files /dev/null and b/docs/images/帖子详情.png differ diff --git a/docs/images/管理后台.png b/docs/images/管理后台.png new file mode 100644 index 0000000..bf1cc1a Binary files /dev/null and b/docs/images/管理后台.png differ diff --git a/docs/images/访问统计.png b/docs/images/访问统计.png new file mode 100644 index 0000000..3a1bb15 Binary files /dev/null and b/docs/images/访问统计.png differ diff --git a/docs/images/首页.png b/docs/images/首页.png new file mode 100644 index 0000000..158b681 Binary files /dev/null and b/docs/images/首页.png differ diff --git a/frontend/app/admin/settings/HeaderNavPanel.tsx b/frontend/app/admin/settings/HeaderNavPanel.tsx index 9ce8107..da7c41d 100644 --- a/frontend/app/admin/settings/HeaderNavPanel.tsx +++ b/frontend/app/admin/settings/HeaderNavPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowDown, ArrowUp, BookOpen, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react"; +import { ArrowDown, ArrowUp, BookOpen, CodeXml, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react"; import { useEffect, useState } from "react"; import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin"; import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api"; @@ -19,6 +19,7 @@ const BUILTIN_ICONS: Record = { home: , leaderboard: , library: , + snippets: , }; /** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */ @@ -26,6 +27,7 @@ const BUILTIN_HINTS: Record = { home: "站点首页 /", leaderboard: "排行榜页(地址随伪静态风格变化)", library: "书库页(地址随伪静态风格变化)", + snippets: "片段广场页(地址随伪静态风格变化)", }; type BuiltinSwitches = Record; @@ -42,6 +44,7 @@ function switchesOf(items: HeaderNavItem[]): BuiltinSwitches { home: present.has("home"), leaderboard: present.has("leaderboard"), library: present.has("library"), + snippets: present.has("snippets"), }; } diff --git a/frontend/app/admin/snippets/SnippetsAdmin.tsx b/frontend/app/admin/snippets/SnippetsAdmin.tsx new file mode 100644 index 0000000..968770f --- /dev/null +++ b/frontend/app/admin/snippets/SnippetsAdmin.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { CodeXml, Copy, Eye, EyeOff, GitFork, Loader2, MessagesSquare, Trash2 } from "lucide-react"; +import { + AdminEmpty, + AdminPageHeader, + AdminPager, + AdminSearchField, + AdminSegmented, +} from "@/components/admin"; +import ConfirmDialog from "@/components/ConfirmDialog"; +import Avatar from "@/components/Avatar"; +import { + apiAdminDeleteSnippet, + apiAdminSetSnippetVisibility, + apiAdminSnippets, + type SnippetListItem, +} from "@/lib/api"; +import { formatRelative } from "@/lib/format"; +import { toast } from "@/lib/toast"; +import { snippetPath } from "@/lib/urlStyle"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; + +type VisibilityFilter = "all" | "public" | "private"; + +const PAGE_SIZE = 20; + +/** 片段管理:全量列表(含私有),支持搜索、可见性筛选、下架/上架与删除。 */ +export default function SnippetsAdmin() { + const urlStyle = useUrlStyle(); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [vis, setVis] = useState("all"); + const [keyword, setKeyword] = useState(""); + const [q, setQ] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [confirm, setConfirm] = useState< + | { kind: "delete"; item: SnippetListItem } + | { kind: "hide" | "show"; item: SnippetListItem } + | null + >(null); + const [busy, setBusy] = useState(false); + + const load = useCallback(async (p: number, v: VisibilityFilter, kw: string) => { + setLoading(true); + setError(""); + try { + const res = await apiAdminSnippets({ + q: kw || undefined, + visibility: v === "all" ? undefined : v, + page: p, + size: PAGE_SIZE, + }); + setItems(res.items); + setTotal(res.total); + setPage(p); + } catch (e) { + setError(e instanceof Error ? e.message : "加载失败"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(1, "all", ""); + }, [load]); + + const runConfirm = async () => { + if (!confirm) return; + setBusy(true); + try { + if (confirm.kind === "delete") { + await apiAdminDeleteSnippet(confirm.item.id); + toast("片段已删除", "ok"); + } else { + const next = confirm.kind === "hide" ? "private" : "public"; + await apiAdminSetSnippetVisibility(confirm.item.id, next); + toast(next === "private" ? "已下架为私有" : "已恢复为公开", "ok"); + } + setConfirm(null); + await load(page, vis, q); + } catch (e) { + toast(e instanceof Error ? e.message : "操作失败"); + } finally { + setBusy(false); + } + }; + + return ( +
+ + +
+
+ + ariaLabel="可见性筛选" + size="sm" + value={vis} + onChange={(v) => void load(1, v, q)} + options={[ + { key: "all", label: "全部" }, + { key: "public", label: "公开" }, + { key: "private", label: "私有" }, + ]} + /> + { + setQ(keyword.trim()); + void load(1, vis, keyword.trim()); + }} + onClear={() => { + setKeyword(""); + setQ(""); + void load(1, vis, ""); + }} + placeholder="搜索标题或描述…" + ariaLabel="搜索片段" + /> +
+ + {error ?

{error}

: null} + + {loading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( + } + title="暂无片段" + description={q ? "没有匹配搜索条件的片段。" : "还没有用户发布代码片段。"} + /> + ) : ( +
    + {items.map((it) => ( +
  • +
    +
    + + {it.title} + + + {it.language} + + {it.visibility === "private" ? ( + + 私有 + + ) : ( + + 公开 + + )} +
    +
    + {it.user ? ( + + + {it.user.nickname} + + ) : ( + 用户 #{it.user_id} + )} + + {it.copy_count} + + + {it.view_count} + + + {it.comment_count} + + + {it.fork_count} + + {formatRelative(it.created_at)} +
    +
    + +
    + {it.visibility === "public" ? ( + + ) : ( + + )} + +
    +
  • + ))} +
+ )} + + void load(p, vis, q)} + /> +
+ + !busy && setConfirm(null)} + onConfirm={() => void runConfirm()} + /> +
+ ); +} diff --git a/frontend/app/admin/snippets/page.tsx b/frontend/app/admin/snippets/page.tsx new file mode 100644 index 0000000..95f1368 --- /dev/null +++ b/frontend/app/admin/snippets/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { authCookieHeader } from "@/lib/cookies"; +import { getMeCached } from "@/lib/serverData"; +import { isStaff } from "@/lib/roles"; +import Forbidden from "../Forbidden"; +import SnippetsAdmin from "./SnippetsAdmin"; + +export const metadata: Metadata = { + title: "片段管理", +}; + +export default async function AdminSnippetsPage() { + const cookie = authCookieHeader(await cookies()); + const me = await getMeCached(cookie || undefined); + if (!me.user || !isStaff(me.user.role)) { + return ; + } + return ; +} diff --git a/frontend/app/chat/[id]/ChatRoomClient.tsx b/frontend/app/chat/[id]/ChatRoomClient.tsx index 04ab357..44eb6ba 100644 --- a/frontend/app/chat/[id]/ChatRoomClient.tsx +++ b/frontend/app/chat/[id]/ChatRoomClient.tsx @@ -874,6 +874,10 @@ export default function ChatRoomClient({ router.refresh(); return; } + if (data.status === "joined") { + void refreshMembers(); + return; + } if (data.status === "kicked") { toast("你已被移出该群"); } else { @@ -883,7 +887,7 @@ export default function ChatRoomClient({ } ); return off; - }, [roomId, router]); + }, [roomId, router, refreshMembers]); // 禁言/解禁实时通知:更新成员列表 + 插入系统提示 useEffect(() => { diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a26cdc4..2d267e0 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -16,6 +16,7 @@ --ink: #161a22; --ink-2: #4b5260; --ink-3: #5b6270; + --ink-4: #949aab; /* 比 --ink-3 更浅:首页帖子列表 meta 文字(作者/时间/统计) */ --line: #e4e7ec; /* 装饰性分隔/卡片边(非交互,配合阴影与底色高差) */ --line-2: #7c8698; /* 功能性边界:输入框/按钮/分页等可交互控件,满足 1.4.11 ≥3:1 */ @@ -139,6 +140,7 @@ --ink: #eef1f6; --ink-2: #b6bdc9; --ink-3: #98a1b2; + --ink-4: #71798a; /* 比 --ink-3 更淡:首页帖子列表 meta 文字(作者/时间/统计) */ --line: rgba(238, 241, 246, 0.08); /* 装饰性分隔/卡片边 */ --line-2: rgba(238, 241, 246, 0.38); /* 功能性控件边界,1.4.11 ≥3:1 */ @@ -4006,6 +4008,35 @@ a.j13-mention-token:hover { color: var(--accent); } + /* 验证码发送按钮:与输入框同圆角,主色浅底强调可点;hover 实心浮起 */ + .btn-code { + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); + color: var(--accent); + font-weight: 600; + border-radius: var(--r-sm); + padding: 10px 16px; + } + .btn-code:hover:not(:disabled) { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-on); + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(16, 24, 40, 0.1), + 0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent); + } + .btn-code:active:not(:disabled) { + transform: translateY(0); + box-shadow: none; + } + .btn-code:disabled { + background: transparent; + border-color: var(--line-2); + color: var(--ink-3); + font-weight: 500; + cursor: not-allowed; + } + .btn-danger { background: var(--danger-soft); color: var(--danger); @@ -6425,8 +6456,10 @@ a.j13-mention-token:hover { user-select: none; } - /* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */ - .j13-like { + /* ----- 点赞/收藏按钮:未激活描边态,hover 主色浅底轻浮起;激活态浅底主色描边 ----- */ + .j13-like, + .j13-fav { + border-radius: 999px; border-color: var(--line-2); color: var(--ink-2); background: var(--panel); @@ -6434,41 +6467,82 @@ a.j13-mention-token:hover { border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease, - box-shadow 0.15s ease; + box-shadow 0.15s ease, + transform 0.15s ease; } - .j13-like svg { + .j13-like svg, + .j13-fav svg { transition: transform 0.15s ease; } .j13-like:hover:not(:disabled), - .j13-like.is-liked { + .j13-fav:hover:not(:disabled), + .j13-like.is-liked, + .j13-fav.is-favorited { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); } - /* 已赞态 hover 再加深一档,并给同色系投影;心形轻微放大(不改变布局) */ - .j13-like:hover:not(:disabled) { + /* hover 浮起 + 同色系投影;心形/书签轻微放大(不改变布局) */ + .j13-like:hover:not(:disabled), + .j13-fav:hover:not(:disabled) { + transform: translateY(-1px); box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 22%, transparent); } - .j13-like.is-liked:hover:not(:disabled) { - background: color-mix(in srgb, var(--accent) 17%, transparent); + .j13-like:active:not(:disabled), + .j13-fav:active:not(:disabled) { + transform: translateY(0); + box-shadow: none; } - .j13-like:hover:not(:disabled) svg { + /* 文章页大按钮:12px 圆角矩形 + 幽灵底,弱化存在感;激活态浅底主色描边(文字恒为主色,保证可读) */ + .j13-like-article { + border-radius: var(--r-sm); + border-color: transparent; + background: color-mix(in srgb, var(--ink) 5%, transparent); + } + .j13-like-article:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--accent) 25%, transparent); + } + .j13-like-article.is-liked, + .j13-like-article.is-favorited { + color: var(--accent); + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); + box-shadow: none; + } + .j13-like-article.is-liked:hover:not(:disabled), + .j13-like-article.is-favorited:hover:not(:disabled) { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 18%, transparent); + border-color: var(--accent); + } + .j13-like:hover:not(:disabled) svg, + .j13-fav:hover:not(:disabled) svg { transform: scale(1.15); } - .j13-like:active:not(:disabled) svg { + .j13-like:active:not(:disabled) svg, + .j13-fav:active:not(:disabled) svg { transform: scale(0.95); } - .j13-like:focus-visible { + .j13-like:focus-visible, + .j13-fav:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } @media (prefers-reduced-motion: reduce) { .j13-like, - .j13-like svg { + .j13-fav, + .j13-like svg, + .j13-fav svg { transition: none; } + .j13-like:hover:not(:disabled), + .j13-fav:hover:not(:disabled), + .j13-like:active:not(:disabled), + .j13-fav:active:not(:disabled), .j13-like:hover:not(:disabled) svg, - .j13-like:active:not(:disabled) svg { + .j13-fav:hover:not(:disabled) svg, + .j13-like:active:not(:disabled) svg, + .j13-fav:active:not(:disabled) svg { transform: none; } } @@ -7134,6 +7208,10 @@ a.j13-mention-token:hover { color: var(--ink-3); font-variant-numeric: tabular-nums; } + /* 帖子列表行:除标题外的 meta 文字(作者/时间/回复/评论数/浏览数/板块胶囊)调浅一档, + 让标题(var(--ink))更突出;hover 仍可被 above-link 的 accent 覆盖。 */ + .j13-post-row .meta { color: var(--ink-4); } + .j13-post-row .j13-board-tag { color: var(--ink-4); } .link-accent { color: var(--accent); } .link-accent:hover { color: var(--accent-hi); } @@ -9074,12 +9152,13 @@ html.dark .prose-content pre[data-theme] span { /* ============================================================ 前台 main 纸面 + 首页三栏「白纸一体」 - main 铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘 - 1px --line 描边与页头下边线围合成框;站长配置了自定义站点 - 背景图时让位(保持透出画布)。左栏无框化,内容直接坐在纸面上; - 右栏保留卡片板块感(j13-module 原样)。 + main 恒定铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘 + 1px --line 描边与页头下边线围合成框;即使站长配置了自定义站点 + 背景图也不让位——header/main/footer 连成一张白纸保证内容可读, + 背景图只在页缘(1440px 画布之外)露出。左栏无框化,内容直接坐 + 在纸面上;右栏保留卡片板块感(j13-module 原样)。 ============================================================ */ -html:not(.j13-has-site-bg) #main { +#main { background: var(--main-bg); border-left: 1px solid var(--line); border-right: 1px solid var(--line); @@ -10399,3 +10478,209 @@ html.j13-reduce-chrome .j13-att-card.is-fresh { box-shadow: var(--shadow-lg); } +/* ===== 片段广场:编辑器窗格卡片 ===== */ +/* 卡片内 shiki 双主题着色(独立作用域,不依赖 .prose-content)。 + 新版 shiki 的 token span 只声明 --shiki-light/--shiki-dark 变量,color 由这里应用 */ +.j13-sn-code { + color: var(--shiki-light, var(--ink-2)); +} +.j13-sn-code span[style*="--shiki-light"] { + color: var(--shiki-light); + background: transparent; +} +html.dark .j13-sn-code { + color: var(--shiki-dark, var(--ink-2)); +} +html.dark .j13-sn-code span[style*="--shiki-dark"] { + color: var(--shiki-dark) !important; + font-style: var(--shiki-dark-font-style) !important; + font-weight: var(--shiki-dark-font-weight) !important; +} +/* 语言下拉弹层 */ +.j13-sn-lang-menu { + animation: j13-sn-pop 140ms cubic-bezier(0.22, 1, 0.36, 1); + transform-origin: top left; +} +@keyframes j13-sn-pop { + from { opacity: 0; transform: translateY(-4px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +/* 搜索软聚焦:光晕替代硬边框 */ +.j13-sn-search { + transition: box-shadow 160ms ease, border-color 160ms ease, background 160ms ease; +} +.j13-sn-search:focus { + outline: none; + border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent); +} + +/* ===== 片段广场:赛博控制台 Hero(明暗双主题自适应) ===== */ +.j13-hero { + /* 浅色:白玻璃底 + 墨色文字,辉光收敛 */ + --hero-bg: #f7f9fc; + --hero-ink: #0f172a; + --hero-ink-2: #475569; + --hero-ink-3: #8593ab; + --hero-line: rgba(15, 23, 42, 0.10); + --hero-chip-bg: rgba(15, 23, 42, 0.035); + --hero-chip-icon: rgba(15, 23, 42, 0.06); + --hero-track: rgba(15, 23, 42, 0.07); + background: + radial-gradient(ellipse 60% 80% at 12% -20%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 65%), + radial-gradient(ellipse 45% 70% at 88% 115%, rgba(13, 148, 136, 0.08), transparent 70%), + radial-gradient(ellipse 35% 45% at 60% 40%, rgba(124, 58, 237, 0.06), transparent 70%), + var(--hero-bg); + border: 1px solid var(--line); + box-shadow: var(--shadow-lg); +} +html.dark .j13-hero { + /* 深色:深夜底 + 霓虹文字,辉光拉满 */ + --hero-bg: #0a0e16; + --hero-ink: #e8edf7; + --hero-ink-2: #9aa7c2; + --hero-ink-3: #5f6b88; + --hero-line: rgba(148, 163, 205, 0.16); + --hero-chip-bg: rgba(148, 163, 205, 0.07); + --hero-chip-icon: rgba(148, 163, 205, 0.11); + --hero-track: rgba(148, 163, 205, 0.10); + background: + radial-gradient(ellipse 60% 80% at 12% -20%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 65%), + radial-gradient(ellipse 45% 70% at 88% 115%, rgba(94, 234, 212, 0.14), transparent 70%), + radial-gradient(ellipse 35% 45% at 60% 40%, rgba(167, 139, 250, 0.10), transparent 70%), + var(--hero-bg); + border: 1px solid rgba(148, 163, 205, 0.22); + box-shadow: + 0 0 0 1px rgba(10, 14, 22, 0.6), + 0 18px 50px -18px color-mix(in srgb, var(--accent) 28%, transparent), + inset 0 1px 0 rgba(232, 237, 247, 0.06); +} +/* 网格底纹(铺满整个面板,仅底部极淡渐出避免压文字) */ +.j13-hero-grid { + background-image: + linear-gradient(rgba(15, 23, 42, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(15, 23, 42, 0.05) 1px, transparent 1px); + background-size: 30px 30px; + mask-image: linear-gradient(to bottom, black 82%, rgba(0, 0, 0, 0.35) 100%); + -webkit-mask-image: linear-gradient(to bottom, black 82%, rgba(0, 0, 0, 0.35) 100%); +} +html.dark .j13-hero-grid { + background-image: + linear-gradient(rgba(148, 163, 205, 0.09) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 205, 0.09) 1px, transparent 1px); +} +/* 水平扫描光束(自上而下巡航) */ +.j13-hero-scan { + position: absolute; + left: 0; + right: 0; + height: 72px; + top: -72px; + background: linear-gradient( + to bottom, + transparent, + color-mix(in srgb, var(--accent) 8%, transparent) 60%, + color-mix(in srgb, var(--accent) 30%, transparent) + ); + filter: blur(0.5px); + animation: j13-hero-scan 8s cubic-bezier(0.4, 0, 0.2, 1) infinite; + pointer-events: none; +} +@keyframes j13-hero-scan { + 0% { transform: translateY(0); opacity: 0; } + 8% { opacity: 1; } + 60% { opacity: 1; } + 68%, 100% { transform: translateY(140%); opacity: 0; } +} +/* 大标题霓虹渐变 */ +.j13-hero-title { + background: linear-gradient(100deg, var(--hero-ink) 20%, var(--accent) 55%, #5eead4 85%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + text-shadow: 0 0 34px color-mix(in srgb, var(--accent) 32%, transparent); +} +/* 统计数字进入 */ +.j13-hero-stat { + animation: j13-hero-rise 560ms cubic-bezier(0.22, 1, 0.36, 1) both; +} +@keyframes j13-hero-rise { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} +/* 语言均衡条:加载展开 */ +.j13-hero-langbar { + transform-origin: left center; + animation: j13-hero-bar 800ms cubic-bezier(0.22, 1, 0.36, 1) 260ms both; +} +@keyframes j13-hero-bar { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} +/* 呼吸状态灯 */ +.j13-hero-pulse { + animation: j13-hero-pulse 2.2s ease-in-out infinite; +} +@keyframes j13-hero-pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } + 50% { opacity: 0.6; box-shadow: 0 0 0 6px transparent; } +} +/* 光标闪烁 */ +.j13-hero-caret { + animation: j13-hero-caret 1.1s steps(2, start) infinite; +} +@keyframes j13-hero-caret { + to { visibility: hidden; } +} +/* 语言跑马灯 */ +.j13-hero-ticker { + overflow: hidden; + mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent); + -webkit-mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent); +} +.j13-hero-ticker-track { + display: inline-flex; + align-items: center; + gap: 2.25rem; + white-space: nowrap; + animation: j13-hero-ticker 36s linear infinite; +} +.j13-hero-ticker:hover .j13-hero-ticker-track { + animation-play-state: paused; +} +@keyframes j13-hero-ticker { + to { transform: translateX(-50%); } +} +@media (prefers-reduced-motion: reduce) { + .j13-hero-scan, .j13-hero-pulse, .j13-hero-caret, .j13-hero-ticker-track { animation: none; } + .j13-hero-stat, .j13-hero-langbar { animation-duration: 1ms; } +} + +/* ===== 片段卡片:语言霓虹 ===== */ +.j13-snc { + position: relative; +} +/* 顶部霓虹语言条 */ +.j13-snc::before { + content: ""; + position: absolute; + top: 0; + left: 14px; + right: 14px; + height: 2px; + border-radius: 2px; + background: linear-gradient(90deg, var(--sn-lang, var(--accent)), transparent 75%); + opacity: 0.75; + transition: opacity 200ms ease, left 200ms ease, right 200ms ease; +} +.j13-snc:hover::before { + opacity: 1; + left: 10px; + right: 10px; +} +.j13-snc:hover { + box-shadow: + 0 10px 34px -14px color-mix(in srgb, var(--sn-lang, var(--accent)) 45%, transparent), + var(--shadow-lg) !important; +} + diff --git a/frontend/app/register/RegisterClient.tsx b/frontend/app/register/RegisterClient.tsx index a6f4961..27c1dd1 100644 --- a/frontend/app/register/RegisterClient.tsx +++ b/frontend/app/register/RegisterClient.tsx @@ -190,7 +190,7 @@ export default function RegisterClient({ /> )} + {toolNarrow && ( )} @@ -1712,6 +1715,13 @@ export default function MarkdownEditor({ {popover === "video" && videoPopover} )} + {!isComment && !toolNarrow && ( + } + onClick={() => setSnippetPickerOpen(true)} + /> + )} {allowHide && !toolNarrow ? (
insertAtCursor(`![图片](${item.url})`)} /> + + setSnippetPickerOpen(false)} + onPick={(id) => insertAtCursor(`[snippet id=${id}]`)} + />
); } diff --git a/frontend/components/PostRow.tsx b/frontend/components/PostRow.tsx index b722ccf..2f14887 100644 --- a/frontend/components/PostRow.tsx +++ b/frontend/components/PostRow.tsx @@ -50,7 +50,7 @@ export default async function PostRow({ return (
{ + apiMe() + .then((res) => { + if (res.user) { + setIsOwner(res.user.id === snippet.user_id); + setIsStaff(["admin", "moderator"].includes(res.user.role)); + } + }) + .catch(() => {}); + }, [snippet.user_id]); + + const onFork = async () => { + if (forking) return; + setForking(true); + try { + const res = await apiForkSnippet(snippet.id); + toast("已复制为你的私有副本,随便改改吧", "ok"); + // 直接进入编辑页,让"Fork 后可以演化"这件事立刻可见 + router.push(`/snippets/${res.snippet.id}/edit`); + } catch (e) { + toast(e instanceof Error ? e.message : "Fork 失败"); + } finally { + setForking(false); + } + }; + + const onDelete = async () => { + if (deleting) return; + setDeleting(true); + try { + await apiDeleteSnippet(snippet.id); + toast("已删除", "ok"); + router.push(snippetsPath(urlStyle)); + } catch (e) { + toast(e instanceof Error ? e.message : "删除失败"); + } finally { + setDeleting(false); + setDeleteOpen(false); + } + }; + + const onCopyLink = async () => { + try { + const url = `${window.location.origin}${snippetPath(urlStyle, snippet.id)}`; + await navigator.clipboard.writeText(url); + setCopied(true); + toast("链接已复制", "ok"); + window.setTimeout(() => setCopied(false), 1600); + } catch { + toast("复制失败"); + } + }; + + const btnBase = + "h-8 px-3 inline-flex items-center gap-1.5 rounded-full text-[12.5px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"; + const btnNormal = { + background: "var(--panel)", + border: "1px solid var(--line)", + color: "var(--ink-2)", + } as const; + + return ( + <> +
+ + + {snippet.visibility === "public" && ( + + )} + + + + {isOwner && ( + + 编辑 + + )} + + {(isOwner || isStaff) && ( + + )} +
+ + setDeleteOpen(false)} + dismissOnOverlay + maxWidthClass="max-w-sm" + footer={ + <> + + + + } + > +

+ 此操作不可撤销。 +

+
+ + setHistoryOpen(false)} + snippetId={snippet.id} + currentCode={snippet.code} + currentTitle={snippet.title} + currentLanguage={snippet.language} + currentDescription={snippet.description} + /> + + ); +} diff --git a/frontend/components/SnippetCodeView.tsx b/frontend/components/SnippetCodeView.tsx new file mode 100644 index 0000000..5fe0ba4 --- /dev/null +++ b/frontend/components/SnippetCodeView.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useState, useEffect, useRef, useLayoutEffect } from "react"; +import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react"; +import { toast } from "@/lib/toast"; +import { apiRecordSnippetCopy } from "@/lib/api"; +import { siteCodeToHtml } from "@/components/markdown/shikiHighlighter"; + +const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)"; +const LINE_H = 22; // px,与下方 leading 严格对齐 +const PAD_Y = 14; + +/** 从 shiki 双主题输出提取 pre 上的 CSS 变量与 code 内逐行 HTML */ +function extractShiki(html: string): { vars: Record; lines: string[] } | null { + const codeM = /]*>([\s\S]*?)<\/code>/i.exec(html); + if (!codeM) return null; + const vars: Record = {}; + const preM = /]*\sstyle="([^"]*)"[^>]*>/i.exec(html); + if (preM) { + for (const m of preM[1].matchAll(/(--[\w-]+)\s*:\s*([^;]+)/g)) { + vars[m[1]] = m[2].trim(); + } + } + return { vars, lines: codeM[1].split("\n") }; +} + +function WindowDots() { + return ( + + + + + + ); +} + +/** 片段代码视图:编辑器窗格 + shiki 高亮 + 行号 + 复制 + 超长折叠 */ +export default function SnippetCodeView({ + code, + language, + snippetId, + defaultFold = true, + foldLines = 25, +}: { + code: string; + language: string; + snippetId: number; + defaultFold?: boolean; + foldLines?: number; +}) { + const [copied, setCopied] = useState(false); + const [expanded, setExpanded] = useState(!defaultFold); + const [hl, setHl] = useState<{ vars: Record; lines: string[] } | null>(null); + const [maxH, setMaxH] = useState(undefined); + const bodyRef = useRef(null); + + const plainLines = code.split("\n"); + const lineCount = plainLines.length; + const canCollapse = defaultFold && lineCount > foldLines; + const collapsedH = PAD_Y + foldLines * LINE_H; + const rows = hl?.lines ?? plainLines; + const highlighted = hl !== null; + + useEffect(() => { + let cancelled = false; + siteCodeToHtml(code, language) + .then((html) => { + if (cancelled) return; + const parsed = extractShiki(html); + if (parsed && parsed.lines.length > 0) { + setHl({ vars: parsed.vars, lines: parsed.lines.slice(0, lineCount) }); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [code, language, lineCount]); + + useLayoutEffect(() => { + if (!canCollapse || !bodyRef.current) return; + setMaxH(expanded ? bodyRef.current.scrollHeight : collapsedH); + }, [expanded, canCollapse, collapsedH, hl]); + + const onCopy = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + toast("已复制到剪贴板", "ok"); + apiRecordSnippetCopy(snippetId).catch(() => {}); + window.setTimeout(() => setCopied(false), 1600); + } catch { + toast("复制失败,请手动选择文本"); + } + }; + + const fileName = `snippet.${language === "plaintext" ? "txt" : language}`; + + return ( +
+ {/* 窗格标题栏 */} +
+ + + {fileName} + + + {lineCount} 行 + + + {language || "text"} + + +
+ + {/* 代码主体 */} +
+
+          {rows.map((ln, i) => (
+            
+ + {i + 1} + + {highlighted ? ( + + ) : ( + + {ln || " "} + + )} +
+ ))} +
+ {canCollapse && !expanded && ( +
+ )} +
+ + {canCollapse && ( + + )} +
+ ); +} diff --git a/frontend/components/SnippetCommentCountChip.tsx b/frontend/components/SnippetCommentCountChip.tsx new file mode 100644 index 0000000..e06a059 --- /dev/null +++ b/frontend/components/SnippetCommentCountChip.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ReactNode } from "react"; + +export function snippetCommentCountEvent(snippetId: number) { + return `j13:snippet:${snippetId}:comment-count`; +} + +/** + * 片段详情顶部的评论计数 chip: + * 初始值来自 SSR,评论区增删后通过 window CustomEvent 同步。 + */ +export default function SnippetCommentCountChip({ + snippetId, + initial, + icon, + label, +}: { + snippetId: number; + initial: number; + icon: ReactNode; + label: string; +}) { + const [count, setCount] = useState(initial); + + useEffect(() => { + const handler = (e: Event) => { + const next = (e as CustomEvent).detail; + if (typeof next === "number") setCount(next); + }; + const name = snippetCommentCountEvent(snippetId); + window.addEventListener(name, handler); + return () => window.removeEventListener(name, handler); + }, [snippetId]); + + return ( + + {icon} + {count} + {label} + + ); +} diff --git a/frontend/components/SnippetCommentSection.tsx b/frontend/components/SnippetCommentSection.tsx new file mode 100644 index 0000000..d406efb --- /dev/null +++ b/frontend/components/SnippetCommentSection.tsx @@ -0,0 +1,702 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { MessageSquare, Send } from "lucide-react"; +import { + apiCreateSnippetComment, + apiUpdateSnippetComment, + apiDeleteSnippetComment, + apiSnippetCommentHistory, + apiMe, + type SnippetCommentItem, +} from "@/lib/api"; +import { toast } from "@/lib/toast"; +import { formatRelative, formatDateTime } from "@/lib/format"; +import Avatar from "@/components/Avatar"; +import { useUrlStyle } from "@/components/SiteBrandProvider"; +import { userPath } from "@/lib/urlStyle"; +import Link from "next/link"; +import MarkdownEditor from "@/components/MarkdownEditor"; +import MarkdownBodyClient from "@/components/MarkdownBodyClient"; +import EditHistoryModal, { type EditHistoryItem } from "@/components/EditHistoryModal"; +import ConfirmDialog from "@/components/ConfirmDialog"; +import { snippetCommentCountEvent } from "@/components/SnippetCommentCountChip"; + +const COMMENT_MAX = 2000; // 与后端 snippetCommentMaxLen 一致 + +type MoreItem = { + key: string; + label: string; + danger?: boolean; + disabled?: boolean; + onSelect: () => void; +}; + +/** 低频操作:编辑、历史、删除。Escape 关闭并回到触发按钮。 */ +function CommentMoreMenu({ items }: { items: MoreItem[] }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const btnRef = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + btnRef.current?.focus(); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + if (items.length === 0) return null; + const normal = items.filter((item) => !item.danger); + const danger = items.filter((item) => item.danger); + + return ( +
+ + {open && ( +
+ {normal.map((item) => ( + + ))} + {normal.length > 0 && danger.length > 0 ?
: null} + {danger.map((item) => ( + + ))} +
+ )} +
+ ); +} + +function CommentNode({ + item, + depth, + snippetId, + onUpdate, + onDelete, + onOpenHistory, + loggedIn, +}: { + item: SnippetCommentItem; + depth: number; + snippetId: number; + onUpdate: (id: number, content: string) => Promise; + onDelete: (id: number) => Promise; + onOpenHistory: (id: number, content: string) => void; + loggedIn: boolean; +}) { + const urlStyle = useUrlStyle(); + const [editing, setEditing] = useState(false); + const [editContent, setEditContent] = useState(item.content); + const [editUploading, setEditUploading] = useState(false); + const [replyOpen, setReplyOpen] = useState(false); + const [replyContent, setReplyContent] = useState(""); + const [replyUploading, setReplyUploading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [currentUserId, setCurrentUserId] = useState(null); + const [isStaff, setIsStaff] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(false); + + useEffect(() => { + apiMe() + .then((res) => { + if (res.user) { + setCurrentUserId(res.user.id); + setIsStaff(["admin", "moderator"].includes(res.user.role)); + } + }) + .catch(() => {}); + }, []); + + const canEdit = currentUserId === item.user_id; + const canDelete = canEdit || isStaff; + const canViewHistory = loggedIn; + const userName = item.user ? item.user.nickname || item.user.username : "已注销"; + const isFloor = depth === 0; + + const handleEdit = async () => { + const next = editContent.trim(); + if (!next || submitting) return; + setSubmitting(true); + try { + await onUpdate(item.id, next); + setEditing(false); + toast("已保存", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "保存失败"); + } finally { + setSubmitting(false); + } + }; + + const handleDelete = async () => { + setDeleteTarget(false); + if (submitting) return; + setSubmitting(true); + try { + await onDelete(item.id); + toast("已删除", "ok"); + } catch (e) { + toast(e instanceof Error ? e.message : "删除失败"); + } finally { + setSubmitting(false); + } + }; + + const handleReply = async () => { + const next = replyContent.trim(); + if (!next || submitting) return; + setSubmitting(true); + try { + await apiCreateSnippetComment(snippetId, next, item.id); + setReplyContent(""); + setReplyOpen(false); + toast("已回复", "ok"); + window.dispatchEvent(new CustomEvent("j13-snippet-comment-refresh")); + } catch (e) { + toast(e instanceof Error ? e.message : "回复失败"); + } finally { + setSubmitting(false); + } + }; + + const moreItems: MoreItem[] = []; + if (canEdit && !editing) { + moreItems.push({ + key: "edit", + label: "编辑", + disabled: submitting, + onSelect: () => setEditing(true), + }); + } + if (canViewHistory && item.edited && !editing) { + moreItems.push({ + key: "history", + label: "编辑历史", + onSelect: () => onOpenHistory(item.id, item.content), + }); + } + if (canDelete && !editing) { + moreItems.push({ + key: "delete", + label: "删除", + danger: true, + disabled: submitting, + onSelect: () => setDeleteTarget(true), + }); + } + + const editedMark = item.edited ? ( + canViewHistory ? ( + + ) : ( + + 已编辑 + + ) + ) : null; + + if (item.deleted) { + return ( +
+
+ +
+
+
+ + {userName} + + {item.floor && item.floor > 0 && ( + + #{item.floor} + + )} + {formatRelative(item.created_at)} +
+
+
+

该评论已删除

+
+
+ +
+ {item.replies && item.replies.length > 0 && ( +
+ {item.replies.map((r) => ( + + ))} +
+ )} +
+
+ setDeleteTarget(false)} + /> +
+ ); + } + + return ( +
+
+ + + +
+
+
+ + + {userName} + + + {item.floor && item.floor > 0 && ( + + #{item.floor} + + )} + + {editedMark} +
+
+ + {editing ? ( +
+ setEditContent(v.slice(0, COMMENT_MAX))} + maxChars={COMMENT_MAX} + disabled={submitting} + autoFocus + allowHide={false} + ariaLabel="编辑评论" + placeholder="编辑评论…" + shortcutName="保存" + onUploadingChange={setEditUploading} + onSubmitShortcut={() => { + void handleEdit(); + }} + footer={ + <> + + + + } + /> +
+ ) : ( + + )} + +
+ {!editing && loggedIn && ( + + )} + +
+ + {replyOpen && !editing && ( +
{ + e.preventDefault(); + void handleReply(); + }} + className="cmt-reply-form" + > +
+ + 回复 @{userName} + + +
+
+ setReplyContent(v.slice(0, COMMENT_MAX))} + maxChars={COMMENT_MAX} + disabled={submitting} + autoFocus + allowHide={false} + ariaLabel={`回复 ${userName}`} + placeholder="写下你的回复…" + shortcutName="回复" + onUploadingChange={setReplyUploading} + onSubmitShortcut={() => { + void handleReply(); + }} + footer={ + + } + /> +
+
+ )} + + {item.replies && item.replies.length > 0 && ( +
+ {item.replies.map((r) => ( + + ))} +
+ )} +
+
+ + setDeleteTarget(false)} + /> +
+ ); +} + +export default function SnippetCommentSection({ + snippetId, + initialItems, + initialTotal, + urlStyle: _urlStyle, +}: { + snippetId: number; + initialItems: SnippetCommentItem[]; + initialTotal: number; + urlStyle: string; +}) { + const [items, setItems] = useState(initialItems); + const [total, setTotal] = useState(initialTotal); + const [content, setContent] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [uploading, setUploading] = useState(false); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [loggedIn, setLoggedIn] = useState(false); + const [historyCommentId, setHistoryCommentId] = useState(null); + const [historyCommentContent, setHistoryCommentContent] = useState(""); + + useEffect(() => { + window.dispatchEvent(new CustomEvent(snippetCommentCountEvent(snippetId), { detail: total })); + }, [total, snippetId]); + + useEffect(() => { + apiMe() + .then((res) => setLoggedIn(!!res.user)) + .catch(() => {}); + }, []); + + const loadComments = useCallback( + async (p: number) => { + setLoading(true); + try { + const { fetchSnippetComments } = await import("@/lib/api"); + const res = await fetchSnippetComments(snippetId, p, 20); + setItems(res.items); + setTotal(res.total); + setPage(p); + } catch { + toast("加载评论失败"); + } finally { + setLoading(false); + } + }, + [snippetId] + ); + + useEffect(() => { + const onRefresh = () => loadComments(page); + window.addEventListener("j13-snippet-comment-refresh", onRefresh); + return () => window.removeEventListener("j13-snippet-comment-refresh", onRefresh); + }, [loadComments, page]); + + const handleSubmit = async () => { + const next = content.trim(); + if (!next || submitting) return; + setSubmitting(true); + try { + await apiCreateSnippetComment(snippetId, next); + setContent(""); + toast("评论成功", "ok"); + loadComments(1); + } catch (e) { + toast(e instanceof Error ? e.message : "评论失败"); + } finally { + setSubmitting(false); + } + }; + + const handleUpdate = async (cid: number, newContent: string) => { + await apiUpdateSnippetComment(snippetId, cid, newContent); + loadComments(page); + }; + + const handleDelete = async (cid: number) => { + await apiDeleteSnippetComment(snippetId, cid); + loadComments(page); + }; + + const openHistory = (cid: number, currentContent: string) => { + setHistoryCommentContent(currentContent); + setHistoryCommentId(cid); + }; + + const fetchHistoryPage = async (p: number, size: number) => { + if (!historyCommentId) return { items: [], total: 0, page: p, size }; + const res = await apiSnippetCommentHistory(snippetId, historyCommentId, p, size); + const items: EditHistoryItem[] = res.items.map((it) => ({ + id: it.id, + editor: it.editor + ? { + id: it.editor.id, + username: it.editor.username, + nickname: it.editor.nickname, + avatar: it.editor.avatar, + } + : { id: 0, username: "已注销", nickname: "已注销", avatar: "" }, + old_content: it.old_content, + created_at: it.created_at, + })); + return { items, total: res.total, page: res.page, size: res.size }; + }; + + const totalPages = Math.max(1, Math.ceil(total / 20)); + + return ( +
+

+ + 评论({total}) +

+ + {loggedIn ? ( +
+ setContent(v.slice(0, COMMENT_MAX))} + maxChars={COMMENT_MAX} + disabled={submitting} + allowHide={false} + ariaLabel="发表评论" + placeholder="写下你的看法,支持 Markdown…" + onUploadingChange={setUploading} + onSubmitShortcut={() => { + void handleSubmit(); + }} + footer={ + + } + /> +
+ ) : ( +
+

+ + 登录 + {" "} + 后参与评论 +

+
+ )} + + {loading ? ( +

加载中…

+ ) : items.length === 0 ? ( +
+ +

+ 还没有评论,来抢沙发吧 +

+
+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} + + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} + + setHistoryCommentId(null)} + /> +
+ ); +} diff --git a/frontend/components/SnippetEditor.tsx b/frontend/components/SnippetEditor.tsx new file mode 100644 index 0000000..3b5c3d1 --- /dev/null +++ b/frontend/components/SnippetEditor.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, CodeXml, Eye, EyeOff, Trash2 } from "lucide-react"; +import { apiCreateSnippet, apiUpdateSnippet, apiDeleteSnippet, fetchSnippetLanguages, type SnippetInput, type SnippetVisibility } from "@/lib/api"; +import { toast } from "@/lib/toast"; +import { snippetPath, snippetsPath, type UrlStyle } from "@/lib/urlStyle"; +import Modal from "@/components/Modal"; +import SnippetLangSelect from "@/components/SnippetLangSelect"; +import { hasAuthCookieHint } from "@/lib/cookies"; +import { useSiteBrand } from "@/components/SiteBrandProvider"; + +const TITLE_MAX = 120; +const CODE_MAX = 64 * 1024; // 64KB +const DESC_MAX = 8 * 1024; // 8KB + +const VISIBILITY_OPTIONS: { value: SnippetVisibility; label: string; desc: string }[] = [ + { value: "private", label: "私有", desc: "仅自己可见" }, + { value: "public", label: "公开", desc: "所有人可见,出现在广场" }, +]; + +export default function SnippetEditor({ + mode, + snippetId, + initialTitle = "", + initialLanguage = "plaintext", + initialDescription = "", + initialCode = "", + initialVisibility = "private", +}: { + mode: "create" | "edit"; + snippetId?: number; + initialTitle?: string; + initialLanguage?: string; + initialDescription?: string; + initialCode?: string; + initialVisibility?: SnippetVisibility; +}) { + const router = useRouter(); + const { url_style: urlStyle } = useSiteBrand(); + const [title, setTitle] = useState(initialTitle); + const [language, setLanguage] = useState(initialLanguage); + const [description, setDescription] = useState(initialDescription); + const [code, setCode] = useState(initialCode); + const [visibility, setVisibility] = useState(initialVisibility); + const [languages, setLanguages] = useState([]); + const [loading, setLoading] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + const codeRef = useRef(null); + + useEffect(() => { + if (!hasAuthCookieHint()) { + router.push("/login?redirect=/snippets/new"); + return; + } + fetchSnippetLanguages() + .then((res) => setLanguages(res.languages)) + .catch(() => {}); + }, [router]); + + // Tab 键缩进 + const handleCodeKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Tab") { + e.preventDefault(); + const el = e.currentTarget; + const start = el.selectionStart; + const end = el.selectionEnd; + const newValue = code.substring(0, start) + " " + code.substring(end); + setCode(newValue); + requestAnimationFrame(() => { + el.selectionStart = el.selectionEnd = start + 2; + }); + } + }; + + const codeBytes = new TextEncoder().encode(code).length; + const descBytes = new TextEncoder().encode(description).length; + + const canSubmit = + !loading && + title.trim().length > 0 && + code.trim().length > 0 && + codeBytes <= CODE_MAX && + descBytes <= DESC_MAX; + + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); + if (!canSubmit) return; + setLoading(true); + const input: SnippetInput = { + title: title.trim(), + language, + description: description.trim(), + code: code.trim(), + visibility, + }; + try { + if (mode === "create") { + const res = await apiCreateSnippet(input); + toast("创建成功", "ok"); + router.push(snippetPath(urlStyle, res.snippet.id)); + } else if (snippetId) { + const res = await apiUpdateSnippet(snippetId, input); + toast("已保存", "ok"); + router.push(snippetPath(urlStyle, res.snippet.id)); + } + } catch (err) { + toast(err instanceof Error ? err.message : "操作失败"); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + if (!snippetId || deleting) return; + setDeleting(true); + try { + await apiDeleteSnippet(snippetId); + toast("已删除", "ok"); + router.push(snippetsPath(urlStyle)); + } catch (err) { + toast(err instanceof Error ? err.message : "删除失败"); + } finally { + setDeleting(false); + setDeleteOpen(false); + } + }; + + return ( +
+
+
+ +
+

+ {mode === "create" ? "新建片段" : "编辑片段"} +

+

+ {mode === "create" ? "保存一段可复用的代码" : "修改片段内容"} +

+
+
+
+ {mode === "edit" && ( + + )} + +
+
+ +
+
+ {/* 标题 */} +
+ + setTitle(e.target.value.slice(0, TITLE_MAX))} + required + maxLength={TITLE_MAX} + placeholder="片段标题" + className="j13-compose-title" + disabled={loading} + /> + + {title.length}/{TITLE_MAX} + +
+ + {/* 语言 + 可见性 */} +
+
+ + +
+ +
+ {VISIBILITY_OPTIONS.map((opt) => ( + + ))} +
+ + {VISIBILITY_OPTIONS.find((o) => o.value === visibility)?.desc} + +
+ + {/* 代码编辑区 */} +
+ +