7 Commits

Author SHA1 Message Date
4e8dfa2b74 chore: bump version to 0.2.11 2026-10-16 16:31:09 +08:00
55ded2695c feat: 移动端底部导航可配置与工作区导航、置顶后台等前端调整 2026-10-16 16:26:08 +08:00
47f87a011c feat: 帖子分级置顶(板块/全站)与板块管理员置顶权限、消息管理权限调整 2026-10-15 23:35:30 +08:00
e7b0686947 chore: bump version to 0.2.10 2026-10-09 19:07:57 +08:00
da72843c70 feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等 2026-10-09 18:53:38 +08:00
5598e1cbb7 chore: bump version to 0.2.9 2026-10-03 02:40:45 +08:00
46e0cdc0b3 feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
2026-10-03 02:38:11 +08:00
122 changed files with 9518 additions and 671 deletions

View File

@@ -150,9 +150,9 @@ data volume ──► Go 的 dataDir(与本地 backend/data 语义一致
## 四级 RBAC + 内容审核(2026-09)
- 角色:`owner`(全站唯一,不可改角色/封禁;独占彻底删除) > `super_admin`(全站后台+群聊全站监管,仅 owner 可授予) > `admin`(公告+全站帖/评论审核软删+置顶加精) > `board_admin`(仅授权板审核/软删普通用户内容,不可软删 admin+ 作者,默认无消息管理) > `user`。RoleLevel 0/30/50/80/100,staff=board_admin 及以上
- 角色:`owner`(全站唯一,不可改角色/封禁;独占彻底删除) > `super_admin`(全站后台,仅 owner 可授予) > `admin`(公告+全站帖/评论审核软删+置顶加精) > `board_admin`(仅授权板审核/软删普通用户内容,不可软删 admin+ 作者,默认无消息管理) > `user`。RoleLevel 0/30/50/80/100,staff=board_admin 及以上
- Actor 由 RequireStaff 每请求从 DB 现取(角色+board_ids+can_manage_messages);RequirePerm(announcements/users/settings/messages);板块隔离按 BoardIDs;板管仪表盘 analytics 按授权板过滤
- 消息:站长可授 `can_manage_messages`(全站群聊后台,无私聊);站长可在群内任命 `ChatRoleAdmin`;站长/超管 `CanOverseeChat` 含私聊;禁言 API 已接
- 消息:站长可授 `can_manage_messages`(全站群聊后台,无私聊);超管亦需授予才进入后台消息管理并获全站监管(含私聊),站长固有;站长可在群内任命 `ChatRoleAdmin`;`Actor.CanOverseeChat()` = 站长 || (超管 && can_manage_messages);禁言 API 已接
- 彻底删除帖/评:仅 owner;其余管理员仅软删+恢复
- 发帖/评论:staff 直发 published,普通用户进 pending;pending 不计 comment_count(ApproveComment 时 +1,删除只减 published);公开列表仅 published;作者/staff 可经详情页看 pending/rejected(GetByIDForViewer + EnsurePostVisible)
- 审核端点 7 个直挂 /api/admin/moderation/*;通过/拒绝发 approved/rejected 通知(拒绝需 reason,评论通过时补发 comment/reply 业务通知);ErrModerationForbidden→403、ErrNotPending→400、ErrAuthorProtected/ErrPurgeOwnerOnly→403

220
LICENSE
View File

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

422
README.md
View File

@@ -1,101 +1,128 @@
<div align="center">
# 姜十三论坛(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)](#贡献指南)
</div>
---
## 目录
- [项目截图](#项目截图)
- [功能特性](#功能特性)
- [技术栈](#技术栈)
- [快速开始](#快速开始)
- [配置说明](#配置说明)
- [Docker 部署](#docker-部署)
- [项目结构](#项目结构)
- [安全设计](#安全设计)
- [开发与测试](#开发与测试)
- [更新日志](#更新日志)
- [贡献指南](#贡献指南)
- [许可证](#许可证)
- [联系方式](#联系方式)
- [赞赏支持](#赞赏支持)
---
## 项目截图
| 首页信息流 | 帖子详情 |
| :---: | :---: |
| ![首页](docs/images/首页.png) | ![帖子详情](docs/images/帖子详情.png) |
| 代码片段广场 | 书库在线阅读 |
| ![代码片段](docs/images/代码片段.png) | ![书库](docs/images/书库.png) |
| 管理后台仪表盘 | 访问统计分析 |
| ![管理后台](docs/images/管理后台.png) | ![访问统计](docs/images/访问统计.png) |
<!--
后台设置类截图(邮件 / 过滤 / 维护 / 安全 / 存储,含移动端与暗色模式):
docs/screenshots/settings/
-->
---
## 功能特性
### 内容社区
- 帖子发布 / 编辑 / 删除,支持普通帖、**问答帖**(采纳答案、状态流转)、**投票帖**、**悬赏帖**、**抽奖帖**,以及密码帖与付费解锁内容
- 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)
---
## 赞赏支持
如果本项目对你有帮助,欢迎请作者喝杯咖啡 ☕,你的支持是持续维护的动力。
<p align="center">
<img src="docs/images/wechat.png" width="220" alt="微信打赏" />
&nbsp;&nbsp;&nbsp;&nbsp;
<img src="docs/images/zfb.png" width="220" alt="支付宝打赏" />
</p>
<p align="center">微信打赏 &nbsp;&nbsp;|&nbsp;&nbsp; 支付宝打赏</p>

View File

@@ -1 +1 @@
0.2.8
0.2.11

View File

@@ -21,8 +21,10 @@ func main() {
log.Fatalf("数据库初始化失败: %v", err)
}
// 生产 HTTPS 启用 __Host- 认证 cookie 前缀(须在路由注册、读写 cookie 前配置)
service.ConfigureCookieNames(!cfg.DevMode)
// 启用 __Host- 认证 cookie 前缀(须在路由注册、读写 cookie 前配置)。
// 以前是 !DevMode 硬绑定,现在解耦:根据 SITE_URL scheme 或 FORCE_HTTPS 判断,
// 内网 HTTP 部署零配置即可跑,反代 TLS 但后端只看到 HTTP 时设 FORCE_HTTPS=true。
service.ConfigureCookieNames(cfg.IsSecureOrigin())
// 过期/已吊销 refresh token 定期清理
service.StartRefreshTokenCleanup(model.DB)
// 悬赏过期退回 / 抽奖到期开奖

View File

@@ -24,6 +24,7 @@ type Config struct {
DevMode bool
SiteURL string // 对外站点 origin,如 https://bbs.example.com;生产 CORS 用
CORSOrigins []string // 额外允许的 CORS origin(逗号分隔环境变量)
ForceHTTPS bool // 强制启用 HTTPS 安全 cookie / HSTS / __Host- 前缀;反代 TLS 但后端只看到 HTTP 时设 true
}
// appIniKey 描述 app.ini 中应由启动补全的键(已有值不改写)。
@@ -43,7 +44,8 @@ var appIniSchema = []appIniKey{
{section: "paths", key: "DATA", value: "data"},
{section: "app", key: "DEV_MODE", value: "true"},
{section: "app", key: "SITE_URL", value: "", comment: "对外正式 origin,无尾斜杠。账号邮件链接与 Next 的 SITE_URL 必须一致。\n本地可填 http://localhost:3000;生产填 https://bbs.example.com。环境变量 SITE_URL 优先。"},
{section: "app", key: "CORS_ORIGINS", value: "", comment: "额外 CORS origin,逗号分隔。环境变量 CORS_ORIGINS 优先。同源反代通常可留空。"},
{section: "app", key: "CORS_ORIGINS", value: "", comment: "额外 CORS origin,逗号分隔。环境变量 CORS_ORIGINS 优先。"},
{section: "app", key: "FORCE_HTTPS", value: "false", comment: "强制启用 HTTPS 安全 cookie / HSTS / __Host- 前缀。反代 TLS 但后端只看到 HTTP 时设 true。\n默认自动根据 SITE_URL scheme 判断。环境变量 FORCE_HTTPS 优先。"},
}
// Parse 解析配置:环境变量 > app.ini > 默认值。
@@ -98,6 +100,9 @@ func Parse() (*Config, error) {
if v := strings.TrimSpace(os.Getenv("CORS_ORIGINS")); v != "" {
cfg.CORSOrigins = splitCSVOrigins(v)
}
if v := strings.TrimSpace(os.Getenv("FORCE_HTTPS")); v != "" {
cfg.ForceHTTPS = strings.EqualFold(v, "true") || v == "1"
}
if v := strings.TrimSpace(os.Getenv("DATA_DIR")); v != "" {
abs, err := filepath.Abs(v)
if err != nil {
@@ -179,6 +184,9 @@ func applyIni(cfg *Config, f *ini.File) {
cfg.CORSOrigins = splitCSVOrigins(v)
}
}
if s := f.Section("app"); s.HasKey("FORCE_HTTPS") {
cfg.ForceHTTPS = s.Key("FORCE_HTTPS").MustBool(false)
}
}
// loadAndEnsureAppIni 加载 app.ini;不存在则按 schema 新建;已存在则只补缺失键。
@@ -227,6 +235,19 @@ func loadAndEnsureAppIni(path string) (*ini.File, error) {
return f, nil
}
// IsSecureOrigin 判断对外站点是否走 HTTPS,用于控制 cookie Secure 标志、HSTS 头、__Host- cookie 前缀。
// 优先级:FORCE_HTTPS 显式设置 > SITE_URL scheme 推断。
// 本地 dev 未填 SITE_URL 时按 http 处理,不会误开安全策略。
func (c *Config) IsSecureOrigin() bool {
if c.ForceHTTPS {
return true
}
if c.SiteURL == "" {
return false
}
return strings.HasPrefix(c.SiteURL, "https://")
}
// AllowOrigin 供 CORS 中间件判断浏览器 Origin。
// 开发态只放行 localhost / 127.0.0.1 的 3000 端口(与 next dev 一致);
// 生产态放行 SITE_URL 与 CORS_ORIGINS。同源反代下浏览器不依赖 CORS,此列表作兜底。

View File

@@ -164,7 +164,7 @@ func (h *Handlers) Register(c *gin.Context) {
})
return
}
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
setAuthCookies(c, accessToken, refreshToken, h.Cfg.IsSecureOrigin())
h.AdminUser.RecordLogin(loginUser.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
@@ -212,7 +212,7 @@ func (h *Handlers) Login(c *gin.Context) {
h.AdminUser.RecordLogin(user.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
// dev 模式不设 Secure,生产环境需 HTTPS
h.Ops.ClearFailure(req.Username)
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
setAuthCookies(c, accessToken, refreshToken, h.Cfg.IsSecureOrigin())
// 多会话并存:新登录不踢旧设备;仅当本次登录超出会话上限、淘汰了最旧
// 会话时广播告知(被淘汰设备 WS 在线则当场弹"登录已失效",无需等
// access token 过期或硬刷新才发现)
@@ -244,13 +244,13 @@ func (h *Handlers) Refresh(c *gin.Context) {
if err != nil {
if errors.Is(err, service.ErrAccountBanned) {
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
clearAuthCookies(c, !h.Cfg.DevMode)
clearAuthCookies(c, h.Cfg.IsSecureOrigin())
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
return
}
if errors.Is(err, service.ErrRefreshInvalid) || errors.Is(err, service.ErrRefreshExpired) {
// 登录态确实失效:清 cookie 要求重新登录(前端弹"登录已失效")
clearAuthCookies(c, !h.Cfg.DevMode)
clearAuthCookies(c, h.Cfg.IsSecureOrigin())
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return
}
@@ -259,7 +259,7 @@ func (h *Handlers) Refresh(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "登录刷新暂不可用,请稍后重试"})
return
}
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
setAuthCookies(c, accessToken, newRefresh, h.Cfg.IsSecureOrigin())
c.JSON(http.StatusOK, gin.H{
"user": gin.H{
"id": user.ID,
@@ -279,7 +279,7 @@ func (h *Handlers) Logout(c *gin.Context) {
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil && refreshToken != "" {
_ = h.Auth.RevokeRefreshToken(refreshToken)
}
clearAuthCookies(c, !h.Cfg.DevMode)
clearAuthCookies(c, h.Cfg.IsSecureOrigin())
c.JSON(http.StatusOK, gin.H{"message": "已登出"})
}
@@ -302,7 +302,7 @@ func (h *Handlers) ChangePassword(c *gin.Context) {
return
}
// 密码已改,token_version 已递增,旧 token 全部失效,清除 cookie 要求重新登录
clearAuthCookies(c, !h.Cfg.DevMode)
clearAuthCookies(c, h.Cfg.IsSecureOrigin())
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功,请重新登录"})
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/middleware"
@@ -68,7 +69,7 @@ func (h *Handlers) chatOversee(userID uint) bool {
if err != nil || actor == nil {
return false
}
return service.CanOverseeChat(actor.Role)
return actor.CanOverseeChat()
}
// RequireMessagesOpen 前台群聊/私聊闸门;关闭后一律 403。管理端 /api/admin/chat 不受影响。
@@ -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})
}
@@ -622,7 +673,7 @@ func (h *Handlers) AdminChatMessages(c *gin.Context) {
"messages": msgs,
"total": total,
"page": page,
"can_oversee": service.CanOverseeChat(actor.Role),
"can_oversee": actor.CanOverseeChat(),
"can_recall": true,
"site_messages": actor.HasSiteMessagePerm(),
})
@@ -646,7 +697,7 @@ func (h *Handlers) AdminRecallChatMessage(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "消息 ID 无效"})
return
}
oversee := service.CanOverseeChat(actor.Role)
oversee := actor.CanOverseeChat()
msg, newly, err := h.Chat.RecallMessage(claims.ID, uint(roomID), uint(mid), oversee)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})

View File

@@ -42,6 +42,7 @@ type Handlers struct {
Badge *service.BadgeService
LeaderboardSvc *service.LeaderboardService
LibrarySvc *service.LibraryService
SnippetSvc *service.SnippetService
}
// resolvePublishStatus 决定新帖/新评的初始状态:

View File

@@ -246,3 +246,55 @@ func (h *Handlers) execModeration(c *gin.Context, fn func(*service.Actor, uint)
h.broadcastModerationChanged()
c.JSON(http.StatusOK, gin.H{"message": "操作成功"})
}
// ===== 置顶顺序配置 =====
// AdminListPinnedPosts 列出指定层级(level=1 板块 / 2 全站)的置顶帖,供后台排序
func (h *Handlers) AdminListPinnedPosts(c *gin.Context) {
level, _ := strconv.Atoi(c.Query("level"))
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
if level != model.PinBoard && level != model.PinGlobal {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的置顶层级"})
return
}
items, err := h.Moderation.ListPinnedPosts(middleware.CurrentActor(c), level, uint(boardID))
if err != nil {
if errors.Is(err, service.ErrPinOrderForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取置顶列表失败"})
return
}
c.JSON(http.StatusOK, gin.H{"posts": nonNilSlice(items)})
}
// AdminSavePinOrderRequest 置顶顺序保存请求
type AdminSavePinOrderRequest struct {
Level int `json:"level" binding:"required"`
BoardID uint `json:"board_id"`
IDs []uint `json:"ids" binding:"required"`
}
// AdminSavePinOrder 保存置顶帖显示顺序(ids 数组顺序即展示顺序)
func (h *Handlers) AdminSavePinOrder(c *gin.Context) {
var req AdminSavePinOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if req.Level != model.PinBoard && req.Level != model.PinGlobal {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的置顶层级"})
return
}
if err := h.Moderation.SavePinOrder(middleware.CurrentActor(c), req.Level, req.BoardID, req.IDs); err != nil {
if errors.Is(err, service.ErrPinOrderForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.broadcastFeedChanged("post", 0, req.BoardID, middleware.CurrentActor(c).ID)
c.JSON(http.StatusOK, gin.H{"message": "已保存"})
}

View File

@@ -262,6 +262,10 @@ func (h *Handlers) SendEmailCode(c *gin.Context) {
return
}
wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP())
if errors.Is(e, service.ErrEmailRegistered) {
c.JSON(http.StatusBadRequest, gin.H{"error": e.Error()})
return
}
if !h.quotaResponse(c, wait, e) {
return
}

View File

@@ -550,9 +550,12 @@ func (h *Handlers) DeletePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
// TogglePin 切换帖子置顶(管理员及以上,板块管理员无此权限)
// TogglePin 切换帖子置顶:管理员及以上切全站置顶,板块管理员切本板板块置顶
func (h *Handlers) TogglePin(c *gin.Context) {
if !h.requireAdminOrAbove(c) {
claims := middleware.CurrentUser(c)
actor, err := h.Auth.LoadActor(claims.ID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
@@ -560,9 +563,17 @@ func (h *Handlers) TogglePin(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
pinned, err := h.Post.TogglePin(uint(id))
level, _ := strconv.Atoi(c.Query("level"))
pinned, err := h.Post.TogglePin(uint(id), actor, level)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
switch {
case errors.Is(err, service.ErrPostNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPostForbidden), errors.Is(err, service.ErrPinGlobalOnly):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{"pinned": pinned})

View File

@@ -61,7 +61,7 @@ func (h *Handlers) RealtimeWS(c *gin.Context) {
canOverseeChat := false
if actor, loadErr := h.Auth.LoadActor(claims.ID); loadErr == nil {
isStaff = actor.IsStaff()
canOverseeChat = service.CanOverseeChat(actor.Role)
canOverseeChat = actor.CanOverseeChat()
}
client := realtime.NewClient(
@@ -74,7 +74,7 @@ func (h *Handlers) RealtimeWS(c *gin.Context) {
return verr == nil
},
func() { h.Auth.TouchLastSeen(claims.ID) },
// 动态房间订阅鉴权:成员可订阅;站长/超管旁路可订阅任意群
// 动态房间订阅鉴权:成员可订阅;站长/已授权超管旁路可订阅任意群
func(room string) bool {
roomID, ok := parseWSChatRoom(room)
if !ok {

View File

@@ -57,6 +57,7 @@ type updateSettingsRequest struct {
BrandMark *string `json:"brand_mark"`
BrandLogoSize *string `json:"brand_logo_size"`
BrandLogoFit *string `json:"brand_logo_fit"`
BrandSloganVisible *bool `json:"brand_slogan_visible"`
FooterLinks *[]service.FooterLink `json:"footer_links"`
PointsCheckinBase *int `json:"points_checkin_base"`
@@ -120,6 +121,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"brand_mark": saved.BrandMark,
"brand_logo_size": saved.BrandLogoSize,
"brand_logo_fit": saved.BrandLogoFit,
"brand_slogan_visible": saved.BrandSloganVisible,
"footer_links": saved.FooterLinks,
"points_checkin_base": saved.PointsCheckinBase,
@@ -160,6 +162,7 @@ func (req *updateSettingsRequest) hasAny() bool {
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
req.BrandSloganVisible != nil ||
req.FooterLinks != nil ||
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
@@ -540,6 +543,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
if req.BrandSloganVisible != nil {
if err := h.Setting.SetBrandSloganVisible(*req.BrandSloganVisible); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.FooterLinks != nil {
if err := h.Setting.SetFooterLinks(*req.FooterLinks); err != nil {
if errors.Is(err, service.ErrInvalidSiteSetting) {

424
backend/handler/snippet.go Normal file
View File

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

View File

@@ -5,8 +5,9 @@ import (
)
// SecurityHeaders 安全响应头中间件
// 为所有响应添加安全头,降低 XSS、点击劫持、MIME 嗅探等风险
func SecurityHeaders() gin.HandlerFunc {
// 为所有响应添加安全头,降低 XSS、点击劫持、MIME 嗅探等风险。
// secure=true 时额外下发 HSTS(仅 HTTPS origin 合法,HTTP 环境发 HSTS 属于误导)。
func SecurityHeaders(secure bool) gin.HandlerFunc {
return func(c *gin.Context) {
// CSP:限制资源加载来源,降低 XSS 危害
c.Header("Content-Security-Policy",
@@ -20,8 +21,10 @@ func SecurityHeaders() gin.HandlerFunc {
"base-uri 'self'; "+
"form-action 'self'")
// HSTS:强制 HTTPS(生产环境生效,dev 模式浏览器会忽略)
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
// HSTS:仅 HTTPS origin 下发。HTTP 响应发 HSTS 是无效且误导的。
if secure {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
// 点击劫持防护
c.Header("X-Frame-Options", "DENY")
@@ -32,9 +35,6 @@ func SecurityHeaders() gin.HandlerFunc {
// 控制 Referer 信息泄露
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// 禁止浏览器缓存敏感页面(可按需覆盖)
// c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Next()
}
}

View File

@@ -56,7 +56,7 @@ func InitDB(dsn string) error {
}
if err := db.AutoMigrate(
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{}, &Blob{},
&User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{},
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
@@ -64,10 +64,16 @@ 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)
}
// 帖子置顶分级:历史全站置顶(pinned=1)升为 2,避免存量置顶帖被降级为板块置顶
if err := preparePostPinLevels(db); err != nil {
return fmt.Errorf("帖子置顶分级迁移失败: %w", err)
}
// RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长;
// 已存在 owner 时不动数据,保证幂等
if err := ensureOwnerRole(db); err != nil {
@@ -179,6 +185,25 @@ func prepareAnnouncementPinnedColumn(db *gorm.DB) error {
return db.Exec(`ALTER TABLE announcements ADD COLUMN pinned boolean NOT NULL DEFAULT false`).Error
}
// preparePostPinLevels 一次性迁移:历史置顶(pinned=1,旧语义=全站置顶)升为 2。
// 新语义下 1=板块置顶、2=全站置顶,若不迁移,存量全站置顶会被降级为板块置顶。
// 用 site_settings 内部键做幂等标记,避免每次启动把新建的板块置顶(=1)误升为全站。
func preparePostPinLevels(db *gorm.DB) error {
const markerKey = "internal_pin_levels_migrated"
var marker int64
if err := db.Model(&SiteSetting{}).Where("key = ?", markerKey).Count(&marker).Error; err != nil {
return err
}
if marker > 0 {
return nil
}
if err := db.Exec(`UPDATE posts SET pinned = 2 WHERE pinned = 1`).Error; err != nil {
return err
}
return db.Exec(`INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, markerKey, "1").Error
}
// prepareSiteSettingValueText 将 site_settings.value 从 varchar(255) 扩为 text(幂等)
func prepareSiteSettingValueText(db *gorm.DB) error {
var tableCount int64

View File

@@ -11,7 +11,7 @@ type Role string
const (
RoleUser Role = "user" // 普通用户:发帖/评论需审核
RoleBoardAdmin Role = "board_admin" // 板块管理员:仅管理被授权板块的帖子与评论
RoleBoardAdmin Role = "board_admin" // 板块管理员:管理被授权板块的帖子、评论与板块内置顶
RoleAdmin Role = "admin" // 管理员:公告 + 全站帖子/评论审核
RoleSuperAdmin Role = "super_admin" // 超级管理员:全站后台(用户/公告/设置/内容),但不可操作站长
RoleOwner Role = "owner" // 站长:最高权限,全站唯一,任何人(含自己)不可改角色/封禁
@@ -121,7 +121,7 @@ type User struct {
Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名
Role Role `gorm:"size:16;default:user" json:"role"`
Banned bool `gorm:"default:false" json:"banned"`
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;超管亦需授予,站长固有全站监管)
PermOverrides string `gorm:"size:256;default:''" json:"-"` // 账号级渠道权限覆盖 JSON;仅后台权限服务读取,序列化跳过
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
@@ -207,6 +207,13 @@ type ImportRecord struct {
CreatedAt time.Time `json:"created_at"`
}
// 帖子置顶级别:同一 pinned 字段分级,数值越大范围越大
const (
PinNone = 0 // 不置顶
PinBoard = 1 // 板块置顶:仅所属板块列表靠前(板块管理员可操作)
PinGlobal = 2 // 全站置顶:首页与所有列表靠前(管理员及以上可操作)
)
// Post 帖子
type Post struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -219,7 +226,8 @@ type Post struct {
ContentAccess string `gorm:"size:16;default:public;index" json:"content_access"` // public|login|reply|points|password|mixed(由正文隐藏块派生)
AccessPoints int `gorm:"not null;default:0" json:"access_points"` // 积分隐藏块价格之和
TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳)
Pinned int `gorm:"default:0" json:"pinned"`
Pinned int `gorm:"default:0" json:"pinned"` // 置顶级别:0=无 1=板块 2=全站
PinOrder int `gorm:"default:0" json:"pin_order"` // 同层级置顶帖的显示顺序:越小越靠前
Recommended bool `gorm:"default:false;index" json:"recommended"`
Locked bool `gorm:"default:false;index" json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
Status string `gorm:"size:16;default:published;index" json:"status"`
@@ -362,12 +370,13 @@ type PostContentUnlock struct {
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
type PostAttachment struct {
ObjectID string `gorm:"size:64;index" json:"-"`
ObjectID string `gorm:"size:96;index" json:"-"`
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
UserID uint `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名(blob 去重后即内容 hash)
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
Size int `gorm:"not null;default:0" json:"size"`
PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费
@@ -575,6 +584,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"`
@@ -666,13 +744,14 @@ const (
AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图
)
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
// Attachment 用户上传的附件(头像与帖子插图等公开图片)
type Attachment struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
Source string `gorm:"size:24;not null;default:'post'" json:"source"`
URL string `gorm:"size:512;not null" json:"url"`
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
Width int `gorm:"not null;default:0" json:"width"`
@@ -680,6 +759,20 @@ type Attachment struct {
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// Blob 内容寻址物理文件:对【最终存储字节】算 SHA-256,同内容多次上传共享一份底层存储,
// 业务行(Attachment/PostAttachment 及未来的聊天文件消息)只引用 hash,按引用计数回收。
// 公开(帖子插图/头像)与私有(付费附件、未来私聊文件)是两个互不可见的命名空间:
// 同字节也各自存一份,避免私有文件借公开链接(/api/media/<id>)被越权读取。
type Blob struct {
Hash string `gorm:"primaryKey;size:64" json:"-"` // 最终存储字节的 SHA-256 hex(图片为转码后的字节)
Public bool `gorm:"primaryKey" json:"-"`
RefCount int64 `gorm:"not null;default:0" json:"-"`
Size int `gorm:"not null;default:0" json:"-"`
MIME string `gorm:"size:128;not null;default:''" json:"-"`
ObjectID string `gorm:"size:96;not null;default:''" json:"-"` // S3 后端的 StoredObject ID;本地后端为空
CreatedAt time.Time `json:"-"`
}
// UserBoard 板块管理员的板块授权(多对多;仅 role=board_admin 的行生效)
type UserBoard struct {
ID uint `gorm:"primaryKey" json:"id"`

View File

@@ -51,7 +51,7 @@ type EmailChallenge struct {
// Every remote object pins the immutable configuration revision that wrote it.
type StoredObject struct {
ID string `gorm:"primaryKey;size:64" json:"id"`
ID string `gorm:"primaryKey;size:96" json:"id"`
ConfigName string `gorm:"size:64;index" json:"-"`
Key string `gorm:"size:512" json:"-"`
MIME string `gorm:"size:128" json:"-"`

View File

@@ -65,8 +65,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.Use(gin.Recovery())
r.Use(gin.Logger())
// 全局安全响应头
r.Use(middleware.SecurityHeaders())
// 全局安全响应头(HSTS 仅 HTTPS origin 下发)
r.Use(middleware.SecurityHeaders(cfg.IsSecureOrigin()))
// CORS:开发放行 localhost:3000;生产用 SITE_URL / CORS_ORIGINS(同源反代时浏览器不走跨域)
r.Use(cors.New(cors.Config{
@@ -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)
@@ -158,13 +159,14 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Chat: chatSvc,
Visit: visitSvc,
Analytics: analyticsSvc,
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, cfg.IsSecureOrigin()),
Ads: adSvc,
Sidebar: service.NewSidebarService(model.DB),
FriendLink: service.NewFriendLinkService(model.DB),
Badge: badgeSvc,
LeaderboardSvc: leaderboardSvc,
LibrarySvc: librarySvc,
SnippetSvc: snippetSvc,
}
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
notifSvc.OnNotifyNew = func(userID uint) {
@@ -201,7 +203,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
r.GET("/api/ws", h.RealtimeWS)
// 公开 API(可选登录);EnsureCSRF 让游客也能拿到双提交 token(密码解锁等)
pubAPI := r.Group("/api", authMW.OptionalAuth(), middleware.EnsureCSRFCookie(!cfg.DevMode))
pubAPI := r.Group("/api", authMW.OptionalAuth(), middleware.EnsureCSRFCookie(cfg.IsSecureOrigin()))
{
pubAPI.GET("/me", h.Me)
pubAPI.GET("/boards", h.Boards)
@@ -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)
{
@@ -358,6 +380,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
staffAPI.PUT("/content/comments/:id/restore", h.AdminRestoreComment)
staffAPI.DELETE("/content/comments/:id/purge", h.AdminPurgeComment)
// 置顶帖顺序配置(板块置顶:板管限授权板块;全站置顶:管理员及以上)
staffAPI.GET("/pinned-posts", h.AdminListPinnedPosts)
staffAPI.PUT("/pinned-posts/order", h.AdminSavePinOrder)
// 积分与类型帖概览(管理员及以上)
staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)
@@ -508,6 +534,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))
{

View File

@@ -18,7 +18,7 @@ const (
PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予)
PermSettings = "settings" // 站点外观设置(超管/站长)
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
PermMessages = "messages" // 后台消息管理(站长固有;超管及其他角色需站点消息 flag;群管另有业务层放行)
PermBoards = "boards" // 板块管理(仅站长)
)
@@ -105,7 +105,7 @@ func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages
for _, p := range ParsePermOverrides(permOverridesRaw) {
set[p] = true
}
if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages {
if role == model.RoleOwner || canManageMessages {
set[PermMessages] = true
}
out := make([]string, 0, len(set))
@@ -123,7 +123,7 @@ type Actor struct {
Username string
Role model.Role
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
CanManageMessages bool // 站点级消息管理 flag(仅站长可授,超管亦需此授予);站长固有
ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量)
}
@@ -151,15 +151,22 @@ func (a *Actor) HasPerm(p string) bool {
return false
}
// HasSiteMessagePerm 站点级消息管理:站长/超管固有,或被授予 CanManageMessages
// HasSiteMessagePerm 站点级消息管理:站长固有;超管及其他角色需被授予 CanManageMessages
func (a *Actor) HasSiteMessagePerm() bool {
if a == nil {
return false
}
if a.Role == model.RoleOwner || a.Role == model.RoleSuperAdmin {
return true
return a.Role == model.RoleOwner || a.CanManageMessages
}
// CanOverseeChat 全站消息监管(可见全部群含私聊、撤回任意消息、旁路订阅任意房间):
// 站长固有;超管需先被授予站点消息管理
func (a *Actor) CanOverseeChat() bool {
if a == nil {
return false
}
return a.CanManageMessages
return a.Role == model.RoleOwner ||
(a.Role == model.RoleSuperAdmin && a.CanManageMessages)
}
// CanPurgeContent 彻底删除帖/评:仅站长

View File

@@ -13,6 +13,7 @@ import (
var (
ErrContentNotDeleted = errors.New("仅已删除内容可彻底清除")
ErrContentNotFound = errors.New("内容不存在")
ErrPinOrderForbidden = errors.New("无权配置该层级置顶顺序")
)
func (s *ModerationService) authorRole(userID uint) (model.Role, error) {
@@ -511,3 +512,105 @@ func (s *ModerationService) PurgeComment(actor *Actor, id uint) error {
return tx.Unscoped().Delete(&model.Comment{}, id).Error
})
}
// ===== 置顶顺序配置(后台可拖拽排序,同层级 pin_order 越小越靠前) =====
// PinnedPostItem 置顶帖排序列表项
type PinnedPostItem struct {
ID uint `json:"id"`
Title string `json:"title"`
Pinned int `json:"pinned"`
PinOrder int `json:"pin_order"`
BoardID uint `json:"board_id"`
BoardName string `json:"board_name"`
CreatedAt time.Time `json:"created_at"`
}
// assertPinScopePermission 校验 actor 对指定置顶层级/板块的排序权限:
// 全局置顶仅管理员及以上;板块置顶允许管理员或管辖该板块的板管(board_id 必填)。
func (s *ModerationService) assertPinScopePermission(actor *Actor, level int, boardID uint) error {
if actor == nil {
return ErrPinOrderForbidden
}
if model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin) {
return nil
}
if level == model.PinGlobal {
return ErrPinOrderForbidden
}
if level == model.PinBoard {
if boardID == 0 {
return ErrPinOrderForbidden // 板管必须指定具体板块
}
if actor.CanModerateBoard(boardID) {
return nil
}
}
return ErrPinOrderForbidden
}
// ListPinnedPosts 列出指定层级的置顶帖(当前顺序:pin_order 升序,未配置按发帖时间倒序兜底)。
// level=2 全站置顶;level=1 需带 board_id(管理员可省=全部板块的板块置顶)。
func (s *ModerationService) ListPinnedPosts(actor *Actor, level int, boardID uint) ([]PinnedPostItem, error) {
if err := s.assertPinScopePermission(actor, level, boardID); err != nil {
return nil, err
}
q := s.db.Model(&model.Post{}).
Where("pinned = ? AND deleted_at IS NULL AND status = ?", level, model.ContentStatusPublished)
if boardID > 0 {
q = q.Where("board_id = ?", boardID)
}
var posts []model.Post
if err := q.Order("(pin_order = 0), pin_order ASC, created_at DESC").
Preload("Board").Find(&posts).Error; err != nil {
return nil, err
}
items := make([]PinnedPostItem, 0, len(posts))
for _, p := range posts {
items = append(items, PinnedPostItem{
ID: p.ID,
Title: p.Title,
Pinned: p.Pinned,
PinOrder: p.PinOrder,
BoardID: p.BoardID,
BoardName: p.Board.Name,
CreatedAt: p.CreatedAt,
})
}
return items, nil
}
// SavePinOrder 按传入 ID 顺序重写 pin_order(从 1 递增)。
// 安全约束:ids 必须全部属于该层级且在权限范围内,否则整体拒绝,避免半截写入。
func (s *ModerationService) SavePinOrder(actor *Actor, level int, boardID uint, ids []uint) error {
if err := s.assertPinScopePermission(actor, level, boardID); err != nil {
return err
}
if len(ids) == 0 {
return nil
}
if len(ids) > 200 {
return errors.New("单次最多排序 200 条")
}
var count int64
q := s.db.Model(&model.Post{}).
Where("id IN ? AND pinned = ? AND deleted_at IS NULL", ids, level)
if boardID > 0 {
q = q.Where("board_id = ?", boardID)
}
if err := q.Count(&count).Error; err != nil {
return err
}
if count != int64(len(ids)) {
return ErrPinOrderForbidden // 含越权/已取消置顶/已删除的 ID
}
return s.db.Transaction(func(tx *gorm.DB) error {
for i, id := range ids {
if err := tx.Model(&model.Post{}).Where("id = ?", id).
UpdateColumn("pin_order", i+1).Error; err != nil {
return err
}
}
return nil
})
}

View File

@@ -470,7 +470,7 @@ func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perm
return s.getItem(s.db, targetID)
}
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
// SetCanManageMessages 站长授予/撤销站点消息管理权限(超管亦需授予;不可操作站长账号与自己)
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
if operator == nil || operator.Role != model.RoleOwner {
return nil, ErrOwnerOnly
@@ -486,10 +486,6 @@ func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint,
if u.Role == model.RoleOwner {
return ErrProtectedOwner
}
// 超管固有全站监管,flag 无意义;强制保持 false 以免误导
if u.Role == model.RoleSuperAdmin {
enabled = false
}
if u.CanManageMessages == enabled {
return nil
}

View File

@@ -107,10 +107,13 @@ var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
// 邮箱非空时全库唯一(忽略大小写):后期支持邮箱登录,一邮箱只能对应一个账号
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
return nil, err
}
// 邮箱统一小写归一化,避免大小写变体重复注册
email = strings.ToLower(strings.TrimSpace(email))
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -130,6 +133,16 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
if count > 0 {
return errors.New("用户名已被使用")
}
// 邮箱唯一(忽略大小写,兜底防绕过;入口发码处已拦一次)
if email != "" {
var emailCount int64
if err := tx.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&emailCount).Error; err != nil {
return err
}
if emailCount > 0 {
return errors.New("该邮箱已被注册")
}
}
var n int64
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
return err
@@ -681,14 +694,14 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature stri
if nickCount > 0 {
return nil, errors.New("该昵称已被使用")
}
email = strings.TrimSpace(email)
email = strings.ToLower(strings.TrimSpace(email))
if email != "" {
if !strings.Contains(email, "@") || len(email) > 128 {
return nil, errors.New("邮箱格式不正确")
}
// 邮箱唯一性校验(排除自身)
// 邮箱唯一性校验(忽略大小写,排除自身),与注册口径一致
var count int64
s.db.Model(&model.User{}).Where("email = ? AND id <> ?", email, userID).Count(&count)
s.db.Model(&model.User{}).Where("LOWER(email) = ? AND id <> ?", email, userID).Count(&count)
if count > 0 {
return nil, errors.New("该邮箱已被使用")
}

View File

@@ -0,0 +1,223 @@
package service
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"os"
"path/filepath"
"testing"
"github.com/freefire/jiang13-bbs/model"
webpenc "github.com/gen2brain/webp"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newBlobTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&model.Blob{}, &model.Attachment{}, &model.PostAttachment{},
&model.User{}, &model.Post{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func webpFixtureBytes() []byte {
src := image.NewRGBA(image.Rect(0, 0, 20, 10))
src.Set(0, 0, color.RGBA{10, 20, 30, 255})
var buf bytes.Buffer
_ = webpenc.Encode(&buf, src, webpenc.Options{Quality: ImageWebPQuality})
return buf.Bytes()
}
func TestSaveImageBlobDedupAndRefcount(t *testing.T) {
db := newBlobTestDB(t)
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
data := webpFixtureBytes()
att1, err := s.SaveImage(1, bytes.NewReader(data), "post")
if err != nil {
t.Fatalf("SaveImage 1: %v", err)
}
att2, err := s.SaveImage(2, bytes.NewReader(data), "post")
if err != nil {
t.Fatalf("SaveImage 2: %v", err)
}
if att1.URL != att2.URL || att1.BlobHash == "" || att1.BlobHash != att2.BlobHash {
t.Fatalf("identical content should share url/hash: %q vs %q", att1.URL, att2.URL)
}
var blob model.Blob
if err := db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true).Error; err != nil {
t.Fatalf("load blob: %v", err)
}
if blob.RefCount != 2 {
t.Fatalf("refcount want 2, got %d", blob.RefCount)
}
blobPath := filepath.Join(dir, filepath.FromSlash(att1.URL[len("/uploads/"):]))
if fi, err := os.Stat(blobPath); err != nil || fi.IsDir() {
t.Fatalf("shared physical file missing: %v", err)
}
// 删一个引用:文件保留
if err := s.DeleteAttachment(1, att1.ID); err != nil {
t.Fatalf("DeleteAttachment 1: %v", err)
}
db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true)
if blob.RefCount != 1 {
t.Fatalf("refcount want 1, got %d", blob.RefCount)
}
if _, err := os.Stat(blobPath); err != nil {
t.Fatalf("blob file should survive while referenced: %v", err)
}
// 最后一个引用删除:文件与 blob 行一起回收
if err := s.DeleteAttachment(2, att2.ID); err != nil {
t.Fatalf("DeleteAttachment 2: %v", err)
}
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
t.Fatalf("blob file should be removed after last release, stat err=%v", err)
}
var n int64
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att1.BlobHash, true).Count(&n)
if n != 0 {
t.Fatalf("blob row should be gone, count=%d", n)
}
}
func TestSaveImageTranscodedJPEGDedup(t *testing.T) {
db := newBlobTestDB(t)
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
src := image.NewRGBA(image.Rect(0, 0, 48, 36))
src.Set(1, 2, color.RGBA{200, 100, 50, 255})
var in bytes.Buffer
if err := jpeg.Encode(&in, src, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
att1, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage 1: %v", err)
}
att2, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
if err != nil {
t.Fatalf("SaveImage 2: %v", err)
}
// hash 必须对转码后的字节计算:同源 JPEG 两次上传应命中同一个 WebP blob
if att1.URL != att2.URL || att1.BlobHash != att2.BlobHash {
t.Fatalf("transcoded JPEG should dedup: %q(%s) vs %q(%s)", att1.URL, att1.BlobHash, att2.URL, att2.BlobHash)
}
}
func TestSaveDraftFileBlobDedupAndRefcount(t *testing.T) {
db := newBlobTestDB(t)
root := t.TempDir()
s := NewPostFileService(db, root)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
data := []byte("paid attachment content v1")
a1, err := s.SaveDraftFile(1, "report.pdf", bytes.NewReader(data), 50)
if err != nil {
t.Fatalf("SaveDraftFile 1: %v", err)
}
a2, err := s.SaveDraftFile(2, "another-name.pdf", bytes.NewReader(data), 0)
if err != nil {
t.Fatalf("SaveDraftFile 2: %v", err)
}
if a1.BlobHash == "" || a1.BlobHash != a2.BlobHash || a1.StoredName != a2.StoredName {
t.Fatalf("same bytes should share blob: %+v vs %+v", a1, a2)
}
// 原始文件名仍各自保留
if a1.Name != "report.pdf" || a2.Name != "another-name.pdf" {
t.Fatalf("original names not preserved: %q / %q", a1.Name, a2.Name)
}
blobPath := s.FilePath(a1)
if _, err := os.Stat(blobPath); err != nil {
t.Fatalf("shared private blob missing: %v", err)
}
var blob model.Blob
if err := db.First(&blob, "hash = ? AND public = ?", a1.BlobHash, false).Error; err != nil {
t.Fatalf("load private blob: %v", err)
}
if blob.RefCount != 2 {
t.Fatalf("refcount want 2, got %d", blob.RefCount)
}
if err := s.DeleteOwn(1, a1.ID); err != nil {
t.Fatalf("DeleteOwn 1: %v", err)
}
if _, err := os.Stat(blobPath); err != nil {
t.Fatalf("private blob should survive with one ref: %v", err)
}
if err := s.DeleteOwn(2, a2.ID); err != nil {
t.Fatalf("DeleteOwn 2: %v", err)
}
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
t.Fatalf("private blob should be removed after last release, stat err=%v", err)
}
}
func TestBlobPublicPrivateVisibilityIsolation(t *testing.T) {
db := newBlobTestDB(t)
root := t.TempDir()
img := NewUploadService(db, filepath.Join(root, "uploads"))
pf := NewPostFileService(db, filepath.Join(root, "private"))
if err := img.EnsureDir(); err != nil {
t.Fatalf("ensure upload dir: %v", err)
}
if err := pf.EnsureDir(); err != nil {
t.Fatalf("ensure private dir: %v", err)
}
data := webpFixtureBytes()
att, err := img.SaveImage(1, bytes.NewReader(data), "post")
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
file, err := pf.SaveDraftFile(1, "x.webp", bytes.NewReader(data), 0)
if err != nil {
t.Fatalf("SaveDraftFile: %v", err)
}
if att.BlobHash != file.BlobHash {
t.Fatalf("test premise: same bytes should produce same hash")
}
// 同字节在公开/私有命名空间各持一条 blob,互不复用,防止越权读取
var pubs, privs int64
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, true).Count(&pubs)
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, false).Count(&privs)
if pubs != 1 || privs != 1 {
t.Fatalf("want separate public/private blobs, pubs=%d privs=%d", pubs, privs)
}
publicPath := filepath.Join(root, "uploads", filepath.FromSlash(att.URL[len("/uploads/"):]))
privatePath := pf.FilePath(file)
if publicPath == privatePath {
t.Fatal("public and private blob must not share a path")
}
if _, err := os.Stat(publicPath); err != nil {
t.Fatalf("public blob missing: %v", err)
}
if _, err := os.Stat(privatePath); err != nil {
t.Fatalf("private blob missing: %v", err)
}
}

View File

@@ -0,0 +1,132 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 内容寻址存储(content-addressed storage):
// 物理文件按【最终存储字节】的 SHA-256 命名,业务行只引用 hash 并各持一份引用计数;
// 同内容重复上传不再重复落盘/传输,最后一个引用删除时才回收物理文件。
// 公开与私有是独立命名空间(见 model.Blob 注释)。
// sha256Hex 计算字节的 SHA-256 hex
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
// hashFile 计算文件内容的 SHA-256 hex,整文件流式读取不进内存
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// acquireBlobRef 登记一次 blob 引用:行不存在则创建(计数=1),已存在则计数+1。
// 并发下靠 (hash, public) 主键冲突重试,保证不丢计数。
func acquireBlobRef(db *gorm.DB, hash string, public bool, size int, mimeType, objectID string) error {
for {
var b model.Blob
err := db.Where("hash = ? AND public = ?", hash, public).First(&b).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
b = model.Blob{
Hash: hash,
Public: public,
RefCount: 1,
Size: size,
MIME: mimeType,
ObjectID: objectID,
}
if err := db.Create(&b).Error; err != nil {
if isUniqueConflict(err) {
continue // 并发首建:改为走计数+1
}
return err
}
return nil
}
if err != nil {
return err
}
return db.Model(&model.Blob{}).
Where("hash = ? AND public = ?", hash, public).
UpdateColumn("ref_count", gorm.Expr("ref_count + 1")).Error
}
}
// releaseBlobRef 释放一次 blob 引用。计数归零时删行并返回 last=true 与该行快照
// (快照里的 ObjectID/Hash 供调用方回收物理对象)。行不存在视为幂等成功。
func releaseBlobRef(db *gorm.DB, hash string, public bool) (model.Blob, bool, error) {
var removed model.Blob
var last bool
err := db.Transaction(func(tx *gorm.DB) error {
var b model.Blob
// 行锁:postgres 下 FOR UPDATE 串行化同 blob 的并发释放;sqlite 写事务天然串行
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&b, "hash = ? AND public = ?", hash, public).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if b.RefCount <= 1 {
result := tx.Where("hash = ? AND public = ? AND ref_count <= 1", hash, public).Delete(&model.Blob{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
removed = b
last = true
}
return nil
}
return tx.Model(&model.Blob{}).
Where("hash = ? AND public = ?", hash, public).
UpdateColumn("ref_count", b.RefCount-1).Error
})
if err != nil {
return model.Blob{}, false, err
}
return removed, last, nil
}
// placeLocalBlob 把临时文件原子放到内容寻址目标路径:
// 目标已存在说明同内容已落盘(字节必然一致),直接丢弃源文件;否则整体 rename。
// src 与 dst 同一路径时不做操作。
func placeLocalBlob(src, dst string) error {
if abs, err := filepath.Abs(src); err == nil {
if dstAbs, err := filepath.Abs(dst); err == nil && abs == dstAbs {
return nil
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if _, err := os.Stat(dst); err == nil {
if src != dst {
_ = os.Remove(src)
}
return nil
}
if err := os.Rename(src, dst); err != nil {
_ = os.Remove(src)
return err
}
return nil
}

View File

@@ -9,16 +9,17 @@ import (
// 站点品牌与页脚(公开设置,缺省为空)
const (
SettingKeySiteWordmark = "site_wordmark"
SettingKeySiteSlogan = "site_slogan"
SettingKeySiteKeywords = "site_keywords"
SettingKeyLogoLightURL = "logo_light_url"
SettingKeyLogoDarkURL = "logo_dark_url"
SettingKeyFaviconURL = "favicon_url"
SettingKeyFooterLinks = "footer_links"
SettingKeyBrandMark = "brand_mark"
SettingKeyBrandLogoSize = "brand_logo_size"
SettingKeyBrandLogoFit = "brand_logo_fit"
SettingKeySiteWordmark = "site_wordmark"
SettingKeySiteSlogan = "site_slogan"
SettingKeySiteKeywords = "site_keywords"
SettingKeyLogoLightURL = "logo_light_url"
SettingKeyLogoDarkURL = "logo_dark_url"
SettingKeyFaviconURL = "favicon_url"
SettingKeyFooterLinks = "footer_links"
SettingKeyBrandMark = "brand_mark"
SettingKeyBrandLogoSize = "brand_logo_size"
SettingKeyBrandLogoFit = "brand_logo_fit"
SettingKeyBrandSloganVisible = "brand_slogan_visible"
BrandSlotLight = "logo_light"
BrandSlotDark = "logo_dark"
@@ -43,6 +44,8 @@ const (
BrandLogoSize3x1 = "3x1"
BrandLogoSize4x1 = "4x1"
BrandLogoSize169 = "16x9"
// BrandLogoSizeAuto 原图比例:自由裁剪,页眉图槽按图片原始比例自适应宽度
BrandLogoSizeAuto = "auto"
BrandLogoFitCover = "cover"
BrandLogoFitContain = "contain"
@@ -114,7 +117,7 @@ func NormalizeBrandLogoSize(raw string) (string, bool) {
switch strings.TrimSpace(raw) {
case "", BrandLogoSizeSQ:
return BrandLogoSizeSQ, true
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169:
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169, BrandLogoSizeAuto:
return strings.TrimSpace(raw), true
default:
return "", false
@@ -367,6 +370,15 @@ func (s *SettingService) fillBrand(out *PublicSiteSettings) error {
out.BrandLogoFit = f
}
}
sv, found, err := s.getValue(SettingKeyBrandSloganVisible)
if err != nil {
return err
}
if found {
out.BrandSloganVisible = sv == "true"
} else {
out.BrandSloganVisible = true
}
rawLinks, found, err := s.getValue(SettingKeyFooterLinks)
if err != nil {
return err
@@ -483,6 +495,14 @@ func (s *SettingService) SetBrandLogoFit(raw string) error {
return s.putValue(SettingKeyBrandLogoFit, v)
}
// SetBrandSloganVisible 控制标语是否在页眉显示;true 为缺省值,存库时删 key 以保持精简。
func (s *SettingService) SetBrandSloganVisible(visible bool) error {
if visible {
return s.deleteKey(SettingKeyBrandSloganVisible)
}
return s.putValue(SettingKeyBrandSloganVisible, "false")
}
func (s *SettingService) setBrandURL(key, slot, url string) error {
u, ok := NormalizeBrandURL(slot, url)
if !ok {

View File

@@ -42,11 +42,6 @@ var (
ErrChatAdminAccess = errors.New("无权管理消息")
)
// CanOverseeChat 站长/超管可旁路查看全部群(含私聊)并撤回任意消息
func CanOverseeChat(role model.Role) bool {
return role == model.RoleOwner || role == model.RoleSuperAdmin
}
const (
chatNameMinRune = 2
chatNameMaxRune = 32
@@ -767,6 +762,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:系统提示不作为会话预览,
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。
@@ -917,7 +924,7 @@ func (s *ChatService) isChatRoomModerator(userID, roomID uint) bool {
return m.Role == model.ChatRoleOwner || m.Role == model.ChatRoleAdmin
}
// hasSiteGroupMessagePerm 站点消息管理 flag(不含超管/站长,调用方通常已判 oversee)
// hasSiteGroupMessagePerm 站点消息管理 flag(不含站长,超管经 oversee 已放行;调用方通常已判 oversee)
func (s *ChatService) hasSiteGroupMessagePerm(userID uint) bool {
var u model.User
if err := s.db.Select("can_manage_messages").First(&u, userID).Error; err != nil {
@@ -1207,7 +1214,7 @@ type AdminChatMessageItem struct {
}
// AdminListMessages 按房间类型分页;scope 限制可见房间;非全站监管脱敏已撤回正文。
// actor 为站长/超管:全站;HasSiteMessagePerm:全部群聊(无私聊);否则仅群管房间。
// actor 为站长或已授权超管:全站;HasSiteMessagePerm:全部群聊(无私聊);否则仅群管房间。
func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomType string) ([]AdminChatMessageItem, int64, error) {
if actor == nil || !s.CanAccessAdminMessages(actor) {
return nil, 0, ErrChatAdminAccess
@@ -1215,7 +1222,7 @@ func (s *ChatService) AdminListMessages(actor *Actor, page int, keyword, roomTyp
if page < 1 {
page = 1
}
fullOversee := CanOverseeChat(actor.Role)
fullOversee := actor.CanOverseeChat()
siteGroup := actor.HasSiteMessagePerm() && !fullOversee
if roomType != model.ChatRoomTypeGroup && roomType != model.ChatRoomTypeDirect {
return nil, 0, errors.New("房间类型无效")

View File

@@ -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 顶部导航菜单项:
//

View File

@@ -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)
}
// 空数组 = 全部隐藏

View File

@@ -226,6 +226,14 @@ type legacyPostRow struct {
func (legacyPostRow) TableName() string { return "posts" }
// legacyPinLevel 旧库 pinned(0/1,非零=全站置顶)映射到新置顶分级:板块置顶为本站新能力,旧数据无此语义
func legacyPinLevel(v int) int {
if v > 0 {
return model.PinGlobal
}
return model.PinNone
}
type legacyCommentRow struct {
ID uint `gorm:"primaryKey"`
PostID uint `gorm:"column:post_id"`
@@ -757,7 +765,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
Content: content,
Tags: p.Tags,
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
Pinned: p.Pinned,
Pinned: legacyPinLevel(p.Pinned),
Recommended: p.Featured,
Status: model.ContentStatusPublished,
LikeCount: p.LikeCount,

View File

@@ -64,6 +64,7 @@ type MediaLibraryItem struct {
Height int `json:"height"`
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
RefCount int `json:"ref_count,omitempty"` // 引用计数(>1 表示内容相同的重复上传共享此文件)
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
}
@@ -91,13 +92,29 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
Scan(&rows).Error; err != nil {
return nil, nil, err
}
metaByURL := make(map[string]attMeta, len(rows))
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
items := make([]MediaLibraryItem, 0, len(rows))
// 同 URL 多条记录(内容寻址去重后,重复上传共享同一文件):
// 上传者/时间取最早一条,计数即该文件的引用次数
type urlMeta struct {
meta attMeta
count int
}
agg := make(map[string]*urlMeta, len(rows))
for _, r := range rows {
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
if !strings.HasPrefix(r.URL, "/api/media/") {
continue
if a, ok := agg[r.URL]; ok {
a.count++
if r.CreatedAt.Before(a.meta.CreatedAt) {
a.meta = r
}
} else {
agg[r.URL] = &urlMeta{meta: r, count: 1}
}
}
// 远程存储对象(/api/media/)不在磁盘上,按 URL 去重后直接作为条目加入
items := make([]MediaLibraryItem, 0, len(agg))
for url, a := range agg {
r := a.meta
if !strings.HasPrefix(url, "/api/media/") {
continue // 本地文件在磁盘扫描时按 URL 合并元数据
}
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
continue // 未知 kind 不进媒体库
@@ -109,10 +126,10 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
ca := r.CreatedAt
items = append(items, MediaLibraryItem{
URL: r.URL, Category: catKey, CategoryName: catName,
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
URL: url, Category: catKey, CategoryName: catName,
Name: strings.TrimPrefix(url, "/api/media/"), MIME: r.MIME,
Size: int64(r.Size), Width: r.Width, Height: r.Height,
Uploader: uploader, UploadedAt: &ca,
Uploader: uploader, UploadedAt: &ca, RefCount: a.count,
})
}
@@ -152,8 +169,9 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
mt := info.ModTime()
item.UploadedAt = &mt
}
if m, ok := metaByURL[url]; ok {
if a, ok := agg[url]; ok {
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
m := a.meta
uploader := m.Nickname
if uploader == "" {
uploader = m.Username
@@ -161,6 +179,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
ca := m.CreatedAt
item.Uploader = uploader
item.UploadedAt = &ca
item.RefCount = a.count
if m.Size > 0 {
item.Size = int64(m.Size)
}

View File

@@ -25,7 +25,7 @@ func newMediaLibraryService(t *testing.T) (*UploadService, string) {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Attachment{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
@@ -315,6 +315,82 @@ func TestAdminMediaLibraryBookCategory(t *testing.T) {
}
}
func TestAdminMediaLibraryDedupAggregation(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "images/dup.webp", []byte("fake-webp-bytes"))
mkUser := func(username, nickname string) model.User {
if err := s.db.Create(&model.User{Username: username, Nickname: nickname}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
var u model.User
if err := s.db.Where("username = ?", username).First(&u).Error; err != nil {
t.Fatalf("load user: %v", err)
}
return u
}
alice := mkUser("alice", "爱丽丝")
bob := mkUser("bob", "阿Bob")
early := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
late := early.Add(2 * time.Hour)
// 同一张图被两人先后上传(内容寻址去重后共享同一文件/对象)
mkAtt := func(u model.User, url string, at time.Time) {
if err := s.db.Create(&model.Attachment{
UserID: u.ID, Kind: model.AttachmentKindImage,
URL: url, MIME: "image/webp",
Size: 100, Width: 10, Height: 10, CreatedAt: at,
}).Error; err != nil {
t.Fatalf("create attachment %s: %v", url, err)
}
}
mkAtt(alice, "/uploads/images/dup.webp", early)
mkAtt(bob, "/uploads/images/dup.webp", late)
mkAtt(bob, "/api/media/rdup", early)
mkAtt(alice, "/api/media/rdup", late)
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
countByURL := map[string]int{}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
countByURL[it.URL]++
byURL[it.URL] = it
}
// 本地去重文件:单个条目,上传者/时间取最早上传的爱丽丝,引用计数 2
local, ok := byURL["/uploads/images/dup.webp"]
if !ok {
t.Fatal("dedup local file missing")
}
if countByURL["/uploads/images/dup.webp"] != 1 {
t.Fatal("dedup local file should appear exactly once")
}
if local.Uploader != "爱丽丝" || local.RefCount != 2 {
t.Fatalf("local dedup meta wrong: %+v", local)
}
if local.UploadedAt == nil || !local.UploadedAt.Equal(early) {
t.Fatalf("local dedup time should be earliest upload, got %v", local.UploadedAt)
}
// 远程对象同 URL 两条记录:也只出一个条目,取最早的 bob
remote, ok := byURL["/api/media/rdup"]
if !ok {
t.Fatal("dedup remote object missing")
}
if countByURL["/api/media/rdup"] != 1 {
t.Fatal("dedup remote object should appear exactly once")
}
if remote.Uploader != "阿Bob" || remote.RefCount != 2 {
t.Fatalf("remote dedup meta wrong: %+v", remote)
}
}
func TestMediaThumb(t *testing.T) {
s, dir := newMediaLibraryService(t)

View File

@@ -368,7 +368,7 @@ func (o *Operations) validate(db *gorm.DB, name string, v any) error {
return e
}
origin, originErr := url.Parse(o.cfg.SiteURL)
if originErr != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || strings.Trim(origin.Path, "/") != "" || (origin.Scheme != "https" && !(o.cfg.DevMode && origin.Scheme == "http")) {
if originErr != nil || origin.Host == "" || origin.User != nil || origin.RawQuery != "" || origin.Fragment != "" || strings.Trim(origin.Path, "/") != "" || (origin.Scheme != "https" && o.cfg.IsSecureOrigin()) {
return bad("verify_email", "部署 SITE_URL 后才能开启账号邮件")
}
}
@@ -430,8 +430,8 @@ func (o *Operations) validate(db *gorm.DB, name string, v any) error {
}
if c.Backend == "s3" {
u, e := url.Parse(c.Endpoint)
if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" || (u.Scheme != "https" && !(o.cfg.DevMode && u.Scheme == "http")) {
return bad("endpoint", "须为 HTTPS origin(开发可 HTTP)")
if e != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.Trim(u.Path, "/") != "" || (u.Scheme != "https" && o.cfg.IsSecureOrigin()) {
return bad("endpoint", "须为 HTTPS origin(HTTP origin 部署可放宽)")
}
if !regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`).MatchString(c.Bucket) || strings.Contains(c.Bucket, "..") {
return bad("bucket", "桶名称无效")

View File

@@ -24,6 +24,9 @@ import (
type mailPayload struct{ To, Subject, Body string }
// ErrEmailRegistered 注册发码时邮箱已被占用,handler 据此返回 400 业务提示
var ErrEmailRegistered = errors.New("该邮箱已被注册,请直接登录")
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
password, e := o.open(c.Password, "mail:password")
if e != nil {
@@ -392,6 +395,16 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
return 0, nil
}
}
if purpose == "register" {
// 入口拦截:邮箱已被占用则不发码,省一次邮件资源,也给用户明确提示
var n int64
if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil {
return 0, e
}
if n > 0 {
return 0, ErrEmailRegistered
}
}
code := randomCode()
hash := counterKey(email + ":" + purpose + ":" + code)
e = o.db.Transaction(func(tx *gorm.DB) error {

View File

@@ -16,6 +16,8 @@ import (
"path/filepath"
"strings"
"time"
"gorm.io/gorm"
)
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
@@ -137,6 +139,65 @@ func (o *Operations) StoreFile(path, mimeType string, public bool) (string, erro
}
return id, nil
}
// blobObjectID 内容寻址对象的稳定 ID:公开/私有加不同前缀,同字节也不共享可见性。
func blobObjectID(hash string, public bool) string {
if public {
return "pub_" + hash
}
return "prv_" + hash
}
// StoreFileHashed 按内容 hash 幂等上传:同一 (hash, public) 只存一份,重复上传跳过传输。
// 本地后端返回空 ID(文件留在本地 blob 目录,由调用方做引用计数)。
func (o *Operations) StoreFileHashed(path, mimeType string, public bool, hash string) (string, error) {
v, version, e := o.read(o.db, "storage")
if e != nil {
return "", errors.New("读取存储配置失败")
}
c := *v.(*StorageConfig)
if c.Backend == "local" {
return "", nil
}
id := blobObjectID(hash, public)
// hash 相同字节必然相同:对象已存在时直接复用,省掉整次上传
var existing model.StoredObject
if e := o.db.First(&existing, "id = ?", id).Error; e == nil {
return id, nil
} else if !errors.Is(e, gorm.ErrRecordNotFound) {
return "", e
}
client, e := o.s3(c)
if e != nil {
return "", e
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
f, e := os.Open(path)
if e != nil {
return "", e
}
stat, e := f.Stat()
if e != nil {
_ = f.Close()
return "", e
}
key := c.Prefix + "objects/" + id
if _, e = client.PutObject(ctx, c.Bucket, key, f, stat.Size(), minio.PutObjectOptions{ContentType: mimeType, DisableMultipart: true}); e != nil {
_ = f.Close()
return "", errors.New("上传到 S3 失败,当前目标未自动切换")
}
_ = f.Close()
obj := model.StoredObject{ID: id, ConfigName: fmt.Sprintf("storage-%d", version), Key: key, MIME: mimeType, Public: public}
if e = o.db.Create(&obj).Error; e != nil {
if isUniqueConflict(e) { // 并发:另一请求已写入同内容对象,直接复用
return id, nil
}
_ = client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
return "", errors.New("保存文件记录失败")
}
return id, nil
}
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
var obj model.StoredObject
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {

View File

@@ -19,6 +19,8 @@ var (
ErrPostInvalidType = errors.New("无效的删除类型")
ErrPostLocked = errors.New("帖子已被锁定,无法编辑")
ErrPostEditLocked = errors.New("帖子已超过可编辑时限,无法编辑")
ErrPinGlobalOnly = errors.New("全站置顶仅管理员可操作")
ErrPinInvalidLevel = errors.New("无效的置顶级别")
)
// editLocked 内容是否已被自动编辑锁定;管理团队豁免;hours<=0 表示不启用
@@ -247,18 +249,25 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
var posts []model.Post
offset := (q.Page - 1) * q.Size
// 置顶排序前缀按范围区分:板块视图里全站置顶(2)在板块置顶(1)之上;
// 全站信息流只让全站置顶(2)浮顶,板块置顶(1)不影响首页与跨板列表。
// 同层级置顶帖之间按后台配置的 pin_order 升序(pin_order=0 视为未配置,排在已配置的后面,再按活跃序兜底)。
pinFirst := "(pinned >= 2) DESC, (pin_order = 0), pin_order ASC"
if q.BoardID > 0 {
pinFirst = "pinned DESC, (pin_order = 0), pin_order ASC"
}
// latest:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。
// 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引
order := `pinned DESC, GREATEST(created_at, COALESCE((
order := pinFirst + `, GREATEST(created_at, COALESCE((
SELECT MAX(c.created_at) FROM comments c
WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL
), created_at)) DESC`
if q.Sort == "recommended" {
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
order = pinFirst + ", (like_count + comment_count * 2) DESC, created_at DESC"
}
// new:纯按发帖时间(新帖子)
if q.Sort == "new" {
order = "pinned DESC, created_at DESC"
order = pinFirst + ", created_at DESC"
}
if err := query.Order(order).Offset(offset).Limit(q.Size).
Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil {
@@ -311,7 +320,7 @@ func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size
}
var posts []model.Post
offset := (page - 1) * size
order := "pinned DESC, created_at DESC"
order := "(pinned >= 2) DESC, (pin_order = 0), pin_order ASC, created_at DESC"
if sort == "hot" {
order = "like_count + comment_count DESC, view_count DESC, created_at DESC"
}
@@ -349,24 +358,36 @@ func (s *PostService) CountByUser(userID uint) (int64, error) {
return total, err
}
// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限)
func (s *PostService) TogglePin(id uint) (int, error) {
// TogglePin 设置置顶级别:level 0=取消置顶,1=板块置顶,2=全站置顶。
// 板块管理员只能操作自己授权板块且只能设置/取消板块置顶;管理员可设置任意级别。
func (s *PostService) TogglePin(id uint, actor *Actor, level int) (int, error) {
if level < model.PinNone || level > model.PinGlobal {
return 0, ErrPinInvalidLevel
}
var post model.Post
if err := s.db.First(&post, id).Error; err != nil {
if err := s.db.Select("id", "board_id", "pinned").First(&post, id).Error; err != nil {
return 0, err
}
var newPinned int
if post.Pinned > 0 {
newPinned = 0
} else {
newPinned = 1
isGlobalAdmin := actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)
isBoardModerator := actor != nil && actor.CanModerateBoard(post.BoardID)
// 权限校验
if level == model.PinGlobal && !isGlobalAdmin {
return 0, ErrPinGlobalOnly
}
if level > model.PinNone && !isGlobalAdmin && !isBoardModerator {
return 0, ErrPostForbidden
}
// 板块管理员不能取消全局置顶(只能取消自己设置的板块置顶)
if !isGlobalAdmin && level == model.PinNone && post.Pinned == model.PinGlobal {
return 0, ErrPinGlobalOnly
}
// UpdateColumn:置顶非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("pinned", newPinned)
if result.Error != nil {
return 0, result.Error
if err := s.db.Model(&post).UpdateColumn("pinned", level).Error; err != nil {
return 0, err
}
return newPinned, nil
return level, nil
}
// ToggleLock 切换帖子手动锁定状态(仅管理员可操作,由 handler 校验权限)。

View File

@@ -2,10 +2,12 @@ package service
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"unicode/utf8"
@@ -145,8 +147,9 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
if err != nil {
return nil, err
}
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」;落盘同时计算内容 hash
hasher := sha256.New()
written, copyErr := io.Copy(io.MultiWriter(f, hasher), io.LimitReader(src, maxB+1))
_ = f.Close()
if copyErr != nil {
_ = os.Remove(tmp)
@@ -167,28 +170,45 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
return nil, err
}
att := &model.PostAttachment{
PostID: 0,
UserID: userID,
Name: name,
StoredName: stored,
MIME: mimeType,
Size: int(written),
PricePoints: pricePoints,
}
// 内容寻址:私有 blob 以 hash 命名(不带扩展名,下载时以 att.MIME + 原文件名下发)
hash := hex.EncodeToString(hasher.Sum(nil))
final := absPath(s.dir, hash)
var objectID string
if s.ops != nil {
id, e := s.ops.StoreFile(full, att.MIME, false)
id, e := s.ops.StoreFileHashed(full, mimeType, false, hash)
if e != nil {
_ = os.Remove(full)
return nil, e
}
att.ObjectID = id
objectID = id
if id != "" {
_ = os.Remove(full)
}
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(full)
if objectID == "" {
if e := placeLocalBlob(full, final); e != nil {
return nil, e
}
}
att := &model.PostAttachment{
PostID: 0,
UserID: userID,
Name: name,
StoredName: hash,
BlobHash: hash,
ObjectID: objectID,
MIME: mimeType,
Size: int(written),
PricePoints: pricePoints,
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if e := acquireBlobRef(tx, hash, false, int(written), mimeType, objectID); e != nil {
return e
}
return tx.Create(att).Error
}); err != nil {
s.releasePostBlobBestEffort(hash)
return nil, err
}
return att, nil
@@ -283,14 +303,51 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
if att.UserID != userID {
return ErrAttachmentForbidden
}
path := absPath(s.dir, att.StoredName)
if err := s.db.Delete(&att).Error; err != nil {
return err
}
if att.BlobHash != "" {
return s.releasePostBlob(&att)
}
if s.ops != nil && att.ObjectID != "" {
return s.ops.RemoveObject(att.ObjectID)
}
_ = os.Remove(path)
_ = os.Remove(absPath(s.dir, att.StoredName))
return nil
}
// releasePostBlob 私有附件 blob 引用计数-1;最后一个引用删除时回收物理文件
func (s *PostFileService) releasePostBlob(att *model.PostAttachment) error {
b, last, err := releaseBlobRef(s.db, att.BlobHash, false)
if err != nil || !last {
return err
}
return s.removePrivateBlobObject(b)
}
// releasePostBlobBestEffort 附件记录落库失败时回滚刚加的引用,错误只记日志
func (s *PostFileService) releasePostBlobBestEffort(hash string) {
b, last, err := releaseBlobRef(s.db, hash, false)
if err != nil {
log.Printf("[post-file] 回滚 blob 引用失败 hash=%s: %v", hash, err)
return
}
if !last {
return
}
if err := s.removePrivateBlobObject(b); err != nil {
log.Printf("[post-file] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
}
}
// removePrivateBlobObject 引用归零后的物理回收(私有 blob 文件名即 hash,无扩展名)
func (s *PostFileService) removePrivateBlobObject(b model.Blob) error {
if b.ObjectID != "" && s.ops != nil {
return s.ops.RemoveObject(b.ObjectID)
}
if err := os.Remove(absPath(s.dir, b.Hash)); err != nil && !os.IsNotExist(err) {
log.Printf("[post-file] 删除 blob 文件失败 hash=%s: %v", b.Hash, err)
}
return nil
}

View File

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

View File

@@ -124,15 +124,18 @@ const (
DefaultUrlStyle = "default"
// MaxMobileTabs 底栏配置元素上限(与白名单等长)
MaxMobileTabs = 5
MaxMobileTabs = 6
)
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是默认顺序,与前端 lib/mobileTabs.ts 保持一致)
var MobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
// MobileTabKeys 移动端底栏允许的 tab key 白名单(同时是管理面板排序基准,与前端 lib/mobileTabs.ts 保持一致)
var MobileTabKeys = []string{"home", "library", "snippets", "compose", "messages", "me"}
// DefaultMobileTabs 底栏默认全量配置
// DefaultMobileTabKeys 底栏缺省启用集合(白名单子集:snippets 默认关闭,需管理员手动开启)
var DefaultMobileTabKeys = []string{"home", "library", "compose", "messages", "me"}
// DefaultMobileTabs 底栏默认启用配置
func DefaultMobileTabs() []string {
return append([]string(nil), MobileTabKeys...)
return append([]string(nil), DefaultMobileTabKeys...)
}
// DefaultAttachmentExts 论坛向默认允许扩展名(含安装包/脚本/网页)
@@ -202,6 +205,7 @@ type PublicSiteSettings struct {
BrandMark string `json:"brand_mark"`
BrandLogoSize string `json:"brand_logo_size"`
BrandLogoFit string `json:"brand_logo_fit"`
BrandSloganVisible bool `json:"brand_slogan_visible"`
FooterLinks []FooterLink `json:"footer_links"`
// 积分增长规则(经济规则公开透明,签到卡等可直出)
@@ -644,7 +648,7 @@ func (s *SettingService) SetMobileTabs(tabs []string) error {
if !ok || len(out) == 0 {
return ErrInvalidSiteSetting
}
if mobileTabsEqual(out, MobileTabKeys) {
if mobileTabsEqual(out, DefaultMobileTabKeys) {
return s.deleteKey(SettingKeyMobileTabs)
}
b, err := json.Marshal(out)

1022
backend/service/snippet.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -113,46 +113,57 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
return nil, errors.New("头像尺寸需在 64~512px 之间")
}
nameBytes := make([]byte, 16)
if _, err := rand.Read(nameBytes); err != nil {
return nil, err
}
filename := hex.EncodeToString(nameBytes) + ".webp"
hash := sha256Hex(data)
filename := hash + ".webp"
fullPath := filepath.Join(s.dir, "avatars", filename)
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
return nil, err
url := "/uploads/avatars/" + filename
var objectID string
writeLocal := func() error {
if _, err := os.Stat(fullPath); err == nil {
return nil // 同内容头像已落盘,字节必然一致,无需重写
}
return os.WriteFile(fullPath, data, 0o644)
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindAvatar,
URL: "/uploads/avatars/" + filename,
MIME: "image/webp",
Size: len(data),
Width: cfg.Width,
Height: cfg.Height,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
if err := writeLocal(); err != nil {
return nil, err
}
id, e := s.ops.StoreFileHashed(fullPath, "image/webp", true, hash)
if e != nil {
_ = os.Remove(fullPath)
return nil, e
}
objectID = id
if id != "" {
att.URL = "/api/media/" + id
url = "/api/media/" + id
_ = os.Remove(fullPath)
}
} else if err := writeLocal(); err != nil {
return nil, err
}
// 附件记录与用户头像更新在同一事务内完成
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindAvatar,
URL: url,
BlobHash: hash,
MIME: "image/webp",
Size: len(data),
Width: cfg.Width,
Height: cfg.Height,
}
// 附件记录、blob 引用与用户头像更新在同一事务内完成
err = s.db.Transaction(func(tx *gorm.DB) error {
if err := acquireBlobRef(tx, hash, true, len(data), "image/webp", objectID); err != nil {
return err
}
if err := tx.Create(att).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
})
if err != nil {
_ = os.Remove(fullPath) // DB 失败时清理已落盘文件
s.releaseBlobBestEffort(hash, true, "/uploads/avatars/"+filename)
return nil, err
}
return att, nil
@@ -351,29 +362,51 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader, source string) (*m
}
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindImage,
Source: normalizeImageSource(source),
URL: "/uploads/images/" + storeName,
MIME: storeMime,
Size: int(storeSize),
Width: w,
Height: h,
// 对最终存储字节做内容寻址(JPEG/PNG 已在上面转成 WebP,同图重复上传才能命中)
hash, err := hashFile(storePath)
if err != nil {
_ = os.Remove(storePath)
return nil, err
}
finalName := hash + strings.ToLower(filepath.Ext(storeName))
url := "/uploads/images/" + finalName
var objectID string
if s.ops != nil {
id, e := s.ops.StoreFile(storePath, att.MIME, true)
id, e := s.ops.StoreFileHashed(storePath, storeMime, true, hash)
if e != nil {
_ = os.Remove(storePath)
return nil, e
}
objectID = id
if id != "" {
att.URL = "/api/media/" + id
// 远程后端:以对象 ID 为访问地址,本地临时副本回收
url = "/api/media/" + id
_ = os.Remove(storePath)
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
return nil, e
}
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
return nil, e
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(storePath)
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindImage,
Source: normalizeImageSource(source),
URL: url,
BlobHash: hash,
MIME: storeMime,
Size: int(storeSize),
Width: w,
Height: h,
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := acquireBlobRef(tx, hash, true, int(storeSize), storeMime, objectID); err != nil {
return err
}
return tx.Create(att).Error
}); err != nil {
s.releaseBlobBestEffort(hash, true, url)
return nil, err
}
return att, nil
@@ -722,6 +755,10 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
return err
}
if att.BlobHash != "" {
return s.releaseAttachmentBlob(&att)
}
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
@@ -736,4 +773,47 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
return nil
}
// releaseAttachmentBlob 公开图片 blob 引用计数-1;最后一个引用删除时回收物理文件
func (s *UploadService) releaseAttachmentBlob(att *model.Attachment) error {
b, last, err := releaseBlobRef(s.db, att.BlobHash, true)
if err != nil || !last {
return err
}
return s.removePublicBlobObject(b, att.URL)
}
// releaseBlobBestEffort 业务记录落库失败时回滚刚加的引用,错误只记日志
func (s *UploadService) releaseBlobBestEffort(hash string, public bool, localURL string) {
b, last, err := releaseBlobRef(s.db, hash, public)
if err != nil {
log.Printf("[upload] 回滚 blob 引用失败 hash=%s: %v", hash, err)
return
}
if !last {
return
}
if err := s.removePublicBlobObject(b, localURL); err != nil {
log.Printf("[upload] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
}
}
// removePublicBlobObject 引用归零后的物理回收:远程对象失败需上抛(与历史行为一致),
// 本地文件失败只记日志(下次同内容上传会自愈覆盖)。
func (s *UploadService) removePublicBlobObject(b model.Blob, localURL string) error {
if b.ObjectID != "" && s.ops != nil {
if e := s.ops.RemoveObject(b.ObjectID); e != nil {
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
}
return nil
}
if localURL == "" {
return nil
}
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(localURL, "/uploads/")))
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
log.Printf("[upload] 删除 blob 文件失败 hash=%s path=%s: %v", b.Hash, abs, err)
}
return nil
}
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }

View File

@@ -25,7 +25,7 @@ func newUploadTestService(t *testing.T) (*UploadService, string) {
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Attachment{}); err != nil {
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()

View File

@@ -11,7 +11,7 @@
- **Markdown 渲染**:完整支持 Markdown 语法,包含代码高亮、引用、列表等,技术内容排版美观。
- **楼层化评论**:主评论占楼层、子评论不占楼层,最多 10 层嵌套,讨论层次分明。
- **点赞互动**:帖子点赞带防重复机制,让优质内容被看见。
- **置顶与推荐**:管理员可对帖子进行置顶、推荐,引导社区焦点。
- **置顶与推荐**:管理员可对帖子进行全站置顶、推荐,引导社区焦点;板块管理员可在被授权板块内单独置顶帖子。
### 🔔 实时通知
- 评论、回复、点赞等互动实时推送,未读红点随页面首屏直出。

BIN
docs/images/wechat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
docs/images/zfb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
docs/images/书库.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

BIN
docs/images/首页.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

View File

@@ -138,7 +138,7 @@ function DetailsInner() {
<div className="w-full sm:w-56">
<AdminSearchField
value={path}
onChange={(v) => { setPath(v); }}
onChange={(v) => { setPath(v); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setPath(""); resetPage(); }}
placeholder="路径模糊匹配,回车应用"
@@ -148,7 +148,7 @@ function DetailsInner() {
<div className="w-full sm:w-48">
<AdminSearchField
value={ip}
onChange={setIp}
onChange={(v) => { setIp(v); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setIp(""); resetPage(); }}
placeholder="按 IP 精确筛选"
@@ -158,7 +158,7 @@ function DetailsInner() {
<div className="w-full sm:w-44">
<AdminSearchField
value={bot}
onChange={setBot}
onChange={(v) => { setBot(v); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setBot(""); resetPage(); }}
placeholder="按爬虫名精确筛选"

View File

@@ -179,7 +179,8 @@ export default function MediaLibrary({
<Modal
open={active !== null}
onClose={() => setActive(null)}
title={active?.name ?? ""}
title={<span className="break-all">{active?.name ?? ""}</span>}
ariaLabel={active?.name ?? ""}
description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined}
maxWidthClass="max-w-2xl"
dismissOnOverlay
@@ -218,6 +219,14 @@ export default function MediaLibrary({
["格式", active.mime],
["上传者", active.uploader || "—"],
["时间", formatTime(active.uploaded_at)],
...(active.ref_count && active.ref_count > 1
? [
[
"引用",
`${active.ref_count} 次上传 · 去重节省 ${formatBytes(active.size * (active.ref_count - 1))}`,
],
]
: []),
].map(([k, v]) => (
<div key={k} className="flex items-baseline gap-2 min-w-0">
<dt className="meta shrink-0 w-11 text-right">{k}</dt>

View File

@@ -250,7 +250,7 @@ function useMessageColumn(roomType: MsgKind) {
/** 群聊(含大厅)| 私聊 双栏监管 */
export default function MessagesAdmin({ me }: { me: User }) {
const canOversee = canOverseeChat(me.role);
const canOversee = canOverseeChat(me);
const [mobileKind, setMobileKind] = useState<MsgKind>("group");
const group = useMessageColumn("group");
const direct = useMessageColumn("direct");

View File

@@ -15,7 +15,7 @@ export default async function AdminMessagesPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !canAccessAdminMessages(me.user)) {
return <Forbidden text="消息管理需站长授予权限,或被任命为群管理员。" />;
return <Forbidden text="消息管理需站长授予权限(超管亦需授权),或被任命为群管理员。" />;
}
return <MessagesAdmin me={me.user} />;

View File

@@ -0,0 +1,553 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type DragEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import {
Check,
ChevronDown,
ChevronUp,
FolderOpen,
Globe,
GripVertical,
Loader2,
Pin,
} from "lucide-react";
import {
apiAdminPinnedPosts,
apiAdminSavePinOrder,
type AdminPinnedPost,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import {
AdminEmpty,
AdminIconButton,
AdminPageHeader,
AdminSegmented,
} from "@/components/admin";
interface BoardOpt {
id: number;
name: string;
}
interface PinnedAdminProps {
boards: BoardOpt[];
/** 管理员及以上:可排全站置顶 */
canGlobal: boolean;
}
type Level = 1 | 2;
export default function PinnedAdmin({ boards, canGlobal }: PinnedAdminProps) {
const [level, setLevel] = useState<Level>(canGlobal ? 2 : 1);
const [boardId, setBoardId] = useState<number>(boards[0]?.id ?? 0);
const [posts, setPosts] = useState<AdminPinnedPost[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
/** 拖拽排序:被拖行下标 + 插入位置(0..N,N 表示末尾) */
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const load = useCallback(async (lv: Level, bid: number) => {
setLoading(true);
setError("");
try {
const r = await apiAdminPinnedPosts(lv, lv === 1 ? bid : undefined);
setPosts(r.posts ?? []);
} catch (e) {
setPosts([]);
setError(e instanceof Error ? e.message : "获取置顶列表失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(level, boardId);
}, [level, boardId, load]);
const switchLevel = (lv: Level) => {
if (lv === level) return;
setLevel(lv);
setPosts([]);
setError("");
};
/** 统一保存:乐观更新新顺序,失败回滚并提示 */
const saveOrder = useCallback(
async (next: AdminPinnedPost[], prev: AdminPinnedPost[]) => {
setPosts(next);
setSaving(true);
try {
await apiAdminSavePinOrder(
level,
level === 1 ? boardId : 0,
next.map((p) => p.id)
);
toast("置顶顺序已更新", "ok");
} catch (e) {
setPosts(prev);
toast(e instanceof Error ? e.message : "保存顺序失败");
} finally {
setSaving(false);
}
},
[level, boardId]
);
// 上移/下移按钮与键盘共用
const move = (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= posts.length || saving) return;
const prev = posts;
const next = [...posts];
const tmp = next[idx];
next[idx] = next[target];
next[target] = tmp;
void saveOrder(next, prev);
};
const resetDrag = () => {
setDragIndex(null);
setDropIndex(null);
};
const onRowDragOver = (e: DragEvent<HTMLLIElement>, i: number) => {
if (dragIndex === null) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// 指针过行中线则插到行后,否则插到行前
const rect = e.currentTarget.getBoundingClientRect();
const after = e.clientY > rect.top + rect.height / 2;
setDropIndex(i + (after ? 1 : 0));
};
const onRowDrop = (e: DragEvent<HTMLLIElement>) => {
e.preventDefault();
if (dragIndex === null || dropIndex === null || saving) {
resetDrag();
return;
}
// 落在原位置前后:无变化
if (dropIndex === dragIndex || dropIndex === dragIndex + 1) {
resetDrag();
return;
}
const prev = posts;
const next = [...posts];
const [moved] = next.splice(dragIndex, 1);
next.splice(dropIndex > dragIndex ? dropIndex - 1 : dropIndex, 0, moved);
resetDrag();
void saveOrder(next, prev);
};
/** 插入指示线:仅当落点确实改变位置时显示 */
const showLineAt = (pos: number) =>
dragIndex !== null && dropIndex === pos && pos !== dragIndex && pos !== dragIndex + 1;
const segOptions = [
...(canGlobal
? [{ key: "2", label: "全站置顶", icon: <Globe size={13} aria-hidden /> }]
: []),
{ key: "1", label: "板块置顶", icon: <FolderOpen size={13} aria-hidden /> },
];
return (
<div>
<AdminPageHeader
icon={Pin}
title="置顶管理"
description="拖拽手柄或上下箭头调整置顶帖顺序,改动自动保存,越靠前越先展示。板块管理员仅可排序授权板块的板块置顶。"
/>
{error && (
<div
className="mb-3 px-3 py-2 rounded-[var(--r-sm)] text-[12.5px]"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
{error}
</div>
)}
{/* 层级切换 + 板块选择 */}
<div className="mb-4 flex items-center gap-3 flex-wrap">
<AdminSegmented
options={segOptions}
value={String(level)}
onChange={(k) => switchLevel(Number(k) as Level)}
ariaLabel="置顶层级"
/>
{level === 1 && (
<BoardSelect
boards={boards}
value={boardId}
onChange={setBoardId}
disabled={loading || saving}
/>
)}
</div>
<div className="panel overflow-hidden">
{loading ? (
<PinListSkeleton />
) : posts.length === 0 ? (
<AdminEmpty
icon={<Pin size={20} />}
title={level === 2 ? "暂无全站置顶帖" : "该板块暂无置顶帖"}
description="在帖子页的「管理」菜单中可将帖子设为置顶。"
/>
) : (
<>
{/* 列表状态条:数量 + 保存状态/操作提示 */}
<div
className="flex items-center justify-between gap-3 px-4 py-2.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span
className="text-[12.5px] font-semibold inline-flex items-center gap-1"
style={{ color: "var(--ink-2)" }}
>
共
<b className="tabular-nums" style={{ color: "var(--accent)" }}>
{posts.length}
</b>
篇置顶
</span>
<span className="meta text-[12px] inline-flex items-center gap-1.5">
{saving ? (
<>
<Loader2 size={12} className="animate-spin" aria-hidden />
保存中…
</>
) : (
"拖拽手柄或上下按钮调整,自动保存"
)}
</span>
</div>
<ul
onDragLeave={(e) => {
// 仅当指针真正离开列表时清除指示线,避免行间移动时闪烁
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setDropIndex(null);
}
}}
>
{posts.map((p, i) => (
<li
key={p.id}
className={`pin-row relative flex items-center gap-3 px-4 py-3${
dragIndex === i ? " is-dragging" : ""
}`}
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
onDragOver={(e) => onRowDragOver(e, i)}
onDrop={onRowDrop}
>
{/* 拖拽插入位置指示线 */}
{showLineAt(i) && (
<span
className="absolute left-3 right-3 -top-px h-[2px] rounded-full pointer-events-none"
style={{ background: "var(--accent)" }}
aria-hidden
/>
)}
{i === posts.length - 1 && showLineAt(posts.length) && (
<span
className="absolute left-3 right-3 -bottom-px h-[2px] rounded-full pointer-events-none"
style={{ background: "var(--accent)" }}
aria-hidden
/>
)}
{/* 拖拽手柄:键盘上下方向键同样可排序 */}
<span
role="button"
tabIndex={0}
aria-label={`拖拽调整「${p.title}」的顺序,也可使用上下方向键`}
className="pin-drag-handle w-5 h-8 -ml-1 inline-flex items-center justify-center rounded-md shrink-0"
draggable={!saving}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(i));
setDragIndex(i);
}}
onDragEnd={resetDrag}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
e.preventDefault();
move(i, -1);
} else if (e.key === "ArrowDown") {
e.preventDefault();
move(i, 1);
}
}}
>
<GripVertical size={14} aria-hidden />
</span>
{/* 名次徽章:前三名高亮 */}
<span
className="w-7 h-7 rounded-lg inline-flex items-center justify-center text-[12.5px] font-bold tabular-nums shrink-0"
style={
i < 3
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { background: "var(--panel-2)", color: "var(--ink-3)" }
}
>
{i + 1}
</span>
<div className="min-w-0 flex-1">
<a
href={`/post/${p.id}`}
target="_blank"
rel="noreferrer"
className="text-[13.5px] font-semibold truncate block hover:underline"
style={{ color: "var(--ink)" }}
>
{p.title}
</a>
<p className="meta text-[12px] mt-0.5 flex items-center gap-1.5 truncate">
{level === 2 && (
<span
className="inline-flex items-center gap-1 px-1.5 py-px rounded-full shrink-0"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<FolderOpen size={10} aria-hidden />
{p.board_name}
</span>
)}
<span className="shrink-0">{formatRelative(p.created_at)}</span>
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<AdminIconButton
ariaLabel={`上移「${p.title}」`}
tip="上移"
disabled={i === 0 || saving}
onClick={() => move(i, -1)}
>
<ChevronUp size={15} />
</AdminIconButton>
<AdminIconButton
ariaLabel={`下移「${p.title}」`}
tip="下移"
disabled={i === posts.length - 1 || saving}
onClick={() => move(i, 1)}
>
<ChevronDown size={15} />
</AdminIconButton>
</div>
</li>
))}
</ul>
</>
)}
</div>
</div>
);
}
/** 板块自定义下拉:浮层 + 键盘导航 + 滚动列表,替代原生 select */
function BoardSelect({
boards,
value,
onChange,
disabled,
}: {
boards: BoardOpt[];
value: number;
onChange: (id: number) => void;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
/** 键盘/悬停高亮下标 */
const [hi, setHi] = useState(0);
const ref = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const current = boards.find((b) => b.id === value);
useEffect(() => {
if (!open) return;
setHi(Math.max(0, boards.findIndex((b) => b.id === value)));
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open, boards, value]);
// 高亮项保持在可视区内
useEffect(() => {
if (!open) return;
const el = listRef.current?.children[hi];
el?.scrollIntoView({ block: "nearest" });
}, [hi, open]);
const close = (refocus = false) => {
setOpen(false);
if (refocus) triggerRef.current?.focus();
};
const select = (id: number) => {
onChange(id);
close(true);
};
const onKeyDown = (e: ReactKeyboardEvent) => {
if (!open) {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
e.preventDefault();
setOpen(true);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
close(true);
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHi((v) => Math.min(boards.length - 1, v + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHi((v) => Math.max(0, v - 1));
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (boards[hi]) select(boards[hi].id);
} else if (e.key === "Tab") {
close();
}
};
if (boards.length === 0) {
return <span className="meta text-[13px] py-2">无可用板块</span>;
}
return (
<div ref={ref} className="relative" onKeyDown={onKeyDown}>
<button
ref={triggerRef}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
aria-label="选择板块"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className="pin-board-trigger h-9 pl-3 pr-2.5 rounded-lg inline-flex items-center gap-2 text-[13px] font-medium transition-[border-color,box-shadow] disabled:opacity-60 disabled:cursor-not-allowed"
style={{
minWidth: 176,
background: "var(--panel)",
border: `1px solid ${open ? "var(--accent)" : "var(--line-2)"}`,
color: "var(--ink)",
boxShadow: open ? "0 0 0 3px var(--accent-soft)" : "var(--shadow-sm)",
}}
>
<FolderOpen
size={14}
className="shrink-0 transition-colors"
style={{ color: open ? "var(--accent)" : "var(--ink-3)" }}
aria-hidden
/>
<span className="truncate max-w-44">{current?.name ?? "选择板块"}</span>
<ChevronDown
size={13}
className={`ml-auto shrink-0 transition-transform duration-150${
open ? " rotate-180" : ""
}`}
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
</button>
{open && (
<div
className="absolute z-30 mt-1.5 left-0 w-full min-w-52 rounded-xl overflow-hidden"
style={{
background: "var(--panel)",
border: "1px solid var(--line-2)",
boxShadow: "var(--shadow-lg)",
}}
>
<ul
ref={listRef}
role="listbox"
aria-label="板块列表"
className="max-h-60 overflow-y-auto p-1.5"
>
{boards.map((b, i) => {
const active = b.id === value;
return (
<li key={b.id}>
<button
type="button"
role="option"
aria-selected={active}
tabIndex={-1}
onClick={() => select(b.id)}
onMouseEnter={() => setHi(i)}
className="w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-left text-[13px] transition-colors"
style={{
background: active
? "var(--nav-active-bg)"
: i === hi
? "var(--panel-2)"
: undefined,
color: active ? "var(--nav-active-ink)" : "var(--ink-2)",
}}
>
<span className="flex-1 truncate font-medium">{b.name}</span>
{active && <Check size={13} className="shrink-0" aria-hidden />}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}
/** 加载骨架:模拟 手柄/徽章/标题/摘要 行结构 */
function PinListSkeleton() {
return (
<ul aria-hidden>
{[0, 1, 2, 3].map((i) => (
<li
key={i}
className="flex items-center gap-3 px-4 py-3"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
>
<span
className="w-5 h-7 -ml-1 rounded-md animate-pulse shrink-0"
style={{ background: "var(--panel-2)" }}
/>
<span
className="w-7 h-7 rounded-lg animate-pulse shrink-0"
style={{ background: "var(--panel-2)" }}
/>
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
<span
className="h-3.5 rounded animate-pulse"
style={{ background: "var(--panel-2)", width: `${52 - i * 9}%` }}
/>
<span
className="h-3 w-28 rounded animate-pulse"
style={{ background: "var(--panel-2)" }}
/>
</div>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchBoards, type BoardWithCount } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isAdminOrAbove } from "@/lib/roles";
import Forbidden from "../Forbidden";
import PinnedAdmin from "./PinnedAdmin";
export const metadata: Metadata = {
title: "置顶管理",
};
export default async function AdminPinnedPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
// 管理员及以上排全站置顶;板块管理员排自己授权板块的板块置顶
if (!me.user || !canModerateAny(me.user)) {
return <Forbidden text="你所在的角色没有置顶管理权限,或尚未被授权任何板块。" />;
}
// 板块管理员只能看到自己被授权的板块
let boards: BoardWithCount[] = [];
try {
const r = await fetchBoards();
boards = r.boards ?? [];
} catch {
boards = [];
}
if (!isAdminOrAbove(me.user.role)) {
const ids = new Set(me.user.board_ids ?? []);
boards = boards.filter((b) => ids.has(b.id));
}
return (
<PinnedAdmin
boards={boards.map((b) => ({ id: b.id, name: b.name }))}
canGlobal={isAdminOrAbove(me.user.role)}
/>
);
}

View File

@@ -2,7 +2,7 @@
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AdminField, AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api";
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
import { toast } from "@/lib/toast";
@@ -25,6 +25,7 @@ export default function BasicIdentityPanel({
}, []);
const [wordmark, setWordmark] = useState(initial.site_wordmark);
const [slogan, setSlogan] = useState(initial.site_slogan);
const [sloganVisible, setSloganVisible] = useState(initial.brand_slogan_visible);
const [desc, setDesc] = useState(initial.site_description);
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
const [kwInput, setKwInput] = useState("");
@@ -32,6 +33,7 @@ export default function BasicIdentityPanel({
name: initial.site_name,
wordmark: initial.site_wordmark,
slogan: initial.site_slogan,
sloganVisible: initial.brand_slogan_visible,
desc: initial.site_description,
keywords: initial.site_keywords,
});
@@ -44,6 +46,7 @@ export default function BasicIdentityPanel({
name.trim() !== saved.name ||
wordmark.trim() !== saved.wordmark ||
slogan.trim() !== saved.slogan ||
sloganVisible !== saved.sloganVisible ||
desc.trim() !== saved.desc ||
JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
@@ -85,6 +88,7 @@ export default function BasicIdentityPanel({
setName(saved.name);
setWordmark(saved.wordmark);
setSlogan(saved.slogan);
setSloganVisible(saved.sloganVisible);
setDesc(saved.desc);
setKeywords([...saved.keywords]);
setKwInput("");
@@ -98,6 +102,7 @@ export default function BasicIdentityPanel({
name: name.trim(),
wordmark: wordmark.trim(),
slogan: slogan.trim(),
sloganVisible,
desc: desc.trim(),
keywords: [...keywords],
};
@@ -115,6 +120,7 @@ export default function BasicIdentityPanel({
site_name: snap.name,
site_wordmark: snap.wordmark,
site_slogan: snap.slogan,
brand_slogan_visible: snap.sloganVisible,
site_description: snap.desc,
site_keywords: snap.keywords,
});
@@ -123,12 +129,14 @@ export default function BasicIdentityPanel({
name: res.site_name,
wordmark: res.site_wordmark,
slogan: res.site_slogan,
sloganVisible: res.brand_slogan_visible,
desc: res.site_description,
keywords: res.site_keywords,
});
setName((v) => (v.trim() === snap.name ? res.site_name : v));
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
setSloganVisible((v) => (v === snap.sloganVisible ? res.brand_slogan_visible : v));
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
setKeywords((prev) =>
JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev,
@@ -199,18 +207,49 @@ export default function BasicIdentityPanel({
/>
</div>
<AdminField
id="site-slogan"
label="标语"
className="admin-identity-field"
maxLength={BRAND_LIMITS.slogan}
value={slogan}
onChange={(v) => {
setSlogan(v);
clearSaveError();
}}
placeholder="技术分享与讨论"
/>
<div className="admin-identity-field">
<div className="admin-field-head">
<label className="field-label" htmlFor="site-slogan">
标语
</label>
<span className="admin-field-count" aria-hidden>
{slogan.length}/{BRAND_LIMITS.slogan}
</span>
</div>
<div
className="flex items-center gap-2 mb-2"
title={slogan.trim() ? "在页眉站名下方显示副标题" : "先填写标语后可开启"}
>
<AdminSwitch
checked={sloganVisible}
disabled={!slogan.trim()}
onChange={() => {
setSloganVisible((v) => !v);
clearSaveError();
}}
label="页眉显示标语"
/>
<span
className="text-[12px]"
style={{ color: slogan.trim() ? "var(--ink-2)" : "var(--ink-3)" }}
>
页眉显示标语
</span>
</div>
<div className="admin-field-shell">
<input
id="site-slogan"
className="admin-field-input"
maxLength={BRAND_LIMITS.slogan}
value={slogan}
onChange={(e) => {
setSlogan(e.target.value);
clearSaveError();
}}
placeholder="技术分享与讨论"
/>
</div>
</div>
<p className="admin-identity-title-preview" aria-live="polite">
浏览器标题预览 · <strong>{preview}</strong>
</p>

View File

@@ -1,6 +1,6 @@
"use client";
import { Check, ImagePlus, Images, Link2, X } from "lucide-react";
import { Check, Crop, ImagePlus, Images, Link2, X } from "lucide-react";
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import {
AdminSegmented,
@@ -11,8 +11,6 @@ import BrandCropModal from "@/components/BrandCropModal";
import BrandLockup from "@/components/BrandLockup";
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
import {
BRAND_LOGO_FITS,
BRAND_LOGO_SIZES,
brandAccept,
brandAliasPath,
brandFileOk,
@@ -313,6 +311,17 @@ export default function BrandSeoPanel({
>
<Images size={14} /> 库
</button>
{preview && !/\.(svg|ico)(\?.*)?$/i.test(preview) ? (
<button
type="button"
className="btn btn-line btn-sm"
disabled={saving}
title="调整画幅、铺法或重新裁剪"
onClick={() => openCrop(slot, null, preview)}
>
<Crop size={14} /> 调整
</button>
) : null}
<button
type="button"
className="admin-icon-btn"
@@ -361,7 +370,7 @@ export default function BrandSeoPanel({
frame
name={initial.site_name}
wordmark={initial.site_wordmark}
slogan={initial.site_slogan}
slogan={initial.brand_slogan_visible ? initial.site_slogan : ""}
logoLight={previewOf(light)}
logoDark={previewOf(dark)}
/>
@@ -388,46 +397,9 @@ export default function BrandSeoPanel({
/>
</div>
{mark !== "text" ? (
<>
<div className="admin-brand-studio-field">
<span className="admin-brand-studio-kicker">比例</span>
<div className="admin-brand-size-row" role="group" aria-label="Logo 比例">
{BRAND_LOGO_SIZES.map((item) => {
const on = logoSize === item.id;
return (
<button
key={item.id}
type="button"
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
onClick={() => {
setLogoSize(item.id);
if (saveError) setSaveError("");
}}
>
<span className="admin-brand-size-chip-label">{item.label}</span>
<span className="admin-brand-size-chip-px">
{item.w}×{item.h}
</span>
</button>
);
})}
</div>
</div>
<div className="admin-brand-studio-field">
<span className="admin-brand-studio-kicker">铺法</span>
<AdminSegmented
role="group"
size="sm"
ariaLabel="适配方式"
value={logoFit}
onChange={(v) => {
setLogoFit(v);
if (saveError) setSaveError("");
}}
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
/>
</div>
</>
<p className="admin-brand-studio-field text-[11.5px] self-center mb-0.5" style={{ color: "var(--ink-3)" }}>
画幅与铺法在上传/调整图片的裁剪弹窗中设置
</p>
) : null}
</div>
@@ -471,6 +443,16 @@ export default function BrandSeoPanel({
slot={cropSlot}
seedFile={cropSeedFile}
seedUrl={cropSeedUrl}
logoSize={logoSize}
logoFit={logoFit}
onSizeChange={(s) => {
setLogoSize(s);
if (saveError) setSaveError("");
}}
onFitChange={(f) => {
setLogoFit(f);
if (saveError) setSaveError("");
}}
onClose={closeCrop}
onSeedConsumed={() => {
setCropSeedFile(null);

View File

@@ -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<HeaderNavBuiltinKey, React.ReactNode> = {
home: <Home size={14} />,
leaderboard: <Trophy size={14} />,
library: <BookOpen size={14} />,
snippets: <CodeXml size={14} />,
};
/** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */
@@ -26,6 +27,7 @@ const BUILTIN_HINTS: Record<HeaderNavBuiltinKey, string> = {
home: "站点首页 /",
leaderboard: "排行榜页(地址随伪静态风格变化)",
library: "书库页(地址随伪静态风格变化)",
snippets: "片段广场页(地址随伪静态风格变化)",
};
type BuiltinSwitches = Record<HeaderNavBuiltinKey, boolean>;
@@ -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"),
};
}

View File

@@ -11,6 +11,7 @@ import { toast } from "@/lib/toast";
const TAB_META: Record<MobileTabKey, { label: string; hint: string }> = {
home: { label: "首页", hint: "站点首页" },
library: { label: "书库", hint: "书库列表页入口" },
snippets: { label: "片段", hint: "代码片段列表页入口(默认关闭,开启后显示在底栏)" },
compose: { label: "发帖", hint: "发帖工作台入口(中央主按钮,无对应首页)" },
messages: { label: "消息", hint: "私聊/群聊列表;游客不显示" },
me: { label: "我的", hint: "个人主页;未登录指向登录页" },
@@ -22,6 +23,7 @@ function switchesOf(tabs: MobileTabKey[]): TabSwitches {
return {
home: tabs.includes("home"),
library: tabs.includes("library"),
snippets: tabs.includes("snippets"),
compose: tabs.includes("compose"),
messages: tabs.includes("messages"),
me: tabs.includes("me"),
@@ -166,7 +168,7 @@ export default function MobileTabBarPanel({
<ul className="admin-url-style-notes meta mt-1 text-[12px]">
<li>顺序即前台底栏展示顺序;未启用的入口保留排序位置,重新启用后按当前顺序插入。</li>
<li>至少保留一个入口;全部关闭将无法保存。</li>
<li>排行榜不在底栏展示,移动端入口由首页抽屉承担。</li>
<li>片段、排行榜未放进底栏时,移动端仍可从首页 ☰ 导航抽屉进入(片段默认关闭)。</li>
</ul>
</div>
</AdminSettingsGroup>

View File

@@ -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<SnippetListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [vis, setVis] = useState<VisibilityFilter>("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 (
<div>
<AdminPageHeader
icon={CodeXml}
title="片段管理"
description="查看全站代码片段(含私有),可下架违规公开片段或删除。"
/>
<div
className="rounded-2xl overflow-hidden"
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
>
<div
className="flex items-center justify-between gap-2 flex-wrap px-4 sm:px-5 py-3"
style={{ borderBottom: "1px solid var(--line)" }}
>
<AdminSegmented<VisibilityFilter>
ariaLabel="可见性筛选"
size="sm"
value={vis}
onChange={(v) => void load(1, v, q)}
options={[
{ key: "all", label: "全部" },
{ key: "public", label: "公开" },
{ key: "private", label: "私有" },
]}
/>
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={() => {
setQ(keyword.trim());
void load(1, vis, keyword.trim());
}}
onClear={() => {
setKeyword("");
setQ("");
void load(1, vis, "");
}}
placeholder="搜索标题或描述…"
ariaLabel="搜索片段"
/>
</div>
{error ? <p className="alert-error m-3 text-[12.5px]">{error}</p> : null}
{loading && items.length === 0 ? (
<div className="flex justify-center py-14">
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
</div>
) : items.length === 0 ? (
<AdminEmpty
icon={<CodeXml size={20} />}
title="暂无片段"
description={q ? "没有匹配搜索条件的片段。" : "还没有用户发布代码片段。"}
/>
) : (
<ul className="divide-y" style={{ borderColor: "var(--line)" }}>
{items.map((it) => (
<li key={it.id} className="px-4 sm:px-5 py-3 flex items-center gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<Link
href={snippetPath(urlStyle, it.id)}
target="_blank"
className="truncate text-[14px] font-semibold hover:underline"
style={{ color: "var(--ink)" }}
>
{it.title}
</Link>
<span
className="shrink-0 rounded-full px-1.5 py-px text-[10.5px] font-semibold"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
{it.language}
</span>
{it.visibility === "private" ? (
<span
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-px text-[10.5px]"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<EyeOff size={10} /> 私有
</span>
) : (
<span
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-px text-[10.5px]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Eye size={10} /> 公开
</span>
)}
</div>
<div className="mt-1 flex items-center gap-2.5 text-[11.5px] flex-wrap" style={{ color: "var(--ink-3)" }}>
{it.user ? (
<span className="inline-flex items-center gap-1">
<Avatar name={it.user.nickname} src={it.user.avatar} size={14} />
{it.user.nickname}
</span>
) : (
<span>用户 #{it.user_id}</span>
)}
<span className="inline-flex items-center gap-1">
<Copy size={11} /> {it.copy_count}
</span>
<span className="inline-flex items-center gap-1">
<Eye size={11} /> {it.view_count}
</span>
<span className="inline-flex items-center gap-1">
<MessagesSquare size={11} /> {it.comment_count}
</span>
<span className="inline-flex items-center gap-1">
<GitFork size={11} /> {it.fork_count}
</span>
<span>{formatRelative(it.created_at)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{it.visibility === "public" ? (
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1"
onClick={() => setConfirm({ kind: "hide", item: it })}
>
<EyeOff size={13} /> 下架
</button>
) : (
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1"
onClick={() => setConfirm({ kind: "show", item: it })}
>
<Eye size={13} /> 上架
</button>
)}
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1"
style={{ color: "var(--danger)" }}
onClick={() => setConfirm({ kind: "delete", item: it })}
>
<Trash2 size={13} /> 删除
</button>
</div>
</li>
))}
</ul>
)}
<AdminPager
page={page}
total={total}
pageSize={PAGE_SIZE}
loading={loading}
onChange={(p) => void load(p, vis, q)}
/>
</div>
<ConfirmDialog
open={!!confirm}
title={
confirm?.kind === "delete"
? "删除片段"
: confirm?.kind === "hide"
? "下架片段"
: "上架片段"
}
message={
confirm?.kind === "delete"
? `确定删除片段「${confirm.item.title}」吗?删除后前台不可见。`
: confirm?.kind === "hide"
? `确定将「${confirm?.item.title}」下架为私有吗?仅作者本人可见。`
: `确定将「${confirm?.item.title ?? ""}」恢复为公开吗?`
}
danger={confirm?.kind !== "show"}
busy={busy}
confirmLabel={
confirm?.kind === "delete" ? "确认删除" : confirm?.kind === "hide" ? "确认下架" : "确认上架"
}
onCancel={() => !busy && setConfirm(null)}
onConfirm={() => void runConfirm()}
/>
</div>
);
}

View File

@@ -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 <Forbidden text="片段管理仅管理员及以上角色可操作。" />;
}
return <SnippetsAdmin />;
}

View File

@@ -118,7 +118,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
<>
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>

View File

@@ -59,7 +59,7 @@ function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlS
</span>
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
@@ -93,7 +93,7 @@ function AnnouncementRow({ a, showDivider, urlStyle }: { a: Announcement; showDi
<div className="flex items-center gap-2 flex-wrap">
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>

View File

@@ -688,7 +688,7 @@ export default function ChatRoomClient({
const isDirect = roomType === "direct";
const isRealMember = myRole === "owner" || myRole === "admin" || myRole === "member";
const canView = joined;
const oversee = canOverseeChat(meRole);
const oversee = canOverseeChat({ role: meRole, can_manage_messages: canManageMessages });
const siteOwner = isSiteOwner(meRole);
const canModerateChat =
oversee || isOwner || isRoomAdmin || (!!canManageMessages && !isDirect);
@@ -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(() => {

View File

@@ -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 */
@@ -29,6 +30,14 @@
--gold: #825700;
--gold-soft: rgba(130, 87, 0, 0.13);
/* 置顶 / 推荐徽章专用色:与 --clay/--gold(还被文档类型、图表等复用)解耦,单独可调 */
--pin: #c2410c; /* 置顶:焦橙,明度高、不浑浊 */
--pin-soft: rgba(194, 65, 12, 0.1);
--pin-line: rgba(194, 65, 12, 0.26);
--rec: #b7791f; /* 推荐:琥珀金,偏 saffron 而非暗橄榄 */
--rec-soft: rgba(183, 121, 31, 0.13);
--rec-line: rgba(183, 121, 31, 0.3);
--danger: #c8281e;
--danger-soft: rgba(200, 40, 30, 0.09);
--ok: #137333;
@@ -139,6 +148,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 */
@@ -152,6 +162,13 @@
--gold: #e0b259;
--gold-soft: rgba(224, 178, 89, 0.16);
--pin: #ff9a6b;
--pin-soft: rgba(255, 154, 107, 0.14);
--pin-line: rgba(255, 154, 107, 0.3);
--rec: #e8bd63;
--rec-soft: rgba(232, 189, 99, 0.14);
--rec-line: rgba(232, 189, 99, 0.3);
--danger: #ff6b60;
--danger-soft: rgba(255, 107, 96, 0.15);
--ok: #58d18a;
@@ -4006,6 +4023,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);
@@ -5150,6 +5196,15 @@ a.j13-mention-token:hover {
flex-shrink: 0;
overflow: hidden;
}
.j13-brand-slot[data-size="auto"] {
width: auto;
max-width: 176px;
}
.j13-brand-slot[data-size="auto"] > .j13-brand-logo {
width: auto;
max-width: 176px;
object-fit: contain;
}
.j13-brand-slot[data-frame="1"] {
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 70%, transparent);
@@ -5225,6 +5280,36 @@ a.j13-mention-token:hover {
outline-offset: 2px;
}
/* 置顶管理:行悬停、拖拽手柄、板块下拉触发器焦点 */
.pin-row {
transition: background 0.15s ease, opacity 0.15s ease;
}
.pin-row:hover {
background: var(--item-hover-bg);
}
.pin-row.is-dragging {
opacity: 0.45;
}
.pin-drag-handle {
cursor: grab;
color: var(--ink-3);
transition: color 0.15s ease;
}
.pin-row:hover .pin-drag-handle {
color: var(--ink-2);
}
.pin-drag-handle:active {
cursor: grabbing;
}
.pin-drag-handle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.pin-board-trigger:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* 单页/公告管理:限宽左对齐,不套用设置页 1120px;画布保持镂空,与其他管理页一致 */
.admin-doc-page {
width: 100%;
@@ -6416,8 +6501,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);
@@ -6425,41 +6512,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;
}
}
@@ -6509,23 +6637,33 @@ a.j13-mention-token:hover {
background: var(--accent-soft);
color: var(--accent);
}
/* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间 */
/* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间。
形态:圆角方块 + 极淡底 + 同色发丝边 + 线性图标,避免实心图标在小尺寸下发糊。 */
.badge-pin,
.badge-rec {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 20px;
height: 20px;
border-radius: 6px;
border-radius: 7px;
border: 1px solid;
line-height: 0;
}
.badge-pin {
background: var(--clay-soft);
color: var(--clay);
color: var(--pin);
background: var(--pin-soft);
border-color: var(--pin-line);
}
/* 板块置顶:去掉底色只留描边,弱一档,与全站置顶拉开层级(不换色相,避免多一套语义色) */
.badge-pin[data-scope="board"] {
background: transparent;
}
.badge-rec {
background: var(--gold-soft);
color: var(--gold);
color: var(--rec);
background: var(--rec-soft);
border-color: var(--rec-line);
}
.badge-pending {
display: inline-flex;
@@ -7125,6 +7263,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); }
@@ -9065,12 +9207,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);
@@ -10390,3 +10533,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;
}

View File

@@ -491,13 +491,18 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
使 h1 在徽章旁的剩余空间内折行,徽章始终与标题首行同行(移动端) */}
<div className="flex items-start gap-2 min-w-0">
{post.pinned > 0 && (
<span className="badge-pin shrink-0 mt-2 sm:mt-3" title="置顶" aria-label="置顶">
<Pin size={13} fill="currentColor" strokeWidth={2.2} aria-hidden />
<span
className="badge-pin shrink-0 mt-2 sm:mt-3"
data-scope={post.pinned >= 2 ? "global" : "board"}
title={post.pinned >= 2 ? "置顶" : "板块置顶"}
aria-label={post.pinned >= 2 ? "置顶" : "板块置顶"}
>
<Pin size={13} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
{post.recommended && (
<span className="badge-rec shrink-0 mt-2 sm:mt-3" title="推荐" aria-label="推荐">
<Star size={13} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Star size={13} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
<h1

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus } from "lucide-react";
@@ -44,8 +44,33 @@ export default function RegisterClient({
const [notice, setNotice] = useState(registerNotice);
const [sending, setSending] = useState(false);
const [sentMessage, setSentMessage] = useState("");
// 重发倒计时(秒);>0 期间按钮锁定不可点
const [countdown, setCountdown] = useState(0);
const countdownTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const verifyEmail = initialVerifyEmail;
// 卸载时清理倒计时,避免泄漏
useEffect(() => {
return () => {
if (countdownTimer.current) clearInterval(countdownTimer.current);
};
}, []);
const startCountdown = () => {
if (countdownTimer.current) clearInterval(countdownTimer.current);
setCountdown(60);
countdownTimer.current = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
if (countdownTimer.current) clearInterval(countdownTimer.current);
countdownTimer.current = null;
return 0;
}
return c - 1;
});
}, 1000);
};
// 已登录用户不必再注册,直接回首页
useEffect(() => {
if (!hasAuthCookieHint()) return;
@@ -165,9 +190,10 @@ export default function RegisterClient({
/>
<button
type="button"
className="btn btn-line shrink-0"
disabled={sending || !email}
className="btn btn-code shrink-0"
disabled={sending || countdown > 0 || !email}
onClick={async () => {
// 点击即锁定,防止重复发送
setSending(true);
setError("");
try {
@@ -175,7 +201,8 @@ export default function RegisterClient({
email,
purpose: "register",
});
setSentMessage(r.message);
setSentMessage("验证码已发送,请查收邮箱");
startCountdown();
} catch (err) {
setError((err as Error).message);
} finally {
@@ -183,7 +210,11 @@ export default function RegisterClient({
}
}}
>
{sending ? "正在排队…" : "发送验证码"}
{sending
? "发送中…"
: countdown > 0
? `${countdown}s 后可重发`
: "发送验证码"}
</button>
</div>
{sentMessage && (

View File

@@ -118,7 +118,7 @@ export default function ResetPasswordPage() {
/>
<button
type="button"
className="btn btn-line shrink-0"
className="btn btn-code shrink-0"
disabled={sending || busy || !email}
onClick={() => void sendCode()}
>

View File

@@ -5,9 +5,10 @@ import {
fetchAnnouncementsPage,
fetchPublishedSitePages,
fetchLibraryList,
fetchSnippets,
} from "@/lib/api";
import { canonicalPath, resolveSiteOrigin } from "@/lib/siteUrl";
import { announcementPath, postPath, pagePath, leaderboardPath, libraryPath, libraryDocPath } from "@/lib/urlStyle";
import { announcementPath, postPath, pagePath, leaderboardPath, libraryPath, libraryDocPath, snippetsPath, snippetPath } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
@@ -40,6 +41,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "daily",
priority: 0.5,
},
{
url: url(snippetsPath(style)),
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.5,
},
{
url: url("/about"),
lastModified: new Date(),
@@ -104,5 +111,20 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
/* 忽略 */
}
try {
const { items } = await fetchSnippets({ page: 1, size: 200 });
for (const it of items) {
if (it.visibility !== "public") continue;
entries.push({
url: url(snippetPath(style, it.id)),
lastModified: new Date(it.updated_at || it.created_at),
changeFrequency: "weekly",
priority: 0.4,
});
}
} catch {
/* 忽略 */
}
return entries;
}

View File

@@ -0,0 +1,111 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { apiMe, apiGetSnippet } from "@/lib/api";
import SnippetEditor from "@/components/SnippetEditor";
function EditSnippetInner({ id }: { id: number }) {
const router = useRouter();
const [ready, setReady] = useState(false);
const [snippet, setSnippet] = useState<{
title: string;
language: string;
description: string;
code: string;
visibility: "public" | "private";
user_id: number;
} | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const me = await apiMe().catch(() => null);
if (cancelled) return;
if (!me?.user) {
router.push(`/login?redirect=/snippets/${id}/edit`);
return;
}
try {
const data = await apiGetSnippet(id);
if (cancelled) return;
if (!data) {
router.push("/snippets");
return;
}
// 仅 owner 或 staff 可编辑(后端会再次校验)
setSnippet({
title: data.snippet.title,
language: data.snippet.language,
description: data.snippet.description,
code: data.snippet.code,
visibility: data.snippet.visibility,
user_id: data.snippet.user_id,
});
setReady(true);
} catch {
if (!cancelled) router.push("/snippets");
}
})();
return () => {
cancelled = true;
};
}, [router, id]);
if (!ready || !snippet) {
return (
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
<p className="meta">正在加载片段…</p>
</div>
);
}
return (
<SnippetEditor
mode="edit"
snippetId={id}
initialTitle={snippet.title}
initialLanguage={snippet.language}
initialDescription={snippet.description}
initialCode={snippet.code}
initialVisibility={snippet.visibility}
/>
);
}
export default function EditSnippetPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const [id, setId] = useState<number | null>(null);
useEffect(() => {
params.then((p) => {
const numId = Number(p.id);
if (Number.isInteger(numId) && numId > 0) setId(numId);
});
}, [params]);
if (id === null) {
return (
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
<p className="meta">正在加载…</p>
</div>
);
}
return (
<div className="j13-compose-page">
<Suspense
fallback={
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
<p className="meta">正在加载片段…</p>
</div>
}
>
<EditSnippetInner id={id} />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,253 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { cookies } from "next/headers";
import { GitFork, Copy, Eye, MessageSquare } from "lucide-react";
import {
fetchSnippetById,
fetchSnippetComments,
fetchSnippetForks,
} from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
import { requestCanonical } from "@/lib/canonicalMeta";
import { snippetPath, userPath, snippetsPath } from "@/lib/urlStyle";
import { postViewerCookieHeader } from "@/lib/cookies";
import { formatRelative } from "@/lib/format";
import MarkdownBody from "@/components/MarkdownBody";
import Avatar from "@/components/Avatar";
import SnippetCodeView from "@/components/SnippetCodeView";
import SnippetCommentSection from "@/components/SnippetCommentSection";
import SnippetCommentCountChip from "@/components/SnippetCommentCountChip";
import SnippetActions from "@/components/SnippetActions";
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const numId = Number(id);
if (!Number.isInteger(numId) || numId <= 0) return { title: "片段不存在" };
const cookieHeader = postViewerCookieHeader(await cookies());
const data = await fetchSnippetById(numId, cookieHeader).catch(() => null);
if (!data) return { title: "片段不存在" };
const s = data.snippet;
return {
title: s.title,
description: s.description ? s.description.slice(0, 150) : `${s.language} 代码片段`,
...(await requestCanonical(snippetPath((await getPublicSettingsCached()).url_style, s.id))),
};
}
export default async function SnippetDetailPage({ params }: PageProps) {
const { id } = await params;
const numId = Number(id);
if (!Number.isInteger(numId) || numId <= 0) notFound();
const cookieHeader = postViewerCookieHeader(await cookies());
const [detailRes, commentsRes, forksRes, settings] = await Promise.all([
fetchSnippetById(numId, cookieHeader).catch(() => null),
fetchSnippetComments(numId, 1, 20, cookieHeader).catch(() => ({ items: [], total: 0 })),
fetchSnippetForks(numId, 1, 6).catch(() => ({ items: [], total: 0 })),
getPublicSettingsCached(),
]);
if (!detailRes) notFound();
const snippet = detailRes.snippet;
const urlStyle = settings.url_style;
const isOwner = false; // 客户端组件中通过 apiMe 校正
const isStaff = false;
return (
<div className="max-w-[1440px] mx-auto min-w-0">
<div className="mb-4 flex items-center gap-2 text-[12.5px] meta">
<Link
href={snippetsPath(urlStyle)}
className="hover:underline inline-flex items-center gap-1.5"
>
← 片段广场
</Link>
{snippet.forked_from && (
<>
<span style={{ color: "var(--ink-3)" }}>·</span>
<Link
href={snippetPath(urlStyle, snippet.forked_from.id)}
className="inline-flex items-center gap-1 hover:underline"
>
<GitFork size={11} /> Fork 自:{snippet.forked_from.title}
</Link>
</>
)}
</div>
<article className="panel px-4 py-6 sm:p-8">
{/* 头部 */}
<header>
<div className="flex items-center gap-2 flex-wrap">
<span
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
}}
>
{snippet.language}
</span>
{snippet.visibility === "private" && (
<span
className="text-[10px] font-medium px-1.5 py-0.5 rounded-md"
style={{
background: "var(--panel-2)",
color: "var(--ink-3)",
border: "1px solid var(--line)",
}}
>
私有
</span>
)}
</div>
<h1
className="mt-2.5 text-[23px] sm:text-[28px] leading-tight font-bold"
style={{ color: "var(--ink)" }}
>
{snippet.title}
</h1>
{/* 作者 + 统计 */}
<div className="mt-4 flex items-center gap-x-4 gap-y-2.5 flex-wrap">
{snippet.user && (
<Link href={userPath(urlStyle, snippet.user.id)} className="flex items-center gap-2 group min-w-0">
<Avatar
size={30}
name={snippet.user.nickname || snippet.user.username}
src={snippet.user.avatar}
/>
<span className="min-w-0">
<span
className="block text-[13.5px] font-semibold leading-tight truncate group-hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{snippet.user.nickname || snippet.user.username}
</span>
<span className="block text-[11px] leading-tight meta truncate">
@{snippet.user.username}
</span>
</span>
</Link>
)}
<span className="meta text-[12px]">{formatRelative(snippet.created_at)}</span>
{snippet.updated_at !== snippet.created_at && (
<span className="meta text-[12px]">(编辑于 {formatRelative(snippet.updated_at)})</span>
)}
<span className="flex items-center gap-1.5 flex-wrap sm:ml-auto">
<StatChip icon={<Copy size={12} />} value={snippet.copy_count} label="复制" />
<StatChip icon={<Eye size={12} />} value={snippet.view_count} label="查看" />
<SnippetCommentCountChip
snippetId={snippet.id}
initial={snippet.comment_count}
icon={<MessageSquare size={12} />}
label="评论"
/>
<a
href={snippet.fork_count > 0 ? "#snippet-forks" : undefined}
className="inline-flex rounded-full"
style={{ cursor: snippet.fork_count > 0 ? "pointer" : "default" }}
title={snippet.fork_count > 0 ? "查看 Fork 演化" : "还没有人 Fork"}
>
<StatChip icon={<GitFork size={12} />} value={snippet.fork_count} label="Fork" />
</a>
</span>
</div>
{/* 操作 */}
<div className="mt-4 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<SnippetActions
snippet={snippet}
urlStyle={urlStyle}
isOwner={isOwner}
isStaff={isStaff}
/>
</div>
</header>
{/* 代码窗格 */}
<section className="mt-6">
<SnippetCodeView
code={snippet.code}
language={snippet.language}
snippetId={snippet.id}
defaultFold={true}
foldLines={25}
/>
</section>
{snippet.description && (
<section className="mt-7">
<h2 className="text-[13px] font-bold mb-2.5 flex items-center gap-2" style={{ color: "var(--ink)" }}>
<span
className="w-1 h-1 rounded-full"
style={{ background: "var(--accent)" }}
aria-hidden
/>
说明
</h2>
<MarkdownBody content={snippet.description} />
</section>
)}
{forksRes.total > 0 && (
<section id="snippet-forks" className="mt-7 scroll-mt-24">
<h2 className="text-[13px] font-bold mb-2.5 flex items-center gap-2" style={{ color: "var(--ink)" }}>
<span
className="w-1 h-1 rounded-full"
style={{ background: "var(--accent)" }}
aria-hidden
/>
Fork({forksRes.total})
</h2>
<div className="flex flex-col gap-1.5">
{forksRes.items.slice(0, 5).map((f) => (
<Link
key={f.id}
href={snippetPath(urlStyle, f.id)}
className="inline-flex items-center gap-2 self-start max-w-full rounded-lg px-2.5 py-1.5 text-[12.5px] transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)", border: "1px solid var(--line)" }}
>
<GitFork size={12} style={{ color: "var(--ink-3)" }} />
<span className="truncate">{f.title}</span>
</Link>
))}
</div>
</section>
)}
{/* 评论 */}
<section className="mt-8 pt-7" style={{ borderTop: "1px solid var(--line)" }}>
<SnippetCommentSection
snippetId={snippet.id}
initialItems={commentsRes.items}
initialTotal={commentsRes.total}
urlStyle={urlStyle}
/>
</section>
</article>
</div>
);
}
function StatChip({ icon, value, label }: { icon: React.ReactNode; value: number; label: string }) {
return (
<span
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full text-[11.5px] tabular-nums"
style={{ background: "var(--panel-2)", color: "var(--ink-3)", border: "1px solid var(--line)" }}
title={`${value} ${label}`}
>
<span style={{ color: "var(--ink-3)" }}>{icon}</span>
{value}
<span className="hidden sm:inline">{label}</span>
</span>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { Suspense } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { apiMe } from "@/lib/api";
import SnippetEditor from "@/components/SnippetEditor";
function NewSnippetInner() {
const router = useRouter();
const [ready, setReady] = useState(false);
useEffect(() => {
apiMe()
.then((res) => {
if (!res.user) {
router.push("/login?redirect=/snippets/new");
return;
}
setReady(true);
})
.catch(() => {
router.push("/login?redirect=/snippets/new");
});
}, [router]);
if (!ready) {
return (
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
<p className="meta">正在准备编辑器…</p>
</div>
);
}
return <SnippetEditor mode="create" />;
}
export default function NewSnippetPage() {
return (
<div className="j13-compose-page">
<Suspense
fallback={
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
<p className="meta">正在准备编辑器…</p>
</div>
}
>
<NewSnippetInner />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { fetchSnippets, fetchSnippetLanguages, fetchSnippetStats, type SnippetStats } from "@/lib/api";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { authCookieHeader } from "@/lib/cookies";
import { requestCanonical } from "@/lib/canonicalMeta";
import { snippetsPath } from "@/lib/urlStyle";
import SnippetExplorer from "@/components/SnippetExplorer";
export async function generateMetadata(): Promise<Metadata> {
const settings = await getPublicSettingsCached();
return {
title: "代码片段",
description: settings.site_description,
...(await requestCanonical(snippetsPath(settings.url_style))),
};
}
export default async function SnippetsPage() {
const settings = await getPublicSettingsCached();
// 登录态由服务端判定后下发:客户端组件内再读 cookie 会造成 hydration 不一致。
// getMeCached 与 layout 同参调用,命中 React cache 不额外请求后端。
const cookie = authCookieHeader(await cookies());
const isLoggedIn = !!(await getMeCached(cookie || undefined)).user;
let initialItems: Awaited<ReturnType<typeof fetchSnippets>>["items"] = [];
let initialTotal = 0;
let languages: string[] = [];
let stats: SnippetStats | null = null;
try {
const [listRes, langRes, statsRes] = await Promise.all([
fetchSnippets({ size: 12 }),
fetchSnippetLanguages(),
fetchSnippetStats(),
]);
initialItems = listRes.items;
initialTotal = listRes.total;
languages = langRes.languages;
stats = statsRes;
} catch {
/* 降级空态 */
}
return (
<div className="min-w-0 pt-5 sm:pt-7">
<SnippetExplorer
initialItems={initialItems}
initialTotal={initialTotal}
languages={languages}
urlStyle={settings.url_style}
stats={stats}
isLoggedIn={isLoggedIn}
/>
</div>
);
}

View File

@@ -30,13 +30,14 @@ const SORTS: { key: string; label: string }[] = [
/** 帖子卡(发帖列表与收藏列表共用) */
async function PostCardItem({ post, allowComments }: { post: PostListItem; allowComments?: boolean }) {
const urlStyle = (await getPublicSettingsCached()).url_style;
const pinned = post.pinned > 0;
// 用户主页为全站语境:仅全站置顶(2)高亮,板块置顶(1)在主页不作置顶展示
const pinned = post.pinned >= 2;
return (
<Link
href={postPath(urlStyle, post.id)}
className={`${ITEM_BASE} ${
pinned
? "bg-[color-mix(in_srgb,#fde68a_22%,var(--panel))] border-[color-mix(in_srgb,#fde68a_70%,var(--line))] hover:bg-[color-mix(in_srgb,#fde68a_30%,var(--panel))]"
? "bg-[color-mix(in_srgb,var(--pin)_9%,var(--panel))] border-[color-mix(in_srgb,var(--pin)_30%,var(--line))] hover:bg-[color-mix(in_srgb,var(--pin)_14%,var(--panel))]"
: "bg-[var(--panel)] border-[var(--line)] hover:bg-[color-mix(in_srgb,var(--ink)_3.5%,var(--panel))]"
}`}
style={{ boxShadow: CARD_SHADOW }}
@@ -45,10 +46,11 @@ async function PostCardItem({ post, allowComments }: { post: PostListItem; allow
{pinned && (
<span
className="badge-pin shrink-0 mt-0.5"
data-scope="global"
title="置顶"
aria-label="置顶"
>
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
<h2
@@ -59,7 +61,7 @@ async function PostCardItem({ post, allowComments }: { post: PostListItem; allow
</h2>
{post.recommended && (
<span className="badge-rec shrink-0 mt-0.5" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Star size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import Cropper, { type Area } from "react-easy-crop";
import "react-easy-crop/react-easy-crop.css";
import {
@@ -10,29 +10,25 @@ import {
apiMyMedia, apiUploadBrand,
type MediaAttachment,
} from "@/lib/api";
import { getCroppedBrandWebp } from "@/lib/cropImage";
import { getCroppedBrandWebp, getFullBrandWebp } from "@/lib/cropImage";
import Modal from "@/components/Modal";
import { brandFileOk, type BrandSlot, type BrandLogoSize } from "@/lib/brand";
import BrandLockup from "@/components/BrandLockup";
import { AdminSegmented } from "@/components/admin";
import {
BRAND_LOGO_FITS,
BRAND_LOGO_SIZES,
BRAND_LOGO_SIZE_ASPECT,
brandFileOk,
brandLogoBox,
suggestLogoSize,
type BrandLogoFit,
type BrandLogoSize,
type BrandSlot,
} from "@/lib/brand";
// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准)
const SOURCE_MAX_BYTES = 15 * 1024 * 1024;
// 品牌图槽位到默认裁剪比例的映射
const SLOT_ASPECT: Record<BrandSlot, number> = {
logo_light: 1, // 方标 1:1
logo_dark: 1, // 方标 1:1
favicon: 1, // Favicon 1:1
};
// 品牌图比例选项(与 BRAND_LOGO_SIZES 对应)
const ASPECT_OPTIONS: { id: BrandLogoSize; label: string; aspect: number }[] = [
{ id: "sq", label: "1:1", aspect: 1 },
{ id: "2x1", label: "2:1", aspect: 2 },
{ id: "3x1", label: "3:1", aspect: 3 },
{ id: "4x1", label: "4:1", aspect: 4 },
{ id: "16x9", label: "16:9", aspect: 16 / 9 },
];
interface BrandCropModalProps {
open: boolean;
slot: BrandSlot;
@@ -45,10 +41,19 @@ interface BrandCropModalProps {
seedUrl?: string;
/** 已消费 seedFile / seedUrl */
onSeedConsumed?: () => void;
/** 当前显示画幅(与工具栏同源,受控) */
logoSize?: BrandLogoSize;
/** 当前铺法(与工具栏同源,受控) */
logoFit?: BrandLogoFit;
/** 画幅变更回调(同步到父组件工具栏) */
onSizeChange?: (size: BrandLogoSize) => void;
/** 铺法变更回调(同步到父组件工具栏) */
onFitChange?: (fit: BrandLogoFit) => void;
}
export default function BrandCropModal({
open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed,
logoSize = "sq", logoFit = "contain", onSizeChange, onFitChange,
}: BrandCropModalProps) {
const [media, setMedia] = useState<MediaAttachment[]>([]);
const [imageSrc, setImageSrc] = useState<string | null>(null);
@@ -56,11 +61,18 @@ export default function BrandCropModal({
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [pixelCrop, setPixelCrop] = useState<Area | null>(null);
const [aspect, setAspect] = useState<number>(SLOT_ASPECT[slot] || 1);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 已识别过比例的图片(每张图进入裁剪时只自动推荐一次画幅)
const detectedSrcRef = useRef<string | null>(null);
// Favicon 固定方形,不参与画幅/铺法;auto=原图比例自由裁剪
const isFavicon = slot === "favicon";
const effectiveSize: BrandLogoSize = isFavicon ? "sq" : logoSize;
const isAuto = effectiveSize === "auto";
const cropAspect: number | undefined = isFavicon ? 1 : BRAND_LOGO_SIZE_ASPECT[effectiveSize];
// 打开时拉取媒体库
useEffect(() => {
@@ -71,6 +83,23 @@ export default function BrandCropModal({
.catch(() => setMedia([]));
}, [open]);
// 图片进入裁剪后,按原始比例自动推荐画幅(差距大→原图自适应),每张图仅一次
useEffect(() => {
if (!imageSrc || isFavicon || detectedSrcRef.current === imageSrc) return;
detectedSrcRef.current = imageSrc;
const img = new Image();
img.onload = () => {
onSizeChange?.(suggestLogoSize(img.naturalWidth, img.naturalHeight));
};
img.src = imageSrc;
}, [imageSrc, isFavicon, onSizeChange]);
// 画幅变化时重置构图位置(换画幅=重新构图;缩放保留)
useEffect(() => {
if (!open) return;
setCrop({ x: 0, y: 0 });
}, [effectiveSize, open]);
// 关闭时复位并释放本地 blob URL
useEffect(() => {
if (!open) {
@@ -82,10 +111,10 @@ export default function BrandCropModal({
setCrop({ x: 0, y: 0 });
setZoom(1);
setPixelCrop(null);
setAspect(SLOT_ASPECT[slot] || 1);
setError("");
setBusy(false);
setDragOver(false);
detectedSrcRef.current = null;
}
}, [open, slot]);
@@ -178,12 +207,15 @@ export default function BrandCropModal({
};
const confirmUpload = async () => {
if (!imageSrc || !pixelCrop || busy) return;
if (!imageSrc || busy) return;
if (!isAuto && !pixelCrop) return;
setBusy(true);
setError("");
try {
// 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB)
const blob = await getCroppedBrandWebp(imageSrc, pixelCrop, aspect);
// 原图模式:整图直传不裁剪;固定画幅:按裁剪区域导出
const blob = isAuto
? await getFullBrandWebp(imageSrc)
: await getCroppedBrandWebp(imageSrc, pixelCrop!, cropAspect!);
const file = new File([blob], "brand.webp", { type: "image/webp" });
const url = await apiUploadBrand(slot, file);
onCropped(url);
@@ -292,64 +324,166 @@ export default function BrandCropModal({
</>
) : (
<>
{/* 比例选择 */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>比例</span>
{ASPECT_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setAspect(opt.aspect)}
className={`px-2.5 py-1 rounded-md text-[12px] font-medium transition-colors ${
aspect === opt.aspect
? "bg-[var(--accent)] text-white"
: "bg-[var(--panel-2)] text-[var(--ink-2)] hover:bg-[var(--panel-3)]"
}`}
{/* 画幅:唯一的比例控件(同时决定裁剪比例与页眉图框);favicon 固定方形不展示 */}
{!isFavicon && (
<div className="mb-3">
<div className="flex items-center gap-1.5 mb-1.5">
<span className="text-[12px] font-semibold" style={{ color: "var(--ink-3)" }}>画幅</span>
<span className="text-[11px]" style={{ color: "var(--ink-3)" }}>已按图片比例推荐,可手动调整</span>
</div>
<div className="admin-brand-size-row" role="group" aria-label="显示画幅">
{BRAND_LOGO_SIZES.map((item) => {
const on = effectiveSize === item.id;
return (
<button
key={item.id}
type="button"
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
onClick={() => onSizeChange?.(item.id)}
>
<span className="admin-brand-size-chip-label">{item.label}</span>
<span className="admin-brand-size-chip-px">
{item.w ? `${item.w}×${item.h}` : "自适应"}
</span>
</button>
);
})}
</div>
</div>
)}
{/* 原图模式:整图直传,不裁剪;固定画幅:裁剪 */}
{isAuto ? (
<div
className="relative w-full h-72 rounded-xl overflow-hidden flex items-center justify-center p-4"
style={{ background: cropBg }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageSrc || ""}
alt="整图预览"
className="max-w-full max-h-full object-contain"
style={{ borderRadius: 6 }}
/>
<span
className="absolute left-2.5 top-2.5 text-[11px] px-1.5 py-0.5 rounded"
style={{
background: "rgba(0,0,0,.45)",
color: "#fff",
}}
>
{opt.label}
</button>
))}
</div>
整图使用,不裁剪
</span>
</div>
) : (
<>
<div
className="relative w-full h-72 rounded-xl overflow-hidden"
style={{ background: cropBg }}
>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={cropAspect}
cropShape="rect"
showGrid={true}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
{/* 裁剪区 */}
{/* 缩放 + 重置 */}
<div className="flex items-center gap-3 mt-4">
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={15} /></span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }}
aria-label="缩放"
/>
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={19} /></span>
<button
type="button"
onClick={() => {
setZoom(1);
setCrop({ x: 0, y: 0 });
}}
className="btn btn-line btn-sm shrink-0"
title="重置缩放与位置"
>
<RotateCcw size={13} /> 重置
</button>
</div>
</>
)}
{/* 铺法:仅固定画幅需要;原图模式图框随图,无需铺法 */}
{!isFavicon && (
isAuto ? (
<p className="text-[11.5px] mt-3" style={{ color: "var(--ink-3)" }}>
原图模式:整图使用不裁剪,页眉图框按图片原始比例自动给宽,图片完整不变形。
</p>
) : (
<div className="mt-3">
<div className="flex items-center justify-between gap-3">
<span className="text-[12px] font-semibold shrink-0" style={{ color: "var(--ink-3)" }}>铺法</span>
<AdminSegmented
role="group"
size="sm"
ariaLabel="适配方式"
value={logoFit}
onChange={(v) => onFitChange?.(v)}
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
/>
</div>
<p className="text-[11.5px] mt-1.5" style={{ color: "var(--ink-3)" }}>
{logoFit === "contain"
? "适应:完整显示图片,比例不匹配时留空(推荐)"
: logoFit === "cover"
? "填充:裁切填满画幅"
: logoFit === "repeat"
? "平铺:重复铺满画幅"
: "拉伸:变形填满画幅"}
</p>
</div>
)
)}
{/* 页眉实时预览:深色 Logo 用深色衬底并局部反转文字变量 */}
<div
className="relative w-full h-72 rounded-xl overflow-hidden"
style={{ background: cropBg }}
className="mt-3 rounded-xl px-3.5 py-2.5 flex items-center min-h-[56px]"
style={{
background: cropBg,
...(slot === "logo_dark"
? ({ "--ink": "#f5f6f8", "--ink-3": "rgba(255,255,255,.55)" } as CSSProperties)
: null),
}}
aria-label="页眉预览"
>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={aspect}
cropShape="rect"
showGrid={true}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
<BrandLockup
mode="image_text"
size={effectiveSize}
fit={logoFit}
name="站点名称"
wordmark=""
logoLight={imageSrc || ""}
logoDark=""
/>
</div>
{/* 缩放 */}
<div className="flex items-center gap-3 mt-4">
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={15} /></span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }}
aria-label="缩放"
/>
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={19} /></span>
</div>
{/* 输出说明 + 操作 */}
<div className="flex items-center justify-between mt-5">
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>
输出 WebP,高度 ≤ 2048px
{isAuto
? "输出 WebP · 高度 40px,宽度随原图比例"
: `输出 WebP · 图框 ${brandLogoBox(effectiveSize).w}×${brandLogoBox(effectiveSize).h}px`}
</span>
<div className="flex items-center gap-2">
<button type="button" onClick={backToLibrary} disabled={busy} className="btn btn-line btn-sm">

View File

@@ -29,6 +29,8 @@ export default function BrandLockup({
const showImage = mode !== "text";
const showText = mode !== "image";
const box = brandLogoBox(size);
// auto 图槽宽度随图(可达最宽画幅量级),按 176 取密度变体保证清晰
const cssPx = size === "auto" ? 176 : Math.max(box.w, box.h);
const src = logoLight || logoDark;
const both = !!(logoLight && logoDark && logoLight !== logoDark);
const useBox = showImage && (!!src || frame);
@@ -41,16 +43,17 @@ export default function BrandLockup({
className="j13-brand-slot"
data-frame={frame ? "1" : undefined}
data-fit={src ? fit : undefined}
style={{ width: box.w, height: box.h }}
data-size={size}
style={{ width: box.w || undefined, height: box.h }}
>
{src ? (
both ? (
<>
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" cssPx={Math.max(box.w, box.h)} />
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" cssPx={Math.max(box.w, box.h)} />
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" cssPx={cssPx} />
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" cssPx={cssPx} />
</>
) : (
<Logo src={src} fit={fit} alt={label} decorative={showText} cssPx={Math.max(box.w, box.h)} />
<Logo src={src} fit={fit} alt={label} decorative={showText} cssPx={cssPx} />
)
) : (
<Bubble h={box.h} label={showText ? undefined : label} />

View File

@@ -55,9 +55,9 @@ export default function FavoriteButton({
variant === "compact"
? "px-2.5 py-1.5 gap-1 text-[12.5px]"
: variant === "article"
? "px-7 py-3 gap-2 text-[15px] min-w-[132px]"
? "px-5 py-2.5 gap-1.5 text-[14px]"
: "px-4 py-2 gap-1.5 text-sm";
const iconSize = variant === "compact" ? 13 : variant === "article" ? 18 : 15;
const iconSize = variant === "compact" ? 13 : variant === "article" ? 16 : 15;
return (
<button
@@ -66,12 +66,7 @@ export default function FavoriteButton({
data-tip={favorited ? "取消收藏" : "收藏"}
aria-label={favorited ? "取消收藏" : "收藏"}
aria-pressed={favorited}
className={`inline-flex items-center justify-center tabular-nums rounded-full border transition-colors duration-150 disabled:opacity-50 ${
favorited
? "border-[var(--accent)] text-[var(--accent)] bg-[color-mix(in_srgb,var(--accent)_10%,var(--panel))]"
: "border-[var(--line-2)] text-[var(--ink-2)] hover:border-[var(--accent)] hover:text-[var(--accent)]"
} ${sizeCls}`}
style={!favorited ? { background: "var(--panel)" } : undefined}
className={`j13-fav inline-flex items-center justify-center tabular-nums border disabled:opacity-50 ${variant === "article" ? "j13-like-article" : ""} ${favorited ? "is-favorited" : ""} ${sizeCls}`}
>
<Bookmark size={iconSize} fill={favorited ? "currentColor" : "none"} />
{variant === "article" && <span>{favorited ? "已收藏" : "收藏"}</span>}

View File

@@ -20,6 +20,7 @@ import {
MessageSquare,
X,
ExternalLink,
CodeXml,
} from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
@@ -37,7 +38,7 @@ import BadgeChip from "./BadgeChip";
import BrandLockup from "./BrandLockup";
import { useAllowMessages } from "./MessagesPolicyProvider";
import { useSiteBrand, useUrlStyle } from "./SiteBrandProvider";
import { leaderboardPath, libraryPath } from "@/lib/urlStyle";
import { leaderboardPath, libraryPath, snippetsPath } from "@/lib/urlStyle";
import {
HEADER_NAV_BUILTIN_LABELS,
isHeaderNavExternal,
@@ -62,6 +63,8 @@ export default function Header({
brandMark = "image_text",
brandLogoSize = "sq",
brandLogoFit = "contain",
slogan = "",
sloganVisible = true,
}: {
initialUser: User | null;
initialUnread?: number;
@@ -74,12 +77,15 @@ export default function Header({
brandMark?: BrandMark;
brandLogoSize?: BrandLogoSize;
brandLogoFit?: BrandLogoFit;
slogan?: string;
sloganVisible?: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(urlStyle);
const libHref = libraryPath(urlStyle);
const snipHref = snippetsPath(urlStyle);
// 顶部主导航由后台「基本信息 → 顶部导航」配置(顺序/显隐/自定义链接),
// 配置为空时整个 nav 不渲染(含左侧分隔线);移动端导航走底部公共菜单
const headerNav = useSiteBrand().header_nav;
@@ -87,16 +93,22 @@ export default function Header({
home: <Home size={15} />,
leaderboard: <Trophy size={15} />,
library: <BookOpen size={15} />,
snippets: <CodeXml size={15} />,
};
const builtinNavHref: Record<HeaderNavBuiltinKey, string> = {
home: "/",
leaderboard: lbHref,
library: libHref,
snippets: snipHref,
};
const isBuiltinNavActive = (key: HeaderNavBuiltinKey): boolean => {
if (!pathname) return false;
if (key === "home") return pathname === "/";
if (key === "leaderboard") return pathname === lbHref || pathname.startsWith(`${lbHref}/`);
if (key === "snippets") {
// 片段详情标准路径与伪静态(/snippet-*.html)统一按前缀高亮
return pathname.startsWith("/snippets") || pathname.startsWith("/snippet-");
}
// 书库详情/阅读页标准路径与伪静态(/library-*.html)统一按 /library 前缀高亮
return pathname.startsWith("/library");
};
@@ -274,6 +286,7 @@ export default function Header({
fit={brandLogoFit}
name={siteName}
wordmark={wordmark}
slogan={sloganVisible ? slogan : ""}
logoLight={logoLight}
logoDark={logoDark}
/>

View File

@@ -64,9 +64,9 @@ export default function LikeButton({
variant === "compact"
? "px-2.5 py-1.5 gap-1 text-[12.5px]"
: variant === "article"
? "px-7 py-3 gap-2 text-[15px] min-w-[148px]"
? "px-5 py-2.5 gap-1.5 text-[14px]"
: "px-4 py-2 gap-1.5 text-sm";
const iconSize = variant === "compact" ? 13 : variant === "article" ? 18 : 15;
const iconSize = variant === "compact" ? 13 : variant === "article" ? 16 : 15;
return (
<button
@@ -75,7 +75,7 @@ export default function LikeButton({
data-tip={liked ? "取消点赞" : "点赞"}
aria-label={liked ? "取消点赞" : "点赞"}
aria-pressed={liked}
className={`j13-like inline-flex items-center justify-center tabular-nums rounded-full border disabled:opacity-50 ${liked ? "is-liked" : ""} ${sizeCls}`}
className={`j13-like inline-flex items-center justify-center tabular-nums border disabled:opacity-50 ${variant === "article" ? "j13-like-article" : ""} ${liked ? "is-liked" : ""} ${sizeCls}`}
>
<span key={String(liked)} className={liked ? "j13-heart-pop inline-flex" : "inline-flex"}>
<IconHeart size={iconSize} filled={liked} />

View File

@@ -9,6 +9,7 @@ import MdTimeline from "@/components/markdown/MdTimeline";
import MdEntries from "@/components/markdown/MdEntries";
import MdDetails from "@/components/markdown/MdDetails";
import MdVideo from "@/components/markdown/MdVideo";
import MdSnippet from "@/components/markdown/MdSnippet";
type MarkdownBodyProps = {
content: string;
@@ -92,6 +93,10 @@ export default async function MarkdownBody({
nodes.push(<MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />);
continue;
}
if (seg.type === "snippet") {
nodes.push(<MdSnippet key={`snippet-${i}`} id={seg.id} />);
continue;
}
const kind = seg.kind as HideKind;
if (seg.locked) {
nodes.push(

View File

@@ -113,6 +113,18 @@ export default function MarkdownBodyClient({
if (seg.type === "video") {
return <MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />;
}
if (seg.type === "snippet") {
// 客户端预览(发帖/评论草稿)无法 SSR 取数,渲染占位卡提示
return (
<div
key={`snippet-${i}`}
className="my-3 flex items-center gap-2 rounded-xl px-3 py-2.5 text-[13px]"
style={{ border: "1px dashed var(--line-2)", color: "var(--ink-3)" }}
>
代码片段 #{seg.id}(发布后可见)
</div>
);
}
const kind = seg.kind as HideKind;
if (seg.locked) {
return (

View File

@@ -45,6 +45,7 @@ import { toast } from "@/lib/toast";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import ImageInsertModal from "@/components/ImageInsertModal";
import TimelineEditor from "@/components/TimelineEditor";
import SnippetPickerModal from "@/components/SnippetPickerModal";
import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
import EmojiPicker from "@/components/EmojiPicker";
import { resolveTimelineBlockForEdit, parseVideoInput } from "@/lib/directiveBlocks";
@@ -260,6 +261,7 @@ export default function MarkdownEditor({
const [activeWheel, setActiveWheel] = useState<"fg" | "bg" | null>(null);
const [colorHsv, setColorHsv] = useState<HSV>({ h: 0, s: 1, v: 1 });
const [videoInput, setVideoInput] = useState("");
const [snippetPickerOpen, setSnippetPickerOpen] = useState(false);
const [toolNarrow, setToolNarrow] = useState(false);
const [uploadError, setUploadError] = useState("");
const [modKey, setModKey] = useState("Ctrl");
@@ -1670,6 +1672,7 @@ export default function MarkdownEditor({
{!isComment && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("video")}>视频…</button>
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); setSnippetPickerOpen(true); }}>代码片段…</button>
{toolNarrow && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
)}
@@ -1712,6 +1715,13 @@ export default function MarkdownEditor({
{popover === "video" && videoPopover}
</div>
)}
{!isComment && !toolNarrow && (
<ToolBtn
label="代码片段"
icon={<Code2 size={15} />}
onClick={() => setSnippetPickerOpen(true)}
/>
)}
{allowHide && !toolNarrow ? (
<div className="j13-md-popover-anchor relative">
<ToolBtn
@@ -1897,6 +1907,12 @@ export default function MarkdownEditor({
onInsertUrl={insertImageUrl}
onPickMedia={(item) => insertAtCursor(`![图片](${item.url})`)}
/>
<SnippetPickerModal
open={snippetPickerOpen}
onClose={() => setSnippetPickerOpen(false)}
onPick={(id) => insertAtCursor(`[snippet id=${id}]`)}
/>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { Fragment, useEffect, useState } from "react";
import {
Home,
BookOpen,
CodeXml,
PenSquare,
MessageSquare,
User as UserIcon,
@@ -14,7 +15,7 @@ import { apiChatUnreadSummary, type User } from "@/lib/api";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { useSiteBrand } from "@/components/SiteBrandProvider";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { userPath, libraryPath } from "@/lib/urlStyle";
import { userPath, libraryPath, snippetsPath } from "@/lib/urlStyle";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
import type { MobileTabKey } from "@/lib/mobileTabs";
@@ -36,6 +37,7 @@ export default function MobileTabBar({
const allowMessages = useAllowMessages();
const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand();
const libHref = libraryPath(urlStyle);
const snipHref = snippetsPath(urlStyle);
const [chatUnread, setChatUnread] = useState(initialChatUnread);
// 登录态经 router.refresh() 下发新 props 时校正徽标
@@ -100,6 +102,16 @@ export default function MobileTabBar({
active={isActive((p) => p === libHref)}
/>
),
snippets: (
<TabLink
href={snipHref}
icon={<CodeXml size={20} />}
label="片段"
active={isActive(
(p) => p === snipHref || p.startsWith("/snippets") || p.startsWith("/snippet-"),
)}
/>
),
compose: (
<TabLink
href={user ? "/compose" : "/login"}

View File

@@ -15,7 +15,7 @@ function formatTime(dateStr: string) {
/**
* 管理员置顶区:独立于普通帖子列表。
* 陶土色语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。
* 焦橙语义(与 badge-pin 一致),紧凑排列,一眼识别为公告性质内容。
*/
export default async function PinnedPosts({
posts,
@@ -32,11 +32,11 @@ export default async function PinnedPosts({
<div className="flex items-center gap-2 mb-2.5 px-1">
<span
className="w-6 h-6 rounded-full flex items-center justify-center"
style={{ background: "var(--clay-soft)", color: "var(--clay)" }}
style={{ background: "var(--pin-soft)", color: "var(--pin)" }}
>
<Pin size={12} fill="currentColor" strokeWidth={2.4} />
<Pin size={12} fill="none" strokeWidth={2} />
</span>
<h2 className="text-[13.5px] font-extrabold tracking-wide" style={{ color: "var(--clay)" }}>
<h2 className="text-[13.5px] font-extrabold tracking-wide" style={{ color: "var(--pin)" }}>
置顶讨论
</h2>
<span className="meta text-xs">管理员置顶 · {posts.length}</span>
@@ -78,7 +78,7 @@ export default async function PinnedPosts({
title="推荐"
aria-label="推荐"
>
<Star size={10} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Star size={10} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
</div>
@@ -94,9 +94,9 @@ export default async function PinnedPosts({
<Pin
size={13}
className="shrink-0 hidden sm:block"
style={{ color: "var(--clay)" }}
fill="currentColor"
strokeWidth={2.2}
style={{ color: "var(--pin)" }}
fill="none"
strokeWidth={2}
/>
</Link>
))}

View File

@@ -46,7 +46,7 @@ export default function PostActions({
const [user, setUser] = useState<User | null>(null);
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [isPinned, setIsPinned] = useState(pinned > 0);
const [pinLevel, setPinLevel] = useState(pinned);
const [isRecommended, setIsRecommended] = useState(recommended);
const [isLocked, setIsLocked] = useState(locked);
const [acting, setActing] = useState(false);
@@ -89,6 +89,13 @@ export default function PostActions({
const showEdit = canManage && !(isLocked && !staffUser) && !editTimeLocked;
const canApprove =
!!user && status === "pending" && canModerateBoard(user, boardId);
// 置顶分级:管理员及以上可设全局置顶(2)与板块置顶(1);板块管理员只在其授权板块做板块置顶。
// 已是全站置顶的帖子,板块管理员不可操作,故不展示入口。
const canGlobalPin = isAdmin;
const canBoardPin = !!user && canModerateBoard(user, boardId);
const showGlobalPin = canGlobalPin;
const showBoardPin = canBoardPin && (isAdmin || pinLevel < 2);
const showPin = showGlobalPin || showBoardPin;
// staff 删他人帖:走类型+理由;作者自删:简单确认
const staffDeletingOther =
!!user && user.id !== authorId && canStaffSoftDelete(user, boardId, authorRole);
@@ -126,16 +133,24 @@ export default function PostActions({
}
};
const handlePin = async () => {
const handlePin = async (level: 1 | 2) => {
setActing(true);
setOpen(false);
try {
const res = await apiTogglePin(postId);
setIsPinned(res.pinned > 0);
toast(res.pinned > 0 ? "已置顶" : "已取消置顶", "ok");
// 再次点击当前级别 = 取消置顶
const next = pinLevel === level ? 0 : level;
const res = await apiTogglePin(postId, next as 0 | 1 | 2);
setPinLevel(res.pinned);
const okMsg =
res.pinned <= 0
? "已取消置顶"
: res.pinned === 2
? "已全局置顶"
: "已在本板块置顶";
toast(okMsg, "ok");
router.refresh();
} catch {
toast("操作失败,请稍后重试");
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败,请稍后重试");
} finally {
setActing(false);
}
@@ -220,25 +235,44 @@ export default function PostActions({
<div style={{ height: 1, background: "var(--line)" }} />
</>
)}
{showPin && (
<>
{showGlobalPin && (
<button
type="button"
role="menuitem"
onClick={() => void handlePin(2)}
className={itemCls}
style={pinLevel === 2 ? { color: "var(--pin)" } : { color: "var(--ink)" }}
>
<Pin size={14} className="shrink-0" />
<span className="flex-1 text-left">全局置顶</span>
{pinLevel === 2 && <Check size={14} className="shrink-0" />}
</button>
)}
{showBoardPin && (
<button
type="button"
role="menuitem"
onClick={() => void handlePin(1)}
className={itemCls}
style={pinLevel === 1 ? { color: "var(--pin)" } : { color: "var(--ink)" }}
>
<Pin size={14} className="shrink-0" />
<span className="flex-1 text-left">板块置顶</span>
{pinLevel === 1 && <Check size={14} className="shrink-0" />}
</button>
)}
</>
)}
{isAdmin && (
<>
<button
type="button"
role="menuitem"
onClick={handlePin}
className={itemCls}
style={isPinned ? { color: "var(--accent)" } : { color: "var(--ink)" }}
>
<Pin size={14} className="shrink-0" />
<span className="flex-1 text-left">{isPinned ? "取消置顶" : "置顶"}</span>
{isPinned && <Check size={14} className="shrink-0" />}
</button>
<button
type="button"
role="menuitem"
onClick={handleRecommend}
className={itemCls}
style={isRecommended ? { color: "var(--gold)" } : { color: "var(--ink)" }}
style={isRecommended ? { color: "var(--rec)" } : { color: "var(--ink)" }}
>
<Star size={14} className="shrink-0" />
<span className="flex-1 text-left">{isRecommended ? "取消推荐" : "推荐"}</span>

View File

@@ -50,7 +50,7 @@ export default async function PostRow({
return (
<div
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--item-hover-bg)] focus-within:bg-[var(--item-hover-bg)] j13-fade-up ${
className={`group relative flex items-center px-4 sm:px-5 border-t cursor-pointer transition-colors hover:bg-[var(--item-hover-bg)] focus-within:bg-[var(--item-hover-bg)] j13-post-row j13-fade-up ${
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
}`}
style={{
@@ -73,13 +73,18 @@ export default async function PostRow({
<div className="flex items-center gap-2 min-w-0">
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && (
<span className="badge-pin shrink-0" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<span
className="badge-pin shrink-0"
data-scope={post.pinned >= 2 ? "global" : "board"}
title={post.pinned >= 2 ? "置顶" : "板块置顶"}
aria-label={post.pinned >= 2 ? "置顶" : "板块置顶"}
>
<Pin size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
{post.recommended && (
<span className="badge-rec shrink-0" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
<Star size={12} fill="none" strokeWidth={2} aria-hidden />
</span>
)}
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden">

View File

@@ -149,7 +149,6 @@ export default function RoleAccountModal({
const messagesChanged =
canGrantSuper &&
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN &&
manageMessages !== !!target.can_manage_messages;
/** 渠道权限可编辑性跟随弹窗中选中的角色:管理员及以上自带全部渠道 */
const channelEditable = roleLevel(role) < ROLE_LEVEL[ROLES.ADMIN];
@@ -462,48 +461,46 @@ export default function RoleAccountModal({
</div>
)}
{canGrantSuper &&
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN && (
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
消息管理
</p>
<button
type="button"
role="switch"
aria-checked={manageMessages}
onClick={() => setManageMessages((v) => !v)}
className="w-full rounded-xl px-3.5 py-3 text-left transition-colors flex items-center justify-between gap-3"
style={{
border: `1.5px solid ${manageMessages ? "var(--accent)" : "var(--line)"}`,
background: manageMessages ? "var(--accent-soft)" : "var(--panel-2)",
}}
>
<span>
<span
className="block text-[13.5px] font-medium"
style={{ color: manageMessages ? "var(--accent)" : "var(--ink)" }}
>
站点群消息管理
</span>
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可在后台管理全部群聊消息(不含私聊);站长/超管固有全站监管无需此项。
</span>
</span>
{canGrantSuper && target.role !== ROLES.OWNER && (
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
消息管理
</p>
<button
type="button"
role="switch"
aria-checked={manageMessages}
onClick={() => setManageMessages((v) => !v)}
className="w-full rounded-xl px-3.5 py-3 text-left transition-colors flex items-center justify-between gap-3"
style={{
border: `1.5px solid ${manageMessages ? "var(--accent)" : "var(--line)"}`,
background: manageMessages ? "var(--accent-soft)" : "var(--panel-2)",
}}
>
<span>
<span
className="shrink-0 w-10 h-6 rounded-full relative transition-colors"
style={{ background: manageMessages ? "var(--accent)" : "var(--line)" }}
aria-hidden
className="block text-[13.5px] font-medium"
style={{ color: manageMessages ? "var(--accent)" : "var(--ink)" }}
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
style={{ left: manageMessages ? "1.125rem" : "0.125rem" }}
/>
站点群消息管理
</span>
</button>
</div>
)}
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
可在后台管理全部群聊消息(不含私聊);超管授予后另获全站会话(含私聊)监管;站长固有无需此项。
</span>
</span>
<span
className="shrink-0 w-10 h-6 rounded-full relative transition-colors"
style={{ background: manageMessages ? "var(--accent)" : "var(--line)" }}
aria-hidden
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
style={{ left: manageMessages ? "1.125rem" : "0.125rem" }}
/>
</span>
</button>
</div>
)}
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>

View File

@@ -2,7 +2,7 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { apiGetSettings, type PublicSettings } from "@/lib/api";
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeBrandSloganVisible, normalizeFooterLinks } from "@/lib/brand";
import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle";
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "@/lib/headerNav";
@@ -19,6 +19,7 @@ export type SiteBrand = {
brand_mark: PublicSettings["brand_mark"];
brand_logo_size: PublicSettings["brand_logo_size"];
brand_logo_fit: PublicSettings["brand_logo_fit"];
brand_slogan_visible: boolean;
footer_links: PublicSettings["footer_links"];
url_style: UrlStyle;
mobile_tabs: MobileTabKey[];
@@ -38,6 +39,7 @@ function fromSettings(s: PublicSettings): SiteBrand {
brand_mark: s.brand_mark,
brand_logo_size: s.brand_logo_size,
brand_logo_fit: s.brand_logo_fit,
brand_slogan_visible: s.brand_slogan_visible,
footer_links: s.footer_links,
url_style: s.url_style,
mobile_tabs: s.mobile_tabs,
@@ -56,6 +58,7 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
a.brand_mark === b.brand_mark &&
a.brand_logo_size === b.brand_logo_size &&
a.brand_logo_fit === b.brand_logo_fit &&
a.brand_slogan_visible === b.brand_slogan_visible &&
a.url_style === b.url_style &&
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) &&
JSON.stringify(a.mobile_tabs) === JSON.stringify(b.mobile_tabs) &&
@@ -108,6 +111,7 @@ export function useSiteBrand(): SiteBrand {
brand_mark: "image_text",
brand_logo_size: "sq",
brand_logo_fit: "contain",
brand_slogan_visible: true,
footer_links: [],
url_style: "default",
mobile_tabs: [...DEFAULT_MOBILE_TABS],
@@ -159,6 +163,10 @@ export default function SiteBrandProvider({
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
brand_slogan_visible:
raw.brand_slogan_visible !== undefined
? normalizeBrandSloganVisible(raw.brand_slogan_visible)
: prev.brand_slogan_visible,
footer_links: Array.isArray(raw.footer_links)
? normalizeFooterLinks(raw.footer_links)
: prev.footer_links,

View File

@@ -7,7 +7,7 @@ import Footer from "@/components/Footer";
import BackToTop from "@/components/BackToTop";
import MobileTabBar from "@/components/MobileTabBar";
import type { User } from "@/lib/api";
import { libraryPath, userPath } from "@/lib/urlStyle";
import { libraryPath, snippetsPath, userPath } from "@/lib/urlStyle";
import { useSiteBrand } from "@/components/SiteBrandProvider";
function pathIsAdmin(pathname: string | null) {
@@ -88,6 +88,7 @@ export default function SiteChrome({
const tabBarRoots: string[] = [];
if (enabledTabs.has("home")) tabBarRoots.push("/");
if (enabledTabs.has("library")) tabBarRoots.push(libraryPath(brand.url_style));
if (enabledTabs.has("snippets")) tabBarRoots.push(snippetsPath(brand.url_style));
if (enabledTabs.has("messages")) tabBarRoots.push("/chat");
if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref);
const showTabBar =
@@ -106,6 +107,8 @@ export default function SiteChrome({
brandMark={brand.brand_mark}
brandLogoSize={brand.brand_logo_size}
brandLogoFit={brand.brand_logo_fit}
slogan={brand.site_slogan}
sloganVisible={brand.brand_slogan_visible}
/>
<main
id="main"

View File

@@ -0,0 +1,197 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { GitFork, History, Pencil, Trash2, Copy, Check } from "lucide-react";
import { apiForkSnippet, apiDeleteSnippet, apiMe } from "@/lib/api";
import type { SnippetDetail } from "@/lib/api";
import { toast } from "@/lib/toast";
import { snippetPath, snippetsPath, type UrlStyle } from "@/lib/urlStyle";
import Modal from "@/components/Modal";
import SnippetVersionModal from "@/components/SnippetVersionModal";
export default function SnippetActions({
snippet,
urlStyle,
isOwner: initialIsOwner,
isStaff: initialIsStaff,
}: {
snippet: SnippetDetail;
urlStyle: UrlStyle;
isOwner: boolean;
isStaff: boolean;
}) {
const router = useRouter();
const [isOwner, setIsOwner] = useState(initialIsOwner);
const [isStaff, setIsStaff] = useState(initialIsStaff);
const [forking, setForking] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
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 (
<>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={onCopyLink}
className={btnBase}
style={btnNormal}
title="复制片段链接"
>
{copied ? <Check size={13.5} /> : <Copy size={13.5} />}
{copied ? "已复制" : "复制链接"}
</button>
{snippet.visibility === "public" && (
<button
type="button"
onClick={onFork}
disabled={forking}
className={btnBase}
style={btnNormal}
title="把这份代码复制为你的私有副本,可任意修改,互不影响"
>
<GitFork size={13.5} />
{forking ? "创建副本中…" : "Fork 副本"}
</button>
)}
<button
type="button"
onClick={() => setHistoryOpen(true)}
className={btnBase}
style={btnNormal}
title="查看版本历史"
>
<History size={13.5} /> 版本历史
</button>
{isOwner && (
<Link
href={`/snippets/${snippet.id}/edit`}
className={btnBase}
style={btnNormal}
title="编辑片段"
>
<Pencil size={13.5} /> 编辑
</Link>
)}
{(isOwner || isStaff) && (
<button
type="button"
onClick={() => setDeleteOpen(true)}
className={btnBase}
style={{ ...btnNormal, color: "var(--danger)" }}
title="删除片段"
>
<Trash2 size={13.5} /> 删除
</button>
)}
</div>
<Modal
open={deleteOpen}
title="删除片段"
description="删除后不可恢复,确定要删除这个代码片段吗?"
onClose={() => setDeleteOpen(false)}
dismissOnOverlay
maxWidthClass="max-w-sm"
footer={
<>
<button type="button" className="btn btn-line btn-sm" onClick={() => setDeleteOpen(false)}>
取消
</button>
<button
type="button"
className="btn btn-primary btn-sm"
style={{ background: "var(--danger)", borderColor: "var(--danger)" }}
onClick={onDelete}
disabled={deleting}
>
{deleting ? "删除中…" : "确认删除"}
</button>
</>
}
>
<p className="text-[13.5px]" style={{ color: "var(--ink-2)" }}>
此操作不可撤销。
</p>
</Modal>
<SnippetVersionModal
open={historyOpen}
onClose={() => setHistoryOpen(false)}
snippetId={snippet.id}
currentCode={snippet.code}
currentTitle={snippet.title}
currentLanguage={snippet.language}
currentDescription={snippet.description}
/>
</>
);
}

View File

@@ -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<string, string>; lines: string[] } | null {
const codeM = /<code[^>]*>([\s\S]*?)<\/code>/i.exec(html);
if (!codeM) return null;
const vars: Record<string, string> = {};
const preM = /<pre[^>]*\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 (
<span className="inline-flex items-center gap-1.5 shrink-0" aria-hidden>
<span className="w-2.5 h-2.5 rounded-full" style={{ background: "#ff5f57" }} />
<span className="w-2.5 h-2.5 rounded-full" style={{ background: "#febc2e" }} />
<span className="w-2.5 h-2.5 rounded-full" style={{ background: "#28c840" }} />
</span>
);
}
/** 片段代码视图:编辑器窗格 + 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<string, string>; lines: string[] } | null>(null);
const [maxH, setMaxH] = useState<number | undefined>(undefined);
const bodyRef = useRef<HTMLDivElement>(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 (
<div
className="rounded-xl overflow-hidden"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
{/* 窗格标题栏 */}
<div
className="flex items-center gap-2.5 px-3.5 h-10"
style={{ borderBottom: "1px solid var(--line)" }}
>
<WindowDots />
<span className="text-[11px] truncate" style={{ fontFamily: MONO, color: "var(--ink-3)" }}>
{fileName}
</span>
<span className="text-[10.5px] tabular-nums hidden sm:inline" style={{ color: "var(--ink-3)" }}>
{lineCount} 行
</span>
<span
className="ml-auto shrink-0 text-[9.5px] font-semibold uppercase tracking-[0.1em] px-1.5 py-0.5 rounded"
style={{ fontFamily: MONO, color: "var(--ink-3)", background: "var(--panel)" }}
>
{language || "text"}
</span>
<button
type="button"
onClick={onCopy}
aria-label={copied ? "已复制" : "复制代码"}
title="复制代码"
className="shrink-0 h-7 px-2.5 inline-flex items-center gap-1.5 rounded-md text-[11.5px] font-medium transition-colors"
style={{
color: copied ? "var(--accent)" : "var(--ink-3)",
background: "var(--panel)",
border: "1px solid var(--line)",
}}
>
{copied ? <Check size={12.5} /> : <Copy size={12.5} />}
<span className="hidden sm:inline">{copied ? "已复制" : "复制"}</span>
</button>
</div>
{/* 代码主体 */}
<div
ref={bodyRef}
className="relative overflow-hidden"
style={{
maxHeight: canCollapse ? maxH ?? collapsedH : undefined,
transition: "max-height 360ms cubic-bezier(0.22, 1, 0.36, 1)",
}}
>
<pre
className={`j13-sn-code m-0 text-[13px] overflow-x-auto ${highlighted ? "" : "whitespace-pre"}`}
style={{
fontFamily: MONO,
lineHeight: `${LINE_H}px`,
padding: `${PAD_Y}px 0`,
color: "var(--ink-2)",
...(hl?.vars ?? {}),
} as React.CSSProperties}
>
{rows.map((ln, i) => (
<div key={i} className="flex min-w-max">
<span
className="shrink-0 w-10 pr-3 text-right select-none tabular-nums text-[11.5px]"
style={{
color: "var(--ink-3)",
opacity: 0.55,
marginRight: 12,
borderRight: "1px solid var(--line)",
lineHeight: `${LINE_H}px`,
}}
>
{i + 1}
</span>
{highlighted ? (
<span
className="pr-5"
style={{ lineHeight: `${LINE_H}px` }}
dangerouslySetInnerHTML={{ __html: ln || " " }}
/>
) : (
<span className="pr-5" style={{ lineHeight: `${LINE_H}px` }}>
{ln || " "}
</span>
)}
</div>
))}
</pre>
{canCollapse && !expanded && (
<div
className="absolute bottom-0 left-0 right-0 h-10 pointer-events-none"
style={{ background: "linear-gradient(to top, var(--panel-2), transparent)" }}
/>
)}
</div>
{canCollapse && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full h-9 flex items-center justify-center gap-1.5 text-[12px] font-medium transition-colors hover:bg-[var(--accent-soft)]"
style={{
color: expanded ? "var(--ink-3)" : "var(--accent)",
borderTop: "1px solid var(--line)",
}}
>
{expanded ? (
<>
<ChevronUp size={14} /> 收起
</>
) : (
<>
<ChevronDown size={14} /> 展开全部({lineCount} 行)
</>
)}
</button>
)}
</div>
);
}

View File

@@ -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<number>).detail;
if (typeof next === "number") setCount(next);
};
const name = snippetCommentCountEvent(snippetId);
window.addEventListener(name, handler);
return () => window.removeEventListener(name, handler);
}, [snippetId]);
return (
<span
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full text-[11.5px] tabular-nums"
style={{ background: "var(--panel-2)", color: "var(--ink-3)", border: "1px solid var(--line)" }}
title={`${count} ${label}`}
>
<span style={{ color: "var(--ink-3)" }}>{icon}</span>
{count}
<span className="hidden sm:inline">{label}</span>
</span>
);
}

View File

@@ -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<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(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 (
<div className="cmt-more" ref={ref}>
<button
ref={btnRef}
type="button"
className="cmt-more-btn"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
更多
</button>
{open && (
<div role="menu" className="cmt-more-menu" aria-label="评论操作">
{normal.map((item) => (
<button
key={item.key}
type="button"
role="menuitem"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
>
{item.label}
</button>
))}
{normal.length > 0 && danger.length > 0 ? <div className="cmt-more-sep" role="separator" /> : null}
{danger.map((item) => (
<button
key={item.key}
type="button"
role="menuitem"
className="is-danger"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
>
{item.label}
</button>
))}
</div>
)}
</div>
);
}
function CommentNode({
item,
depth,
snippetId,
onUpdate,
onDelete,
onOpenHistory,
loggedIn,
}: {
item: SnippetCommentItem;
depth: number;
snippetId: number;
onUpdate: (id: number, content: string) => Promise<void>;
onDelete: (id: number) => Promise<void>;
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<number | null>(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 ? (
<button
type="button"
className="cmt-edited"
onClick={() => onOpenHistory(item.id, item.content)}
>
已编辑
</button>
) : (
<span className="cmt-edited" data-tip="此评论曾被编辑">
已编辑
</span>
)
) : null;
if (item.deleted) {
return (
<div
id={item.floor ? `comment-${item.floor}` : undefined}
data-comment-id={item.id}
className={`cmt-item scroll-mt-24 ${isFloor ? "" : "cmt-reply-row"}`}
>
<div className="flex gap-3">
<Avatar size={isFloor ? 34 : 32} name={userName} src={item.user?.avatar} />
<div className="flex-1 min-w-0">
<div className="cmt-meta">
<div className="cmt-meta-lead">
<span className="cmt-name" style={{ color: "var(--ink)" }}>
{userName}
</span>
{item.floor && item.floor > 0 && (
<a href={`#comment-${item.floor}`} className="cmt-floor-label">
#{item.floor}
</a>
)}
<span className="cmt-time">{formatRelative(item.created_at)}</span>
</div>
</div>
<div className="cmt-tombstone">
<p className="cmt-tombstone-title">该评论已删除</p>
</div>
<div className="cmt-actions">
<CommentMoreMenu items={moreItems} />
</div>
{item.replies && item.replies.length > 0 && (
<div className="cmt-children">
{item.replies.map((r) => (
<CommentNode
key={r.id}
item={r}
depth={depth + 1}
snippetId={snippetId}
onUpdate={onUpdate}
onDelete={onDelete}
onOpenHistory={onOpenHistory}
loggedIn={loggedIn}
/>
))}
</div>
)}
</div>
</div>
<ConfirmDialog
open={deleteTarget}
title="删除评论"
message="确定删除这条评论吗?删除后将显示占位,回复仍保留。"
confirmLabel="删除"
danger
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(false)}
/>
</div>
);
}
return (
<div
id={item.floor ? `comment-${item.floor}` : undefined}
data-comment-id={item.id}
className={`cmt-item scroll-mt-24 ${isFloor ? "" : "cmt-reply-row"}`}
>
<div className="flex gap-3">
<Link
href={userPath(urlStyle, item.user_id)}
className="shrink-0"
aria-label={userName}
>
<Avatar size={isFloor ? 34 : 32} name={userName} src={item.user?.avatar} />
</Link>
<div className="flex-1 min-w-0">
<div className="cmt-meta">
<div className="cmt-meta-lead">
<Link href={userPath(urlStyle, item.user_id)} className="cmt-name-link group">
<span className="cmt-name group-hover:text-[var(--accent)] transition-colors">
{userName}
</span>
</Link>
{item.floor && item.floor > 0 && (
<a href={`#comment-${item.floor}`} className="cmt-floor-label">
#{item.floor}
</a>
)}
<time className="cmt-time" dateTime={item.created_at} data-tip={formatDateTime(item.created_at)}>
{formatRelative(item.created_at)}
</time>
{editedMark}
</div>
</div>
{editing ? (
<div className="cmt-md-editor mt-1">
<MarkdownEditor
density="comment"
value={editContent}
onChange={(v) => setEditContent(v.slice(0, COMMENT_MAX))}
maxChars={COMMENT_MAX}
disabled={submitting}
autoFocus
allowHide={false}
ariaLabel="编辑评论"
placeholder="编辑评论…"
shortcutName="保存"
onUploadingChange={setEditUploading}
onSubmitShortcut={() => {
void handleEdit();
}}
footer={
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={submitting}
onClick={() => {
setEditing(false);
setEditContent(item.content);
}}
>
取消
</button>
<button
type="button"
onClick={handleEdit}
disabled={submitting || editUploading || !editContent.trim()}
className="btn btn-primary cmt-submit"
>
{submitting ? "保存中…" : "保存修改"}
</button>
</>
}
/>
</div>
) : (
<MarkdownBodyClient content={item.content} className="prose-content prose-comment" />
)}
<div className="cmt-actions">
{!editing && loggedIn && (
<button
type="button"
onClick={() => setReplyOpen(!replyOpen)}
className={`cmt-reply-btn${replyOpen ? " !text-[var(--accent)]" : ""}`}
aria-label={`回复 ${userName}`}
>
回复
</button>
)}
<CommentMoreMenu items={moreItems} />
</div>
{replyOpen && !editing && (
<form
onSubmit={(e) => {
e.preventDefault();
void handleReply();
}}
className="cmt-reply-form"
>
<div className="flex items-center justify-between gap-2 mb-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
<span>
回复 <span style={{ color: "var(--accent)", fontWeight: 600 }}>@{userName}</span>
</span>
<button type="button" className="cmt-reply-btn" onClick={() => setReplyOpen(false)}>
取消
</button>
</div>
<div className="cmt-md-editor">
<MarkdownEditor
density="comment"
value={replyContent}
onChange={(v) => setReplyContent(v.slice(0, COMMENT_MAX))}
maxChars={COMMENT_MAX}
disabled={submitting}
autoFocus
allowHide={false}
ariaLabel={`回复 ${userName}`}
placeholder="写下你的回复…"
shortcutName="回复"
onUploadingChange={setReplyUploading}
onSubmitShortcut={() => {
void handleReply();
}}
footer={
<button
type="submit"
disabled={submitting || replyUploading || !replyContent.trim()}
aria-busy={submitting}
className="btn btn-primary cmt-submit"
>
{submitting ? "回复中…" : "回复"}
</button>
}
/>
</div>
</form>
)}
{item.replies && item.replies.length > 0 && (
<div className="cmt-children">
{item.replies.map((r) => (
<CommentNode
key={r.id}
item={r}
depth={depth + 1}
snippetId={snippetId}
onUpdate={onUpdate}
onDelete={onDelete}
onOpenHistory={onOpenHistory}
loggedIn={loggedIn}
/>
))}
</div>
)}
</div>
</div>
<ConfirmDialog
open={deleteTarget}
title="删除评论"
message="确定删除这条评论吗?删除后将显示占位,回复仍保留。"
confirmLabel="删除"
danger
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(false)}
/>
</div>
);
}
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<number | null>(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 (
<div>
<h2 className="text-[15px] font-bold mb-4 inline-flex items-center gap-2" style={{ color: "var(--ink)" }}>
<MessageSquare size={15} style={{ color: "var(--accent)" }} />
评论({total})
</h2>
{loggedIn ? (
<div className="mb-6 cmt-md-editor">
<MarkdownEditor
id={`snippet-comment-input-${snippetId}`}
density="comment"
value={content}
onChange={(v) => setContent(v.slice(0, COMMENT_MAX))}
maxChars={COMMENT_MAX}
disabled={submitting}
allowHide={false}
ariaLabel="发表评论"
placeholder="写下你的看法,支持 Markdown…"
onUploadingChange={setUploading}
onSubmitShortcut={() => {
void handleSubmit();
}}
footer={
<button
type="button"
onClick={handleSubmit}
disabled={submitting || uploading || !content.trim()}
className="btn btn-primary cmt-submit inline-flex items-center gap-1.5"
>
<Send size={13} />
{submitting ? "发送中…" : "发表评论"}
</button>
}
/>
</div>
) : (
<div
className="mb-6 rounded-xl px-4 py-6 text-center"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<p className="text-[13px]" style={{ color: "var(--ink-3)" }}>
<Link href="/login" className="hover:underline" style={{ color: "var(--accent)" }}>
登录
</Link>{" "}
后参与评论
</p>
</div>
)}
{loading ? (
<p className="meta text-[13px] text-center py-8">加载中…</p>
) : items.length === 0 ? (
<div
className="rounded-xl py-10 text-center"
style={{ background: "var(--panel-2)", border: "1px dashed var(--line-2)" }}
>
<MessageSquare size={20} className="mx-auto mb-2" style={{ color: "var(--ink-3)" }} />
<p className="text-[13px]" style={{ color: "var(--ink-3)" }}>
还没有评论,来抢沙发吧
</p>
</div>
) : (
<div className="flex flex-col divide-y" style={{ borderColor: "var(--line)" }}>
{items.map((item) => (
<CommentNode
key={item.id}
item={item}
depth={0}
snippetId={snippetId}
onUpdate={handleUpdate}
onDelete={handleDelete}
onOpenHistory={openHistory}
loggedIn={loggedIn}
/>
))}
</div>
)}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-2">
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => loadComments(page - 1)}
className="btn btn-line btn-sm"
>
上一页
</button>
<span className="meta text-[12.5px] tabular-nums">
{page} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages || loading}
onClick={() => loadComments(page + 1)}
className="btn btn-line btn-sm"
>
下一页
</button>
</div>
)}
<EditHistoryModal
open={historyCommentId !== null}
title="评论编辑历史"
currentContent={historyCommentContent}
fetchPage={fetchHistoryPage}
onClose={() => setHistoryCommentId(null)}
/>
</div>
);
}

View File

@@ -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<SnippetVisibility>(initialVisibility);
const [languages, setLanguages] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const codeRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
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 (
<form onSubmit={handleSubmit} className="j13-compose-stage is-studio">
<header className="j13-compose-toolbar">
<div className="j13-compose-toolbar-lead">
<button
type="button"
onClick={() => router.back()}
className="j13-compose-back"
aria-label="返回"
data-tip="返回"
>
<ArrowLeft size={18} strokeWidth={2.2} />
</button>
<div className="j13-compose-toolbar-copy min-w-0">
<h1 className="j13-compose-toolbar-title">
{mode === "create" ? "新建片段" : "编辑片段"}
</h1>
<p className="j13-compose-toolbar-sub">
{mode === "create" ? "保存一段可复用的代码" : "修改片段内容"}
</p>
</div>
</div>
<div className="j13-compose-toolbar-actions">
{mode === "edit" && (
<button
type="button"
onClick={() => setDeleteOpen(true)}
className="btn btn-line btn-sm"
style={{ color: "var(--danger)" }}
>
<Trash2 size={14} /> 删除
</button>
)}
<button
type="submit"
disabled={!canSubmit}
className="btn btn-primary min-w-[5.5rem] h-9 px-4 text-[13.5px] font-semibold"
>
{loading ? "保存中…" : mode === "create" ? "创建" : "保存"}
</button>
</div>
</header>
<div className="j13-compose-studio">
<div className="j13-compose-studio-canvas">
{/* 标题 */}
<div className="j13-compose-title-row">
<label className="sr-only" htmlFor="snippet-title">
标题
</label>
<input
id="snippet-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value.slice(0, TITLE_MAX))}
required
maxLength={TITLE_MAX}
placeholder="片段标题"
className="j13-compose-title"
disabled={loading}
/>
<span className="j13-compose-title-count tabular-nums">
{title.length}/{TITLE_MAX}
</span>
</div>
{/* 语言 + 可见性 */}
<div className="flex flex-wrap items-center gap-3 px-4 sm:px-6 py-3">
<div className="flex items-center gap-2">
<CodeXml size={14} style={{ color: "var(--ink-3)" }} />
<SnippetLangSelect
value={language}
onChange={setLanguage}
languages={languages}
allowAll={false}
variant="editor"
ariaLabel="编程语言"
disabled={loading}
/>
</div>
<div className="flex items-center gap-1 rounded-full p-0.5" style={{ background: "var(--panel-2)" }}>
{VISIBILITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setVisibility(opt.value)}
className={`px-3 py-1 rounded-full text-[12px] font-medium transition-colors ${
visibility === opt.value ? "" : "hover:bg-[var(--panel)]"
}`}
style={
visibility === opt.value
? { background: "var(--accent-soft)", color: "var(--accent)" }
: { color: "var(--ink-2)" }
}
disabled={loading}
>
{opt.value === "public" ? <Eye size={12} className="inline mr-1" /> : <EyeOff size={12} className="inline mr-1" />}
{opt.label}
</button>
))}
</div>
<span className="meta text-[11.5px]">
{VISIBILITY_OPTIONS.find((o) => o.value === visibility)?.desc}
</span>
</div>
{/* 代码编辑区 */}
<div className="j13-compose-canvas">
<label className="sr-only" htmlFor="snippet-code">
代码
</label>
<textarea
ref={codeRef}
id="snippet-code"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={handleCodeKeyDown}
placeholder="粘贴或输入代码…"
className="w-full min-h-[320px] px-4 py-3 text-[13.5px] font-mono resize-y"
style={{
background: "var(--bg)",
border: "1px solid var(--line)",
color: "var(--ink)",
borderRadius: "var(--r)",
tabSize: 2,
}}
disabled={loading}
/>
<div className="mt-1 flex justify-end">
<span
className="meta text-[11px] tabular-nums"
style={codeBytes > CODE_MAX ? { color: "var(--danger)", fontWeight: 600 } : undefined}
>
{codeBytes > 1024 ? `${(codeBytes / 1024).toFixed(1)} KB` : `${codeBytes} B`} / 64 KB
</span>
</div>
</div>
{/* 描述 */}
<div className="px-4 sm:px-6 pb-6">
<label className="field-label" htmlFor="snippet-desc">
描述(可选)
</label>
<textarea
id="snippet-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="用 Markdown 描述这段代码的用途、用法或注意事项…"
rows={4}
className="w-full rounded-lg px-4 py-3 text-[13.5px]"
style={{
background: "var(--bg)",
border: "1px solid var(--line)",
color: "var(--ink)",
}}
disabled={loading}
/>
<div className="mt-1 flex justify-end">
<span
className="meta text-[11px] tabular-nums"
style={descBytes > DESC_MAX ? { color: "var(--danger)", fontWeight: 600 } : undefined}
>
{descBytes > 1024 ? `${(descBytes / 1024).toFixed(1)} KB` : `${descBytes} B`} / 8 KB
</span>
</div>
</div>
</div>
</div>
<Modal
open={deleteOpen}
title="删除片段"
description="删除后不可恢复,确定要删除这个代码片段吗?"
onClose={() => setDeleteOpen(false)}
dismissOnOverlay
maxWidthClass="max-w-sm"
footer={
<>
<button type="button" className="btn btn-line btn-sm" onClick={() => setDeleteOpen(false)}>
取消
</button>
<button
type="button"
className="btn btn-primary btn-sm"
style={{ background: "var(--danger)", borderColor: "var(--danger)" }}
onClick={handleDelete}
disabled={deleting}
>
{deleting ? "删除中…" : "确认删除"}
</button>
</>
}
>
<p className="text-[13.5px]" style={{ color: "var(--ink-2)" }}>
此操作不可撤销。
</p>
</Modal>
</form>
);
}

View File

@@ -0,0 +1,582 @@
"use client";
import { useMemo, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import {
Search,
X,
Copy,
Eye,
MessageSquare,
GitFork,
Plus,
Lock,
ArrowLeft,
ArrowRight,
RotateCcw,
} from "lucide-react";
import type { SnippetListItem, SnippetSort, SnippetStats } from "@/lib/api";
import { fetchSnippets, apiMySnippets } from "@/lib/api";
import { snippetPath, type UrlStyle } from "@/lib/urlStyle";
import { formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
import { toast } from "@/lib/toast";
import { siteCodeToHtml } from "@/components/markdown/shikiHighlighter";
import SnippetLangSelect from "@/components/SnippetLangSelect";
import SnippetHero from "@/components/SnippetHero";
import { snippetLangColor } from "@/lib/snippetLangColors";
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
function plainExcerpt(md: string): string {
return md
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[*_~`#>]+/g, "")
.replace(/\s+/g, " ")
.trim();
}
/** 编辑器窗格的三色圆点(灰阶令牌,明暗主题都不穿帮) */
function WindowDots() {
return (
<span className="inline-flex items-center gap-[5px] shrink-0" aria-hidden>
<span className="w-[7px] h-[7px] rounded-full" style={{ background: "var(--ink-3)", opacity: 0.55 }} />
<span className="w-[7px] h-[7px] rounded-full" style={{ background: "var(--ink-3)", opacity: 0.35 }} />
<span className="w-[7px] h-[7px] rounded-full" style={{ background: "var(--ink-3)", opacity: 0.2 }} />
</span>
);
}
/** 语言标识:小圆点 + 等宽语言名 */
function LangTag({ lang }: { lang: string }) {
const c = snippetLangColor(lang);
return (
<span className="inline-flex items-center gap-1.5 min-w-0">
<span
className="w-[6px] h-[6px] rounded-full shrink-0"
style={{ background: c, boxShadow: `0 0 0 2px color-mix(in srgb, ${c} 22%, transparent)` }}
/>
<span
className="text-[10.5px] font-semibold uppercase tracking-[0.08em] truncate"
style={{ fontFamily: MONO, color: c }}
>
{lang}
</span>
</span>
);
}
/**
* 从 shiki 输出提取高亮信息:
* - 双主题 CSS 变量定义在 <pre style> 上(--shiki-light/--shiki-dark …),必须保留
* - <code> 内部按行拆分自绘 gutter
*/
function extractShiki(html: string): { vars: Record<string, string>; lines: string[] } | null {
const codeM = /<code[^>]*>([\s\S]*?)<\/code>/i.exec(html);
if (!codeM) return null;
const vars: Record<string, string> = {};
const preM = /<pre[^>]*\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") };
}
const PREVIEW_LINES = 5;
/** 代码预览:shiki 客户端高亮 + 自绘行号 gutter;未着色前回退纯文本 */
function SnippetPreview({ code, language }: { code: string; language: string }) {
const [hl, setHl] = useState<{ vars: Record<string, string>; lines: string[] } | null>(null);
const plainLines = useMemo(() => code.split("\n").slice(0, PREVIEW_LINES), [code]);
const longPreview = code.split("\n").length > PREVIEW_LINES;
useEffect(() => {
let cancelled = false;
const head = code.split("\n").slice(0, PREVIEW_LINES).join("\n");
siteCodeToHtml(head, language)
.then((html) => {
if (cancelled) return;
const parsed = extractShiki(html);
// shiki 末行通常带尾换行产生空串,截到预览行数
if (parsed && parsed.lines.length > 0) {
setHl({ vars: parsed.vars, lines: parsed.lines.slice(0, PREVIEW_LINES) });
}
})
.catch(() => {
/* 保持纯文本 */
});
return () => {
cancelled = true;
};
}, [code, language]);
const rows = hl?.lines ?? plainLines;
const highlighted = hl !== null;
return (
<div className="relative">
<pre
className={`j13-sn-code m-0 text-[11.5px] leading-[1.7] overflow-hidden max-h-[98px] ${
highlighted ? "" : "whitespace-pre"
}`}
style={{
fontFamily: MONO,
color: "var(--ink-2)",
// 继承 shiki 双主题变量,token span 的 var(--shiki-*) 才能解析
...(hl?.vars ?? {}),
} as React.CSSProperties}
>
{rows.map((ln, i) => (
<div key={i} className="flex min-w-0">
<span
className="shrink-0 w-6 pr-2 text-right select-none tabular-nums"
style={{ color: "var(--ink-3)", opacity: 0.45, borderRight: "1px solid var(--line)", marginRight: 9 }}
>
{i + 1}
</span>
{highlighted ? (
<span className="truncate min-w-0" dangerouslySetInnerHTML={{ __html: ln || " " }} />
) : (
<span className="truncate">{ln || " "}</span>
)}
</div>
))}
</pre>
{longPreview ? (
<div
className="absolute bottom-0 left-0 right-0 h-7 pointer-events-none"
style={{ background: "linear-gradient(to top, var(--panel-2), transparent)" }}
/>
) : null}
</div>
);
}
/** 自定义语言下拉:见共享组件 SnippetLangSelect */
function Stat({ icon, value, label }: { icon: React.ReactNode; value: number; label: string }) {
return (
<span className="inline-flex items-center gap-1" title={label}>
<span style={{ color: "var(--ink-3)" }}>{icon}</span>
<span className="tabular-nums" style={{ color: "var(--ink-2)" }}>
{value}
</span>
</span>
);
}
function SnippetCard({ item, urlStyle }: { item: SnippetListItem; urlStyle: UrlStyle }) {
const ownerName = item.user ? item.user.nickname || item.user.username : "";
const excerpt = plainExcerpt(item.description ?? "");
const href = snippetPath(urlStyle, item.id);
const langColor = snippetLangColor(item.language);
return (
<Link
href={href}
className="j13-snc group flex flex-col h-full min-w-0 rounded-xl overflow-hidden transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:shadow-[var(--shadow-lg)] hover:border-[color-mix(in_srgb,var(--accent)_45%,var(--line))]"
style={{ background: "var(--panel)", border: "1px solid var(--line)", ["--sn-lang" as string]: langColor }}
>
{/* 卡头:语言 + 标题 + 徽标 */}
<div className="px-3.5 pt-3.5 pb-2.5">
<div className="flex items-center gap-2 min-w-0">
<LangTag lang={item.language} />
<span className="ml-auto inline-flex items-center gap-1 shrink-0">
{item.visibility === "private" && (
<span
className="inline-flex items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded-md"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<Lock size={9} /> 私有
</span>
)}
{item.forked_from_id ? (
<span
className="inline-flex items-center gap-0.5 text-[10px] font-medium px-1.5 py-0.5 rounded-md"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<GitFork size={9} /> Fork
</span>
) : null}
</span>
</div>
<h2
className="mt-2 text-[14px] font-bold leading-snug line-clamp-2 group-hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{item.title}
</h2>
{excerpt ? (
<p className="mt-1 text-[12px] leading-relaxed line-clamp-1" style={{ color: "var(--ink-3)" }}>
{excerpt}
</p>
) : null}
</div>
{/* 代码窗格:标题栏 + gutter + shiki 高亮 */}
<div
className="mx-3.5 rounded-lg overflow-hidden"
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<div
className="flex items-center gap-2 px-2.5 py-1.5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<WindowDots />
<span className="text-[10px] truncate" style={{ fontFamily: MONO, color: "var(--ink-3)" }}>
snippet.{item.language === "plaintext" ? "txt" : item.language}
</span>
<span
className="ml-auto shrink-0 text-[9px] font-semibold uppercase tracking-[0.1em] px-1.5 py-px rounded"
style={{ fontFamily: MONO, color: langColor, background: `color-mix(in srgb, ${langColor} 12%, transparent)` }}
>
{item.language}
</span>
</div>
<div className="px-0 pt-1.5 pb-2">
<SnippetPreview code={item.preview ?? ""} language={item.language} />
</div>
</div>
{/* 卡脚:统计 + 作者 */}
<div
className="mt-auto px-3.5 py-2.5 flex items-center gap-3 text-[11px]"
style={{ borderTop: "1px solid var(--line)" }}
>
<Stat icon={<Copy size={11} />} value={item.copy_count} label="复制数" />
<Stat icon={<Eye size={11} />} value={item.view_count} label="浏览数" />
<Stat icon={<MessageSquare size={11} />} value={item.comment_count} label="评论数" />
<Stat icon={<GitFork size={11} />} value={item.fork_count} label="Fork 数" />
<span className="ml-auto inline-flex items-center gap-1.5 min-w-0 shrink-0">
{item.user ? (
<>
<Avatar size={15} name={ownerName} src={item.user.avatar} />
<span className="truncate max-w-[4.5rem]" style={{ color: "var(--ink-3)" }} title={ownerName}>
{ownerName}
</span>
<span style={{ color: "var(--ink-3)" }}>·</span>
</>
) : null}
<span className="shrink-0" style={{ color: "var(--ink-3)" }}>
{formatRelative(item.created_at)}
</span>
</span>
</div>
</Link>
);
}
const SORT_OPTIONS: { value: SnippetSort; label: string }[] = [
{ value: "latest", label: "最新" },
{ value: "copy", label: "复制" },
{ value: "comment", label: "评论" },
{ value: "fork", label: "Fork" },
];
export default function SnippetExplorer({
initialItems,
initialTotal,
languages,
urlStyle,
stats,
isLoggedIn,
}: {
initialItems: SnippetListItem[];
initialTotal: number;
languages: string[];
urlStyle: UrlStyle;
stats: SnippetStats | null;
isLoggedIn: boolean;
}) {
const [items, setItems] = useState(initialItems);
const [total, setTotal] = useState(initialTotal);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const [lang, setLang] = useState("");
const [sort, setSort] = useState<SnippetSort>("latest");
const [scope, setScope] = useState<"public" | "mine">("public");
const pageSize = 12;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const filtering = !!query || (scope === "public" && !!lang);
const load = useCallback(
async (p: number) => {
setLoading(true);
try {
const res =
scope === "mine"
? await apiMySnippets(query, p, pageSize)
: await fetchSnippets({ q: query, lang, sort, page: p, size: pageSize });
setItems(res.items);
setTotal(res.total);
setPage(p);
} catch (e) {
toast(e instanceof Error ? e.message : "加载失败");
} finally {
setLoading(false);
}
},
[scope, query, lang, sort]
);
// 筛选条件变化时回到第一页
useEffect(() => {
const t = setTimeout(() => load(1), 300);
return () => clearTimeout(t);
}, [load]);
const switchScope = (next: "public" | "mine") => {
if (next === scope) return;
setScope(next);
setQuery("");
setLang("");
setSort("latest");
};
return (
<div>
{/* 未来科技感 Hero:网格 + 扫描线 + 统计矩阵 */}
<SnippetHero stats={stats} isLoggedIn={isLoggedIn} />
{/* 工具台:IDE 风格软底工具条,控件浮于其上(无框中框) */}
<div
className="rounded-xl p-1.5 flex flex-col sm:flex-row sm:items-center gap-1.5"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
{isLoggedIn && (
<div
className="flex items-center gap-0.5 rounded-lg p-0.5 shrink-0"
style={{ background: "var(--panel)" }}
role="group"
aria-label="片段范围"
>
{(
[
{ value: "public", label: "广场" },
{ value: "mine", label: "我的" },
] as const
).map((opt) => {
const active = scope === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => switchScope(opt.value)}
aria-pressed={active}
className="whitespace-nowrap px-3 h-8 rounded-md text-[12px] font-medium transition-colors"
style={
active
? {
background: "var(--accent-soft)",
color: "var(--accent)",
boxShadow: "inset 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent)",
}
: { color: "var(--ink-3)" }
}
>
{opt.label}
</button>
);
})}
</div>
)}
<div className="relative flex-1 min-w-0">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={scope === "mine" ? "搜索我的片段…" : "搜索标题、描述或代码…"}
aria-label="搜索片段"
className="j13-sn-search w-full h-9 rounded-lg pr-8 text-[13px]"
style={{ background: "var(--panel)", border: "1px solid transparent", color: "var(--ink)", paddingLeft: 34 }}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-md hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={13} />
</button>
) : null}
</div>
{scope === "public" && (
<SnippetLangSelect value={lang} onChange={setLang} languages={languages} />
)}
{scope === "public" && (
<div
className="flex items-center gap-0.5 rounded-lg p-0.5 shrink-0 overflow-x-auto"
style={{ background: "var(--panel)" }}
role="group"
aria-label="排序方式"
>
{SORT_OPTIONS.map((opt) => {
const active = sort === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setSort(opt.value)}
aria-pressed={active}
className="whitespace-nowrap px-2.5 h-8 rounded-md text-[12px] font-medium transition-colors"
style={
active
? {
background: "var(--accent-soft)",
color: "var(--accent)",
boxShadow: "inset 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent)",
}
: { color: "var(--ink-3)" }
}
>
{opt.label}
</button>
);
})}
</div>
)}
</div>
{/* 结果统计:纯文本行,切换范围时数据原地更新,无任何加载指示 */}
<div className="mt-3 flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
<span className="tabular-nums" style={{ fontFamily: MONO }}>
<span style={{ color: "var(--accent)" }}>$</span>{" "}
{scope === "mine" ? "ls ~/snippets --count" : "ls --count"}
</span>
<span>
共 <b className="tabular-nums" style={{ color: "var(--ink-2)" }}>{total}</b> 个片段
</span>
</div>
{/* 列表 / 空态:请求期间保留现有内容,新数据返回后原地替换,不闪空态 */}
{items.length === 0 ? (
filtering ? (
<div
className="mt-4 rounded-xl px-6 py-12 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<RotateCcw size={22} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />
<p className="text-[14px] font-semibold" style={{ color: "var(--ink-2)" }}>
没有找到匹配的片段
</p>
<button
type="button"
className="btn btn-line btn-sm mt-4"
onClick={() => {
setQuery("");
setLang("");
}}
>
清除筛选条件
</button>
</div>
) : (
<div
className="mt-4 max-w-lg mx-auto rounded-xl overflow-hidden"
style={{ background: "var(--panel)", border: "1px solid var(--line)", boxShadow: "var(--shadow-sm)" }}
>
<div
className="flex items-center gap-2 px-3.5 py-2"
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<WindowDots />
<span className="text-[10.5px]" style={{ fontFamily: MONO, color: "var(--ink-3)" }}>
snippets — zsh
</span>
</div>
<div className="px-6 py-10 text-center">
<p
className="inline-block text-left text-[12.5px] leading-[1.9]"
style={{ fontFamily: MONO, color: "var(--ink-2)" }}
>
<span style={{ color: "var(--accent)" }}>$</span>{" "}
{scope === "mine" ? "ls ~/snippets" : "snippets list --public"}
<br />
<span style={{ color: "var(--ink-3)" }}>
# {scope === "mine" ? "你还没有片段,新建一个或从广场 Fork" : "还没有公开片段"}
</span>
<span className="animate-pulse" style={{ color: "var(--accent)" }}>
▍
</span>
</p>
<p className="mt-5 text-[13.5px]" style={{ color: "var(--ink-2)" }}>
{scope === "mine" ? "Fork 的副本会默认设为私有,只在这里可见" : "成为第一个分享代码片段的人"}
</p>
<div className="mt-4 flex items-center justify-center gap-2">
{scope === "mine" ? (
<>
<Link href="/snippets/new" className="btn btn-primary btn-sm inline-flex items-center gap-1.5">
<Plus size={14} /> 新建片段
</Link>
<button
type="button"
onClick={() => switchScope("public")}
className="btn btn-line btn-sm"
>
去广场逛逛
</button>
</>
) : isLoggedIn ? (
<Link href="/snippets/new" className="btn btn-primary btn-sm inline-flex items-center gap-1.5">
<Plus size={14} /> 新建片段
</Link>
) : (
<Link href="/login?redirect=/snippets/new" className="btn btn-primary btn-sm">
登录后分享
</Link>
)}
</div>
</div>
</div>
)
) : (
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{items.map((item) => (
<SnippetCard key={item.id} item={item} urlStyle={urlStyle} />
))}
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="mt-7 flex items-center justify-center gap-3">
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => load(page - 1)}
className="btn btn-line btn-sm inline-flex items-center gap-1"
>
<ArrowLeft size={13} /> 上一页
</button>
<span className="text-[12px] tabular-nums" style={{ fontFamily: MONO, color: "var(--ink-3)" }}>
{page} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages || loading}
onClick={() => load(page + 1)}
className="btn btn-line btn-sm inline-flex items-center gap-1"
>
下一页 <ArrowRight size={13} />
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,258 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
CodeXml, Copy, Eye, MessageSquare, GitFork, Users,
Plus, Zap, ShieldCheck, Binary,
} from "lucide-react";
import type { SnippetStats } from "@/lib/api";
import { snippetLangColor } from "@/lib/snippetLangColors";
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
function useRollIn(target: number, duration = 1000) {
const [value, setValue] = useState(0);
useEffect(() => {
const start = performance.now();
let raf = 0;
const tick = (t: number) => {
const p = Math.min((t - start) / duration, 1);
const ease = 1 - Math.pow(1 - p, 3);
setValue(Math.round(target * ease));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [target, duration]);
return value;
}
function formatK(n: number) {
if (n >= 100_000) return `${(n / 1000).toFixed(0)}k`;
if (n >= 10_000) return `${(n / 1000).toFixed(0)}k`;
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
return `${n}`;
}
function StatBlock({
icon,
label,
value,
delay,
}: {
icon: React.ReactNode;
label: string;
value: string;
delay: number;
}) {
return (
<div
className="j13-hero-stat flex items-center gap-2.5 px-3 py-2 rounded-md"
style={{
animationDelay: `${delay}ms`,
background: "var(--hero-chip-bg)",
border: "1px solid var(--hero-line)",
}}
>
<span
className="w-7 h-7 flex items-center justify-center rounded-md shrink-0"
style={{ background: "var(--hero-chip-icon)" }}
>
<span style={{ color: "var(--accent)" }}>{icon}</span>
</span>
<div className="flex flex-col leading-none">
<span
className="text-[10px] font-medium tracking-wide uppercase"
style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}
>
{label}
</span>
<span
className="text-[17px] font-extrabold tabular-nums mt-0.5"
style={{ color: "var(--hero-ink)", fontFamily: MONO }}
>
{value}
</span>
</div>
</div>
);
}
function LangMatrix({
topLanguages,
total,
}: {
topLanguages: SnippetStats["top_languages"];
total: number;
}) {
const max = Math.max(1, ...topLanguages.map((l) => l.count));
return (
<div className="mt-4">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-medium tracking-wide uppercase" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
Language Matrix
</span>
<div className="flex items-center gap-1" style={{ color: "var(--hero-ink-3)" }}>
<ShieldCheck size={9} />
<span className="text-[9px]" style={{ fontFamily: MONO }}>
SECURE
</span>
</div>
</div>
<div className="flex flex-col gap-2">
{topLanguages.map((l, i) => {
const c = snippetLangColor(l.language);
const pct = total > 0 ? Math.round((l.count / total) * 100) : 0;
return (
<div key={l.language} className="flex items-center gap-2.5">
<span
className="w-[72px] shrink-0 text-[10px] font-semibold text-right tabular-nums truncate"
style={{ color: c, fontFamily: MONO }}
>
{l.language}
</span>
<div className="flex-1 h-[5px] rounded-full overflow-hidden" style={{ background: "var(--hero-track)" }}>
<div
className="j13-hero-langbar h-full rounded-full"
style={{
width: `${Math.max(3, (l.count / max) * 100)}%`,
background: `linear-gradient(90deg, ${c}, color-mix(in srgb, ${c}, transparent 40%))`,
animationDelay: `${300 + i * 80}ms`,
}}
/>
</div>
<span className="w-7 shrink-0 text-[10px] tabular-nums text-right" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
{pct}%
</span>
</div>
);
})}
</div>
</div>
);
}
/** 语言跑马灯:10 种热门语言 ×2 循环 */
function LangTicker({ langs }: { langs: string[] }) {
const doubled = [...langs, ...langs];
return (
<div className="j13-hero-ticker py-2" style={{ borderTop: "1px solid var(--hero-track)", borderBottom: "1px solid var(--hero-track)" }}>
<div className="j13-hero-ticker-track">
{doubled.map((l, i) => (
<span key={`${l}-${i}`} className="flex items-center gap-1.5 text-[11px] font-medium shrink-0" style={{ fontFamily: MONO, color: "var(--hero-ink-2)" }}>
<span className="inline-block w-[5px] h-[5px] rounded-full" style={{ background: snippetLangColor(l) }} />
{l}
</span>
))}
</div>
</div>
);
}
export default function SnippetHero({
stats,
isLoggedIn,
}: {
stats: SnippetStats | null;
isLoggedIn: boolean;
}) {
const s = stats;
const total = useRollIn(s?.total_snippets ?? 0);
const copies = useRollIn(s?.total_copies ?? 0);
const views = useRollIn(s?.total_views ?? 0);
const comments = useRollIn(s?.total_comments ?? 0);
const forks = useRollIn(s?.total_forks ?? 0);
const contributors = useRollIn(s?.contributors ?? 0);
// 跑马灯展示的热门语言(去重后取前 10,兜底硬编码)
const tickerLangs = Array.from(new Set([
...topLangNames(s?.top_languages),
"go", "rust", "typescript", "python", "javascript", "bash", "dockerfile", "kotlin", "yaml", "sql",
])).slice(0, 10);
return (
<section className="j13-hero relative mb-6 rounded-2xl overflow-hidden">
{/* 网格底纹 */}
<div className="absolute inset-0 j13-hero-grid pointer-events-none" />
{/* 扫描光束 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none" style={{ opacity: 0.5 }}>
<div className="j13-hero-scan" />
</div>
{/* 内容层 */}
<div className="relative px-5 pt-5 pb-5 sm:px-7 sm:pt-6 sm:pb-6">
{/* 顶部栏 */}
<div className="flex items-start justify-between gap-3 mb-5">
<div className="flex items-start gap-3 min-w-0">
<span
className="inline-flex items-center justify-center w-10 h-10 rounded-xl shrink-0"
style={{ background: "rgba(148,163,205,0.09)", border: "1px solid var(--hero-line)" }}
>
<CodeXml size={19} style={{ color: "var(--accent)" }} />
</span>
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[11px]" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
$ jiang13/snippets
</span>
<span className="text-[11px]" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
--public --summary
</span>
<span className="j13-hero-caret inline-block w-[6px] h-[3px] rounded-sm" style={{ background: "var(--accent)" }} />
</div>
<h1 className="j13-hero-title text-[22px] sm:text-[26px] font-extrabold tracking-tight leading-none mt-0.5">
代码片段广场
</h1>
</div>
</div>
<div className="shrink-0 flex items-center gap-2">
<span className="flex items-center gap-1.5 text-[10px] px-2 py-1 rounded-full" style={{ color: "var(--hero-ink-3)", fontFamily: MONO, background: "var(--hero-chip-bg)", border: "1px solid var(--hero-line)" }}>
<span className="inline-block w-2 h-2 rounded-full j13-hero-pulse" style={{ background: "#22c55e" }} />
ONLINE
</span>
{isLoggedIn && (
<Link href="/snippets/new" className="btn btn-primary btn-sm inline-flex items-center gap-1.5 shrink-0">
<Plus size={14} /> 新建
</Link>
)}
</div>
</div>
{/* 统计区 */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
<StatBlock icon={<Binary size={12} />} label="Snippets" value={formatK(total)} delay={0} />
<StatBlock icon={<Eye size={12} />} label="Views" value={formatK(views)} delay={60} />
<StatBlock icon={<Copy size={12} />} label="Copies" value={formatK(copies)} delay={120} />
<StatBlock icon={<GitFork size={12} />} label="Forks" value={formatK(forks)} delay={180} />
<StatBlock icon={<MessageSquare size={12} />} label="Comments" value={formatK(comments)} delay={240} />
<StatBlock icon={<Users size={12} />} label="Devs" value={formatK(contributors)} delay={300} />
</div>
{/* 语言矩阵 */}
{s && s.top_languages.length > 0 && <LangMatrix topLanguages={s.top_languages} total={s.total_snippets} />}
{/* 底栏 */}
<div className="mt-4 flex items-center justify-between">
<span className="flex items-center gap-1.5 text-[10px]" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
<Zap size={9} />
{s ? "DATA_SYNCED" : "OFFLINE"}
</span>
<span className="text-[10px] tabular-nums" style={{ color: "var(--hero-ink-3)", fontFamily: MONO }}>
LAT<span style={{ color: "var(--accent)" }}> 37.7749°N</span> LNG<span style={{ color: "var(--accent)" }}> 122.4194°W</span>
</span>
</div>
</div>
{/* 语言跑马灯 */}
<LangTicker langs={tickerLangs} />
</section>
);
}
function topLangNames(arr?: { language: string; count: number }[]) {
if (!arr) return [];
return arr.map((a) => a.language).filter(Boolean);
}

View File

@@ -0,0 +1,236 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, Search, X } from "lucide-react";
const MONO = "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)";
/**
* 语言选择自定义下拉(统一站点样式):
* - variant="toolbar":广场工具台(含"全部语言"与清除)
* - variant="editor":新建/编辑片段表单(药丸形,语言必选)
*/
export default function SnippetLangSelect({
value,
onChange,
languages,
allowAll = true,
variant = "toolbar",
ariaLabel = "选择语言",
allLabel = "全部语言",
disabled = false,
}: {
value: string;
onChange: (v: string) => void;
languages: string[];
allowAll?: boolean;
variant?: "toolbar" | "editor";
ariaLabel?: string;
allLabel?: string;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
setQ("");
const t = window.setTimeout(() => inputRef.current?.focus(), 30);
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
window.clearTimeout(t);
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const filtered = useMemo(() => {
const kw = q.trim().toLowerCase();
return kw ? languages.filter((l) => l.includes(kw)) : languages;
}, [q, languages]);
const triggerCls =
variant === "editor"
? "j13-sn-search h-8 rounded-full pl-3 pr-2.5 text-[12.5px] gap-1.5"
: "j13-sn-search w-full h-9 rounded-lg px-3 text-[12.5px] gap-2";
const wrapCls = variant === "editor" ? "relative shrink-0" : "relative shrink-0 sm:w-44 w-full";
const menuCls =
variant === "editor"
? "j13-sn-lang-menu absolute z-30 mt-1.5 left-0 w-56 rounded-xl overflow-hidden"
: "j13-sn-lang-menu absolute z-30 mt-1.5 left-0 right-0 sm:right-auto sm:w-60 rounded-xl overflow-hidden";
return (
<div ref={ref} className={wrapCls}>
<button
type="button"
onClick={() => !disabled && setOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={ariaLabel}
disabled={disabled}
className={`${triggerCls} flex items-center disabled:opacity-60 disabled:cursor-not-allowed`}
style={{ background: "var(--panel)", border: "1px solid transparent", color: "var(--ink)" }}
>
<span
className="w-[6px] h-[6px] rounded-full shrink-0"
style={{ background: value ? "var(--accent)" : "var(--ink-3)", opacity: value ? 1 : 0.5 }}
/>
<span
className="truncate text-left flex-1"
style={{ fontFamily: MONO, color: value ? "var(--ink)" : "var(--ink-3)" }}
>
{value || allLabel}
</span>
{allowAll && value ? (
<span
role="button"
tabIndex={-1}
aria-label="清除语言筛选"
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
className="w-5 h-5 -mr-1 flex items-center justify-center rounded-md hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={11} />
</span>
) : (
<ArrowDownIcon className="shrink-0" rotated={open} />
)}
</button>
{open && (
<div
className={menuCls}
style={{
background: "var(--panel)",
border: "1px solid var(--line-2)",
boxShadow: "var(--shadow-lg)",
}}
role="listbox"
>
<div className="p-2" style={{ borderBottom: "1px solid var(--line)" }}>
<div className="relative">
<Search
size={12}
className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
/>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="筛选语言…"
aria-label="筛选语言"
className="w-full h-8 rounded-md pl-7 pr-2 text-[12px] outline-none"
style={{ background: "var(--panel-2)", border: "1px solid transparent", color: "var(--ink)" }}
/>
</div>
</div>
<ul className="max-h-56 overflow-y-auto py-1">
{allowAll ? (
<LangOption
active={value === ""}
onClick={() => {
onChange("");
setOpen(false);
}}
label={allLabel}
dotDim
/>
) : null}
{filtered.length === 0 ? (
<li className="px-3 py-3 text-center text-[12px]" style={{ color: "var(--ink-3)" }}>
没有匹配的语言
</li>
) : (
filtered.map((l) => (
<LangOption
key={l}
active={value === l}
onClick={() => {
onChange(l);
setOpen(false);
}}
label={l}
mono
/>
))
)}
</ul>
</div>
)}
</div>
);
}
function ArrowDownIcon({ className = "", rotated }: { className?: string; rotated: boolean }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
className={`${className} ${rotated ? "rotate-180" : ""}`}
style={{ color: "var(--ink-3)", transition: "transform 160ms ease" }}
aria-hidden
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function LangOption({
active,
onClick,
label,
mono,
dotDim,
}: {
active: boolean;
onClick: () => void;
label: string;
mono?: boolean;
dotDim?: boolean;
}) {
return (
<li>
<button
type="button"
role="option"
aria-selected={active}
onClick={onClick}
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[12.5px] transition-colors hover:bg-[var(--panel-2)]"
style={{
color: active ? "var(--accent)" : "var(--ink-2)",
background: active ? "var(--accent-soft)" : undefined,
fontFamily: mono ? MONO : undefined,
}}
>
<span
className="w-[5px] h-[5px] rounded-full shrink-0"
style={{
background: active ? "var(--accent)" : "var(--ink-3)",
opacity: active ? 1 : dotDim ? 0.5 : 0.3,
}}
/>
<span className="flex-1 truncate">{label}</span>
{active ? <Check size={12} className="shrink-0" /> : null}
</button>
</li>
);
}

Some files were not shown because too many files have changed in this diff Show More