5 Commits

99 changed files with 8277 additions and 444 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.9
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

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

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

@@ -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"`
@@ -576,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"`

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

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

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

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

@@ -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 论坛向默认允许扩展名(含安装包/脚本/网页)
@@ -645,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

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

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

@@ -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);
@@ -5234,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%;
@@ -6425,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);
@@ -6434,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;
}
}
@@ -6518,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;
@@ -7134,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); }
@@ -9074,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);
@@ -10399,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

@@ -190,7 +190,7 @@ export default function RegisterClient({
/>
<button
type="button"
className="btn btn-line shrink-0"
className="btn btn-code shrink-0"
disabled={sending || countdown > 0 || !email}
onClick={async () => {
// 点击即锁定,防止重复发送
@@ -201,7 +201,7 @@ export default function RegisterClient({
email,
purpose: "register",
});
setSentMessage(r.message);
setSentMessage("验证码已发送,请查收邮箱");
startCountdown();
} catch (err) {
setError((err as Error).message);
@@ -211,7 +211,7 @@ export default function RegisterClient({
}}
>
{sending
? "正在排队…"
? "发送中…"
: countdown > 0
? `${countdown}s 后可重发`
: "发送验证码"}

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

@@ -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,
@@ -84,6 +85,7 @@ export default function Header({
const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(urlStyle);
const libHref = libraryPath(urlStyle);
const snipHref = snippetsPath(urlStyle);
// 顶部主导航由后台「基本信息 → 顶部导航」配置(顺序/显隐/自定义链接),
// 配置为空时整个 nav 不渲染(含左侧分隔线);移动端导航走底部公共菜单
const headerNav = useSiteBrand().header_nav;
@@ -91,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");
};

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

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

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

View File

@@ -0,0 +1,185 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CodeXml, Eye, GitFork, Copy, Search } from "lucide-react";
import Modal from "@/components/Modal";
import {
apiMySnippets,
apiSearchSnippets,
type SnippetListItem,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
type Tab = "public" | "mine";
/**
* 「插入片段」选择器:搜索公开广场 / 我的片段,点击插入 [snippet id=N] 指令。
*/
export default function SnippetPickerModal({
open,
onClose,
onPick,
}: {
open: boolean;
onClose: () => void;
onPick: (id: number) => void;
}) {
const [tab, setTab] = useState<Tab>("public");
const [q, setQ] = useState("");
const [items, setItems] = useState<SnippetListItem[]>([]);
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const seq = useRef(0);
// 打开时重置
useEffect(() => {
if (open) {
setTab("public");
setQ("");
}
}, [open]);
useEffect(() => {
if (!open) return;
const mySeq = ++seq.current;
setLoading(true);
const timer = setTimeout(() => {
const req =
tab === "mine" ? apiMySnippets(q.trim(), 1, 20) : apiSearchSnippets({ q: q.trim(), size: 20 });
req
.then((res) => {
if (seq.current !== mySeq) return;
setItems(res.items);
setLoaded(true);
})
.catch((e) => {
if (seq.current !== mySeq) return;
toast(e instanceof Error ? e.message : "加载失败");
})
.finally(() => {
if (seq.current === mySeq) setLoading(false);
});
}, 300);
return () => clearTimeout(timer);
}, [open, tab, q]);
return (
<Modal
open={open}
title="插入代码片段"
description="选择要嵌入的片段,正文中将插入 [snippet id=N] 指令"
onClose={onClose}
dismissOnOverlay
maxWidthClass="max-w-lg"
panelClassName="p-0 flex flex-col max-h-[80vh]"
bodyClassName="flex flex-col min-h-0"
>
<div className="px-5 pt-4 pb-3 flex items-center gap-2" style={{ borderBottom: "1px solid var(--line)" }}>
<div
className="flex rounded-lg p-0.5 text-[12.5px] font-semibold shrink-0"
style={{ background: "var(--panel-2)" }}
role="tablist"
aria-label="片段来源"
>
{(
[
["public", "公开广场"],
["mine", "我的片段"],
] as [Tab, string][]
).map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={tab === key}
className="px-3 py-1 rounded-md transition-colors"
style={
tab === key
? { background: "var(--panel)", color: "var(--ink)", boxShadow: "0 1px 2px rgba(0,0,0,0.08)" }
: { color: "var(--ink-3)" }
}
onClick={() => setTab(key)}
>
{label}
</button>
))}
</div>
<div className="relative flex-1 min-w-0">
<Search
size={13}
className="absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
/>
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索标题或描述…"
aria-label="搜索片段"
className="w-full rounded-lg pl-7 pr-3 py-1.5 text-[13px]"
style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
/>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
{loading && !loaded ? (
<p className="meta text-center py-10 text-[12.5px]">加载中…</p>
) : items.length === 0 ? (
<p className="meta text-center py-10 text-[12.5px]">
{tab === "mine" ? "还没有自己的片段,去片段广场新建一个吧" : "没有匹配的片段"}
</p>
) : (
<ul className="flex flex-col gap-1">
{items.map((it) => (
<li key={it.id}>
<button
type="button"
className="w-full text-left rounded-xl px-3 py-2.5 transition-colors hover:bg-[var(--panel-2)]"
onClick={() => {
onPick(it.id);
onClose();
}}
>
<span className="flex items-center gap-2 min-w-0">
<CodeXml size={14} className="shrink-0" style={{ color: "var(--accent)" }} />
<span className="truncate text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
{it.title}
</span>
<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 rounded-full px-1.5 py-px text-[10.5px]"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
私有
</span>
)}
</span>
<span className="mt-1 flex items-center gap-3 text-[11.5px]" style={{ color: "var(--ink-3)" }}>
<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">
<GitFork size={11} /> {it.fork_count}
</span>
<span className="ml-auto shrink-0">{formatRelative(it.created_at)}</span>
</span>
</button>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,239 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { History, X } from "lucide-react";
import { apiSnippetHistory } from "@/lib/api";
import type { SnippetVersionItem } from "@/lib/api";
import { toast } from "@/lib/toast";
import { formatRelative } from "@/lib/format";
import { smartDiff, toFoldedRows, type FoldedRow } from "@/lib/smartDiff";
import Modal from "@/components/Modal";
function DiffLineRow({ row }: { row: FoldedRow }) {
if (row.kind === "fold") {
return (
<div
className="text-center py-1 text-[11px] select-none"
style={{ color: "var(--ink-3)", background: "var(--panel-2)" }}
>
⋯ 折叠 {row.count} 行 ⋯
</div>
);
}
const bg =
row.op === "del"
? "color-mix(in srgb, var(--danger) 8%, transparent)"
: row.op === "ins"
? "color-mix(in srgb, var(--ok) 8%, transparent)"
: "transparent";
const color =
row.op === "del"
? "var(--danger)"
: row.op === "ins"
? "var(--ok)"
: "var(--ink)";
const prefix = row.op === "del" ? "- " : row.op === "ins" ? "+ " : " ";
if (row.segments && row.segments.length > 0) {
// 行内细分高亮
const parts: Array<{ text: string; changed: boolean }> = [];
let last = 0;
for (const seg of row.segments) {
if (seg.start > last) parts.push({ text: row.text.slice(last, seg.start), changed: false });
parts.push({ text: row.text.slice(seg.start, seg.end), changed: true });
last = seg.end;
}
if (last < row.text.length) parts.push({ text: row.text.slice(last), changed: false });
return (
<div
className="flex gap-2 px-3 py-0.5 text-[12.5px] font-mono whitespace-pre-wrap break-all"
style={{ background: bg, color }}
>
<span className="shrink-0 w-4 select-none" style={{ color: "var(--ink-3)" }}>
{prefix}
</span>
<span>
{parts.map((p, i) =>
p.changed ? (
<mark
key={i}
style={{
background:
row.op === "del"
? "color-mix(in srgb, var(--danger) 25%, transparent)"
: "color-mix(in srgb, var(--ok) 25%, transparent)",
color: "inherit",
}}
>
{p.text}
</mark>
) : (
<span key={i}>{p.text}</span>
)
)}
</span>
</div>
);
}
return (
<div
className="flex gap-2 px-3 py-0.5 text-[12.5px] font-mono whitespace-pre-wrap break-all"
style={{ background: bg, color }}
>
<span className="shrink-0 w-4 select-none" style={{ color: "var(--ink-3)" }}>
{prefix}
</span>
<span>{row.text || " "}</span>
</div>
);
}
function VersionDiff({ oldText, newText }: { oldText: string; newText: string }) {
const result = smartDiff(oldText, newText);
const rows = toFoldedRows(result.blocks, 3);
return (
<div
className="rounded-lg overflow-hidden"
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
>
<div
className="px-3 py-1.5 text-[11px] font-medium flex items-center gap-3"
style={{ background: "var(--panel-2)", borderBottom: "1px solid var(--line)", color: "var(--ink-3)" }}
>
<span style={{ color: "var(--ok)" }}>+{result.stats.adds}</span>
<span style={{ color: "var(--danger)" }}>-{result.stats.dels}</span>
</div>
<div className="max-h-[320px] overflow-y-auto">
{rows.map((row, i) => (
<DiffLineRow key={i} row={row} />
))}
</div>
</div>
);
}
export default function SnippetVersionModal({
open,
onClose,
snippetId,
currentCode,
currentTitle,
currentLanguage,
currentDescription,
}: {
open: boolean;
onClose: () => void;
snippetId: number;
currentCode: string;
currentTitle: string;
currentLanguage: string;
currentDescription: string;
}) {
const [items, setItems] = useState<SnippetVersionItem[]>([]);
const [loading, setLoading] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await apiSnippetHistory(snippetId, 1, 50);
setItems(res.items);
} catch (e) {
toast(e instanceof Error ? e.message : "加载历史失败");
} finally {
setLoading(false);
}
}, [snippetId]);
useEffect(() => {
if (open) load();
}, [open, load]);
const toggleExpand = (id: number) => {
setExpandedId((prev) => (prev === id ? null : id));
};
return (
<Modal
open={open}
title="版本历史"
description="每次编辑前的旧值快照"
onClose={onClose}
dismissOnOverlay
maxWidthClass="max-w-3xl"
panelClassName="p-5 sm:p-6"
>
<div className="max-h-[60vh] overflow-y-auto">
{loading ? (
<p className="meta text-[13px] text-center py-8">加载中…</p>
) : items.length === 0 ? (
<p className="meta text-[13px] text-center py-8">暂无编辑历史</p>
) : (
<div className="flex flex-col gap-3">
{items.map((v) => {
const isExpanded = expandedId === v.id;
const diffRows = isExpanded
? toFoldedRows(smartDiff(v.old_code, currentCode).blocks, 3)
: null;
return (
<div
key={v.id}
className="rounded-lg overflow-hidden"
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
>
<button
type="button"
onClick={() => toggleExpand(v.id)}
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-[var(--panel-2)] transition-colors"
>
<div className="min-w-0">
<p className="text-[13px] font-medium truncate" style={{ color: "var(--ink)" }}>
{v.old_title || currentTitle}
</p>
<p className="meta text-[11px] mt-0.5">
{formatRelative(v.created_at)} · {v.old_language || currentLanguage}
</p>
</div>
<span className="meta text-[11px] shrink-0 ml-3">
{isExpanded ? "收起" : "查看差异"}
</span>
</button>
{isExpanded && diffRows && (
<div className="px-4 pb-4">
<div
className="rounded-lg overflow-hidden"
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
>
<div
className="px-3 py-1.5 text-[11px] font-medium flex items-center gap-3"
style={{
background: "var(--panel-2)",
borderBottom: "1px solid var(--line)",
color: "var(--ink-3)",
}}
>
<span style={{ color: "var(--ok)" }}>
+{smartDiff(v.old_code, currentCode).stats.adds}
</span>
<span style={{ color: "var(--danger)" }}>
-{smartDiff(v.old_code, currentCode).stats.dels}
</span>
</div>
<div className="max-h-[320px] overflow-y-auto">
{diffRows.map((row, i) => (
<DiffLineRow key={i} row={row} />
))}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</Modal>
);
}

View File

@@ -31,6 +31,8 @@ import {
Layers,
Images,
Link2,
CodeXml,
Pin,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -94,6 +96,12 @@ const NAV_GROUPS: NavGroup[] = [
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
{
href: "/admin/pinned",
label: "置顶管理",
icon: Pin,
visible: (u) => canModerateAny(u),
},
{
href: "/admin/messages",
label: "消息管理",
@@ -118,6 +126,12 @@ const NAV_GROUPS: NavGroup[] = [
icon: BookOpen,
visible: (u) => hasPerm(u, PERMS.LIBRARY),
},
{
href: "/admin/snippets",
label: "片段管理",
icon: CodeXml,
visible: (u) => isStaff(u.role),
},
{
href: "/admin/friend-links",
label: "友情链接",

View File

@@ -3,7 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { CalendarDays, CalendarCheck, LogIn, Loader2 } from "lucide-react";
import { apiDoCheckin, type CheckinStatus } from "@/lib/api";
import { apiDoCheckin, apiGetCheckin, type CheckinStatus } from "@/lib/api";
/**
* 每日签到紧凑条,内嵌在右栏「站点数据」卡底部。
@@ -24,7 +24,17 @@ export function PulseCheckin({ initial }: { initial?: CheckinStatus | null }) {
try {
setStatus(await apiDoCheckin());
} catch (e) {
setError(e instanceof Error ? e.message : "签到失败");
const msg = e instanceof Error ? e.message : "签到失败";
// 本地状态滞后(缓存/多标签页)导致后端判重时,回拉真实状态,避免按钮仍显示「签到」
if (msg.includes("已签到")) {
try {
setStatus(await apiGetCheckin());
return;
} catch {
// 回拉失败,回落为错误提示
}
}
setError(msg);
} finally {
setLoading(false);
}

View File

@@ -1,14 +1,14 @@
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Bookmark, Home, LayoutGrid, Menu, ShieldCheck } from "lucide-react";
import { Bookmark, CodeXml, Home, LayoutGrid, Menu, ShieldCheck, Trophy } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import { leaderboardPath, snippetsPath, userPath } from "@/lib/urlStyle";
import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
@@ -26,9 +26,16 @@ export function WorkspaceNavPanel({
user?: User | null;
}) {
const params = useSearchParams();
const pathname = usePathname();
const urlStyle = useUrlStyle();
const activeBoard = Number(params.get("board")) || 0;
const showAdmin = !!user && isStaff(user.role);
const snipHref = snippetsPath(urlStyle);
const lbHref = leaderboardPath(urlStyle);
// 与 Header 高亮规则一致:覆盖标准路径与伪静态(/snippet-*.html、/rank.html 等)
const snippetsActive =
!!pathname && (pathname.startsWith("/snippets") || pathname.startsWith("/snippet-"));
const leaderboardActive = !!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`));
return (
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
@@ -49,6 +56,37 @@ export function WorkspaceNavPanel({
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
</Link>
{/* 不进底栏的全局入口:仅移动端展示(首页 ☰ 导航抽屉是其在移动端的唯一路径),
桌面端由顶部导航承担,左栏不重复 */}
<Link
href={snipHref}
className="j13-bcard lg:hidden"
aria-current={snippetsActive ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<CodeXml size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">代码片段</span>
</Link>
<Link
href={lbHref}
className="j13-bcard lg:hidden"
aria-current={leaderboardActive ? "page" : undefined}
>
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<Trophy size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">排行榜</span>
</Link>
{user ? (
<Link href={`${userPath(urlStyle, user.id)}?tab=favorites`} className="j13-bcard">
<span

View File

@@ -0,0 +1,124 @@
import Link from "next/link";
import { CodeXml, Lock, Copy, Eye, MessageSquare, GitFork } from "lucide-react";
import { fetchSnippetById } from "@/lib/api";
import { postViewerCookieHeader } from "@/lib/cookies";
import { getPublicSettingsCached } from "@/lib/serverData";
import { cookies } from "next/headers";
import { snippetPath } from "@/lib/urlStyle";
import Avatar from "@/components/Avatar";
/**
* [snippet id=N] 嵌入卡片(SSR)。
* 私有/已删除/不存在时渲染降级占位,不暴露内容。
*/
export default async function MdSnippet({ id }: { id: number }) {
const cookieHeader = postViewerCookieHeader(await cookies());
const [data, settings] = await Promise.all([
fetchSnippetById(id, cookieHeader).catch(() => null),
getPublicSettingsCached(),
]);
const urlStyle = settings.url_style;
// 降级占位:不存在 / 私有且无权限 / 已删除
if (!data) {
return (
<div
className="my-4 rounded-xl px-4 py-4 flex items-center gap-3"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<span
className="w-9 h-9 flex items-center justify-center rounded-lg shrink-0"
style={{ background: "var(--panel)", color: "var(--ink-3)" }}
>
<Lock size={16} />
</span>
<div className="min-w-0">
<p className="text-[13px] font-medium" style={{ color: "var(--ink-2)" }}>
代码片段 #{id}
</p>
<p className="meta text-[11.5px]">片段不存在或无权查看</p>
</div>
</div>
);
}
const s = data.snippet;
const ownerName = s.user ? s.user.nickname || s.user.username : "";
const previewLines = s.code.split("\n").slice(0, 5);
const href = snippetPath(urlStyle, s.id);
return (
<Link href={href} className="group block my-4">
<div
className="rounded-xl overflow-hidden transition-[box-shadow,transform] duration-200 group-hover:-translate-y-0.5 group-hover:shadow-[var(--shadow-md)]"
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
>
{/* 头部:语言 + 标题 + 作者 */}
<div className="flex items-center gap-2.5 px-4 py-3" style={{ borderBottom: "1px solid var(--line)" }}>
<span
className="w-8 h-8 flex items-center justify-center rounded-lg shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<CodeXml size={15} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className="text-[10px] font-semibold px-1.5 py-0.5 rounded uppercase tracking-wide shrink-0"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
}}
>
{s.language}
</span>
<p className="text-[14px] font-semibold truncate" style={{ color: "var(--ink)" }}>
{s.title}
</p>
</div>
{s.user && (
<span className="flex items-center gap-1.5 mt-0.5">
<Avatar size={14} name={ownerName} src={s.user.avatar} />
<span className="meta text-[11px] truncate">{ownerName}</span>
</span>
)}
</div>
</div>
{/* 代码预览 */}
<div className="px-4 py-3" style={{ background: "var(--panel-2)" }}>
<pre
className="text-[12px] leading-[1.6] overflow-hidden"
style={{ color: "var(--ink-2)", fontFamily: "var(--font-mono, ui-monospace, monospace)" }}
>
{previewLines.map((line, i) => (
<div key={i} className="flex gap-2 min-w-0">
<span className="shrink-0 w-4 text-right select-none" style={{ color: "var(--ink-3)", opacity: 0.5 }}>
{i + 1}
</span>
<span className="truncate">{line || " "}</span>
</div>
))}
</pre>
</div>
{/* 底部统计 */}
<div className="flex items-center gap-3 px-4 py-2.5 meta text-[11px]">
<span className="inline-flex items-center gap-1">
<Copy size={11} /> {s.copy_count}
</span>
<span className="inline-flex items-center gap-1">
<Eye size={11} /> {s.view_count}
</span>
<span className="inline-flex items-center gap-1">
<MessageSquare size={11} /> {s.comment_count}
</span>
<span className="inline-flex items-center gap-1">
<GitFork size={11} /> {s.fork_count}
</span>
</div>
</div>
</Link>
);
}

View File

@@ -14,14 +14,36 @@ export const SHIKI_LANG_IDS = [
"tsx",
"jsx",
"json",
"jsonc",
"yaml",
"toml",
"ini",
"bash",
"sql",
"html",
"css",
"scss",
"diff",
"rust",
"ruby",
"powershell",
"nginx",
"dockerfile",
"kotlin",
"java",
"c",
"cpp",
"csharp",
"php",
"swift",
"dart",
"lua",
"vue",
"graphql",
"xml",
"regex",
"makefile",
"prisma",
"plaintext",
] as const;
@@ -38,8 +60,44 @@ export const SHIKI_LANG_ALIAS: Record<string, string> = {
yml: "yaml",
text: "plaintext",
txt: "plaintext",
golang: "go",
rb: "ruby",
ps1: "powershell",
pwsh: "powershell",
"c++": "cpp",
cs: "csharp",
"c#": "csharp",
regexp: "regex",
docker: "dockerfile",
};
/**
* 白名单 id → @shikijs/langs grammar 内部注册名。
* 部分语言的 dist 文件只是别名重导出(如 bash.mjs = shellscript.mjs),
* HighlighterCore 按内部名注册,codeToHtml(lang:"bash") 会静默降级 plaintext。
*/
const INTERNAL_LANG_ALIAS: Record<string, string> = {
bash: "shellscript",
sh: "shellscript",
shell: "shellscript",
zsh: "shellscript",
dockerfile: "docker",
regex: "regexp",
makefile: "make",
};
/**
* createHighlighterCore 的 langAlias:输入别名 + 白名单 id 都要能落到 grammar 内部名。
* 注意 core 会校验别名链无环,且 docker/regexp 本身就是内部注册名,
* 不能再作为 alias 键(会与 dockerfile→docker、regex→regexp 互指成环)。
*/
function buildCoreLangAlias(): Record<string, string> {
const merged = { ...SHIKI_LANG_ALIAS, ...INTERNAL_LANG_ALIAS };
delete merged["docker"];
delete merged["regexp"];
return merged;
}
export const SHIKI_THEMES = {
light: "github-light",
dark: "github-dark-dimmed",
@@ -84,10 +142,30 @@ export function getSiteHighlighter(): Promise<HighlighterCore> {
import("@shikijs/langs/css"),
import("@shikijs/langs/scss"),
import("@shikijs/langs/diff"),
import("@shikijs/langs/rust"),
import("@shikijs/langs/ruby"),
import("@shikijs/langs/powershell"),
import("@shikijs/langs/nginx"),
import("@shikijs/langs/dockerfile"),
import("@shikijs/langs/kotlin"),
import("@shikijs/langs/java"),
import("@shikijs/langs/c"),
import("@shikijs/langs/cpp"),
import("@shikijs/langs/csharp"),
import("@shikijs/langs/php"),
import("@shikijs/langs/swift"),
import("@shikijs/langs/dart"),
import("@shikijs/langs/lua"),
import("@shikijs/langs/vue"),
import("@shikijs/langs/graphql"),
import("@shikijs/langs/xml"),
import("@shikijs/langs/regex"),
import("@shikijs/langs/makefile"),
import("@shikijs/langs/prisma"),
// plaintext / text / txt 为 Shiki 特殊语言,无需 grammar
aardioGrammar,
],
langAlias: SHIKI_LANG_ALIAS,
langAlias: buildCoreLangAlias(),
engine: createOnigurumaEngine(import("shiki/wasm")),
});
}

View File

@@ -81,7 +81,7 @@ export interface User {
role: string;
// 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮)
board_ids?: number[];
// 站点消息管理(站长授予;站长/超管固有监管不必依赖此字段)
// 站点消息管理(仅站长可授;超管亦需授予,站长固有监管不必依赖此字段)
can_manage_messages?: boolean;
// 生效权限码(角色默认 ∪ 账号级渠道授予;仅 /api/me 返回,用于后台入口显隐)
perms?: string[];
@@ -2261,8 +2261,8 @@ export async function apiDeletePost(
return res.json();
}
export async function apiTogglePin(postId: string): Promise<{ pinned: number }> {
const res = await fetchWithRefresh(`/api/posts/${postId}/pin`, {
export async function apiTogglePin(postId: string, level: 0 | 1 | 2): Promise<{ pinned: number }> {
const res = await fetchWithRefresh(`/api/posts/${postId}/pin?level=${level}`, {
method: "PUT",
headers: clientHeaders(),
});
@@ -4059,6 +4059,38 @@ export const apiAdminRestoreComment = (id: number) =>
export const apiAdminPurgeComment = (id: number) =>
contentAction(`/api/admin/content/comments/${id}/purge`, "DELETE");
// ===== 置顶顺序配置 =====
export interface AdminPinnedPost {
id: number;
title: string;
pinned: number;
pin_order: number;
board_id: number;
board_name: string;
created_at: string;
}
export async function apiAdminPinnedPosts(
level: 1 | 2,
boardId?: number
): Promise<{ posts: AdminPinnedPost[] }> {
const params = new URLSearchParams({ level: String(level) });
if (boardId) params.set("board_id", String(boardId));
const res = await fetchWithRefresh(`/api/admin/pinned-posts?${params}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取置顶列表失败");
return data;
}
export const apiAdminSavePinOrder = (level: 1 | 2, boardId: number, ids: number[]) =>
contentAction(`/api/admin/pinned-posts/order`, "PUT", {
level,
board_id: boardId,
ids,
});
export interface CommentEditHistoryItem {
id: number;
editor: {
@@ -4987,3 +5019,403 @@ export async function apiAdminSetFriendLinkStatus(
export async function apiAdminDeleteFriendLink(id: number): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/friend-links/${id}`, "DELETE");
}
// ===== 代码片段(广场 + 我的 + 管理) =====
/** 片段可见性 */
export type SnippetVisibility = "public" | "private";
/** 片段广场/列表条目(不含代码全文,preview 为代码前缀) */
export interface SnippetListItem {
id: number;
title: string;
description: string;
language: string;
visibility: SnippetVisibility;
user_id: number;
forked_from_id?: number | null;
copy_count: number;
view_count: number;
comment_count: number;
fork_count: number;
preview: string;
user?: LibraryCreator | null;
created_at: string;
updated_at: string;
}
/** 片段详情(含代码全文与作者/Fork 来源摘要) */
export interface SnippetDetail {
id: number;
title: string;
description: string;
language: string;
code: string;
visibility: SnippetVisibility;
user_id: number;
forked_from_id?: number | null;
copy_count: number;
view_count: number;
comment_count: number;
fork_count: number;
created_at: string;
updated_at: string;
user?: LibraryCreator | null;
forked_from?: { id: number; title: string } | null;
}
/** 片段版本历史条目(编辑前旧值快照) */
export interface SnippetVersionItem {
id: number;
snippet_id: number;
editor_id: number;
old_title: string;
old_language: string;
old_description: string;
old_code: string;
created_at: string;
}
/** 片段评论(楼层树:parent_id 空=主评) */
export interface SnippetCommentItem {
id: number;
snippet_id: number;
user_id: number;
parent_id?: number | null;
root_id?: number | null;
depth: number;
content: string;
floor?: number;
deleted?: boolean;
user?: LibraryCreator | null;
replies?: SnippetCommentItem[];
edited?: boolean;
created_at: string;
updated_at: string;
}
export interface SnippetInput {
title: string;
language: string;
description?: string;
code: string;
visibility: SnippetVisibility;
}
export type SnippetSort = "latest" | "copy" | "comment" | "fork";
/** SSR / 公开:片段广场列表 */
export async function fetchSnippets(opts: {
q?: string;
lang?: string;
sort?: SnippetSort;
page?: number;
size?: number;
cookieHeader?: string;
}): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams();
if (opts.q) params.set("q", opts.q);
if (opts.lang) params.set("lang", opts.lang);
if (opts.sort) params.set("sort", opts.sort);
params.set("page", String(opts.page ?? 1));
params.set("size", String(opts.size ?? 12));
const res = await fetch(
`${API_BASE}/api/snippets?${params}`,
ssrInit(cookieHeaders(opts.cookieHeader))
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取片段列表失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** SSR / 公开:语言白名单 */
export async function fetchSnippetLanguages(): Promise<{ languages: string[] }> {
const res = await fetch(`${API_BASE}/api/snippets/languages`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取语言列表失败");
return { languages: data.languages ?? [] };
}
export interface SnippetLangStat {
language: string;
count: number;
}
export interface SnippetStats {
total_snippets: number;
total_copies: number;
total_views: number;
total_comments: number;
total_forks: number;
contributors: number;
top_languages: SnippetLangStat[];
}
/** SSR / 公开:片段广场聚合统计 */
export async function fetchSnippetStats(): Promise<SnippetStats> {
const res = await fetch(`${API_BASE}/api/snippets/stats`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取片段统计失败");
return (data.stats ?? {
total_snippets: 0,
total_copies: 0,
total_views: 0,
total_comments: 0,
total_forks: 0,
contributors: 0,
top_languages: [],
}) as SnippetStats;
}
/** SSR:片段详情(公开或本人/管理可见私有;404/403 返回 null) */
export async function fetchSnippetById(
id: number,
cookieHeader?: string
): Promise<{ snippet: SnippetDetail } | null> {
const res = await fetch(`${API_BASE}/api/snippets/${id}`, ssrInit(cookieHeaders(cookieHeader)));
if (res.status === 404 || res.status === 403) return null;
if (!res.ok) throw new Error("获取片段失败");
return res.json();
}
/** 客户端:片段详情(带登录凭证,私有片段仅 owner/staff 可见) */
export async function apiGetSnippet(id: number): Promise<{ snippet: SnippetDetail } | null> {
const res = await fetchWithRefresh(`/api/snippets/${id}`, { headers: clientHeaders() });
if (res.status === 404 || res.status === 403) return null;
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取片段失败");
return data as { snippet: SnippetDetail };
}
/** SSR / 公开:片段评论(主评分页 + 回复) */
export async function fetchSnippetComments(
id: number,
page = 1,
size = 20,
cookieHeader?: string
): Promise<{ items: SnippetCommentItem[]; total: number }> {
const res = await fetch(
`${API_BASE}/api/snippets/${id}/comments?page=${page}&size=${size}`,
ssrInit(cookieHeaders(cookieHeader))
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取评论失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** SSR / 公开:片段的公开 Fork 列表 */
export async function fetchSnippetForks(
id: number,
page = 1,
size = 20
): Promise<{ items: SnippetListItem[]; total: number }> {
const res = await fetch(`${API_BASE}/api/snippets/${id}/forks?page=${page}&size=${size}`, ssrInit());
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取 Fork 列表失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 客户端:我的片段(含私有) */
export async function apiMySnippets(
q = "",
page = 1,
size = 12
): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
if (q) params.set("q", q);
const res = await fetchWithRefresh(`/api/snippets/mine?${params}`, { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取我的片段失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 客户端:创建片段 */
export async function apiCreateSnippet(input: SnippetInput): Promise<{ snippet: SnippetDetail }> {
const res = await fetchWithRefresh("/api/snippets", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "创建片段失败");
return data as { snippet: SnippetDetail };
}
/** 客户端:编辑片段(owner/staff;服务端落版本快照) */
export async function apiUpdateSnippet(
id: number,
input: SnippetInput
): Promise<{ snippet: SnippetDetail }> {
const res = await fetchWithRefresh(`/api/snippets/${id}`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存片段失败");
return data as { snippet: SnippetDetail };
}
/** 客户端:删除片段(owner/staff) */
export async function apiDeleteSnippet(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/snippets/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除片段失败");
}
}
/** 客户端:Fork 公开片段为自己的私有副本 */
export async function apiForkSnippet(id: number): Promise<{ snippet: SnippetDetail }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/fork`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Fork 失败");
return data as { snippet: SnippetDetail };
}
/** 客户端:复制计数(节流由服务端按用户/IP 控制;失败静默) */
export async function apiRecordSnippetCopy(id: number): Promise<void> {
try {
await fetchWithRefresh(`/api/snippets/${id}/copy`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
});
} catch {
/* 计数失败不影响复制本身 */
}
}
/** 客户端:版本历史(登录可见) */
export async function apiSnippetHistory(
id: number,
page = 1,
size = 20
): Promise<{ items: SnippetVersionItem[]; total: number }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/history?page=${page}&size=${size}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取版本历史失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 客户端:发表片段评论(parent_id 非空为回复) */
export async function apiCreateSnippetComment(
id: number,
content: string,
parentId?: number
): Promise<{ comment: SnippetCommentItem }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/comments`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ content, parent_id: parentId || undefined }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "评论失败");
return data as { comment: SnippetCommentItem };
}
/** 客户端:编辑片段评论(仅本人) */
export async function apiUpdateSnippetComment(
id: number,
cid: number,
content: string
): Promise<{ comment: SnippetCommentItem }> {
const res = await fetchWithRefresh(`/api/snippets/${id}/comments/${cid}`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ content }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存评论失败");
return data as { comment: SnippetCommentItem };
}
/** 客户端:删除片段评论(本人/staff) */
export async function apiDeleteSnippetComment(id: number, cid: number): Promise<void> {
const res = await fetchWithRefresh(`/api/snippets/${id}/comments/${cid}`, {
method: "DELETE",
headers: clientHeaders(),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "删除评论失败");
}
}
export interface SnippetCommentHistoryItem {
id: number;
editor: { id: number; username: string; nickname: string; avatar: string } | null;
old_content: string;
created_at: string;
}
/** 客户端:片段评论编辑历史(登录可见) */
export async function apiSnippetCommentHistory(
id: number,
cid: number,
page = 1,
size = 10
): Promise<{ items: SnippetCommentHistoryItem[]; total: number; page: number; size: number }> {
const params = new URLSearchParams({ page: String(page), size: String(size) });
const res = await fetchWithRefresh(`/api/snippets/${id}/comments/${cid}/history?${params}`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取编辑历史失败");
return {
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? page,
size: data.size ?? size,
};
}
/** 客户端:片段搜索(嵌入弹窗等场景;公开广场 + 可含我的) */
export async function apiSearchSnippets(opts: {
q?: string;
page?: number;
size?: number;
}): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams();
if (opts.q) params.set("q", opts.q);
params.set("page", String(opts.page ?? 1));
params.set("size", String(opts.size ?? 10));
const res = await fetchWithRefresh(`/api/snippets?${params}`, { headers: clientHeaders() });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "搜索片段失败");
return { items: data.items ?? [], total: data.total ?? 0 };
}
/** 管理端:片段全量列表 */
export async function apiAdminSnippets(opts: {
q?: string;
visibility?: string;
page?: number;
size?: number;
}): Promise<{ items: SnippetListItem[]; total: number }> {
const params = new URLSearchParams({ page: String(opts.page ?? 1), size: String(opts.size ?? 30) });
if (opts.q) params.set("q", opts.q);
if (opts.visibility) params.set("visibility", opts.visibility);
return apiOperations(`/api/admin/snippets?${params}`);
}
/** 管理端:删除片段(软删 + 原因) */
export async function apiAdminDeleteSnippet(id: number, reason = ""): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/snippets/${id}`, "DELETE", reason ? { reason } : undefined);
}
/** 管理端:强制修改片段可见性(下架为 private / 恢复 public) */
export async function apiAdminSetSnippetVisibility(
id: number,
visibility: SnippetVisibility
): Promise<{ ok: boolean }> {
return apiOperations(`/api/admin/snippets/${id}/visibility`, "PUT", { visibility });
}

View File

@@ -27,7 +27,8 @@ export type ContentSegment =
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
| { type: "entries"; items: EntryItem[]; rawBody: string }
| { type: "details"; title: string; body: string }
| { type: "video"; provider: "bilibili" | "youtube"; id: string };
| { type: "video"; provider: "bilibili" | "youtube"; id: string }
| { type: "snippet"; id: number };
function isTimelineClose(line: string): boolean {
return line.trim().toLowerCase() === "[/timeline]";
@@ -57,6 +58,18 @@ export function parseDetailsOpen(trimmed: string): string {
return title || DETAILS_DEFAULT_TITLE;
}
/* ---------- [snippet id=N] 代码片段嵌入(整行单标记) ---------- */
const SNIPPET_LINE_RE = /^\[snippet\s+id=(\d+)\]$/i;
/** 整行 `[snippet id=N]`。解析失败返回 null(该行按普通 Markdown 处理)。 */
export function parseSnippetLine(trimmed: string): number | null {
const m = trimmed.match(SNIPPET_LINE_RE);
if (!m) return null;
const id = Number(m[1]);
return Number.isInteger(id) && id > 0 ? id : null;
}
/* ---------- [bilibili] / [youtube] 视频嵌入(整行单标记) ---------- */
const VIDEO_LINE_RE = /^\[(bilibili|youtube)\]([^\[\]]+)\[\/\1\]$/i;
@@ -122,6 +135,7 @@ export function splitContentSegments(
| { type: "entries"; body: string }
| { type: "details"; title: string; body: string }
| { type: "video"; provider: "bilibili" | "youtube"; id: string }
| { type: "snippet"; id: number }
> = [];
let buf: string[] = [];
let inCode = false;
@@ -228,6 +242,15 @@ export function splitContentSegments(
continue;
}
// [snippet id=N] 整行片段嵌入
const snippetId = parseSnippetLine(trimmed);
if (snippetId != null) {
flushMd();
raw.push({ type: "snippet", id: snippetId });
i++;
continue;
}
// [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
if (isEntriesOffOpen(trimmed)) {
@@ -273,6 +296,10 @@ export function splitContentSegments(
out.push({ type: "video", provider: part.provider, id: part.id });
continue;
}
if (part.type === "snippet") {
out.push({ type: "snippet", id: part.id });
continue;
}
const hideSegs = splitHideSegments(part.text) as HideSegment[];
for (const s of hideSegs) {
if (s.type === "md") {

View File

@@ -15,8 +15,8 @@ describe("normalizeHeaderNav", () => {
assert.deepEqual(normalizeHeaderNav("home"), DEFAULT_HEADER_NAV);
});
it("默认三项顺序与注册表一致", () => {
assert.equal(DEFAULT_HEADER_NAV.length, 3);
it("默认内置项顺序与注册表一致", () => {
assert.equal(DEFAULT_HEADER_NAV.length, HEADER_NAV_BUILTIN_KEYS.length);
assert.deepEqual(
DEFAULT_HEADER_NAV.map((i) => (i.type === "builtin" ? i.key : "")),
[...HEADER_NAV_BUILTIN_KEYS],

View File

@@ -11,7 +11,7 @@
import { footerURLAllowed, isExternalFooterURL, runeCount } from "./brand.ts";
export const HEADER_NAV_BUILTIN_KEYS = ["home", "leaderboard", "library"] as const;
export const HEADER_NAV_BUILTIN_KEYS = ["home", "leaderboard", "library", "snippets"] as const;
export type HeaderNavBuiltinKey = (typeof HEADER_NAV_BUILTIN_KEYS)[number];
@@ -20,6 +20,7 @@ export const HEADER_NAV_BUILTIN_LABELS: Record<HeaderNavBuiltinKey, string> = {
home: "首页",
leaderboard: "排行榜",
library: "书库",
snippets: "片段",
};
export type HeaderNavBuiltinItem = { type: "builtin"; key: HeaderNavBuiltinKey };

View File

@@ -7,6 +7,10 @@ describe("normalizeMobileTabs", () => {
assert.deepEqual(normalizeMobileTabs(["home", "me"]), ["home", "me"]);
assert.deepEqual(normalizeMobileTabs(["messages"]), ["messages"]);
});
it("snippets 为白名单合法项,显式启用时保留", () => {
assert.deepEqual(normalizeMobileTabs(["snippets"]), ["snippets"]);
assert.deepEqual(normalizeMobileTabs(["home", "snippets", "me"]), ["home", "snippets", "me"]);
});
it("去重与去空白", () => {
assert.deepEqual(normalizeMobileTabs(["home", "home", "", " ", "me"]), ["home", "me"]);
});
@@ -16,15 +20,18 @@ describe("normalizeMobileTabs", () => {
it("非法元素被剔除", () => {
assert.deepEqual(normalizeMobileTabs(["home", "trophy", 42, null]), ["home"]);
});
it("空数组/非法输入回落默认全量", () => {
it("空数组/非法输入回落默认集合", () => {
assert.deepEqual(normalizeMobileTabs([]), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(["nope"]), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(undefined), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs("home"), DEFAULT_MOBILE_TABS);
assert.deepEqual(normalizeMobileTabs(null), DEFAULT_MOBILE_TABS);
});
it("默认全量与固定顺序一致", () => {
it("白名单与默认集合:snippets 在白名单内但默认不启用", () => {
assert.equal(MOBILE_TABS.length, 6);
assert.ok(MOBILE_TABS.includes("snippets"));
assert.equal(DEFAULT_MOBILE_TABS.length, 5);
assert.deepEqual(DEFAULT_MOBILE_TABS, [...MOBILE_TABS]);
assert.ok(!DEFAULT_MOBILE_TABS.includes("snippets"));
assert.deepEqual(DEFAULT_MOBILE_TABS, MOBILE_TABS.filter((k) => k !== "snippets"));
});
});

View File

@@ -1,17 +1,20 @@
/**
* 移动端底栏配置的单一事实源:
* 与后端 service.MobileTabKeys 保持一致;管理面板、MobileTabBar、公共设置 normalize 共用。
* 配置为有序数组,数组顺序即前台底栏展示顺序;配置为空/非法回落默认全量。
* 配置为有序数组,数组顺序即前台底栏展示顺序;配置为空/非法回落默认集合。
*
* MOBILE_TABS = 白名单(同时是管理面板排序基准),含全部可选 key;
* DEFAULT_MOBILE_TABS = 缺省启用集合,是白名单的子集(snippets 默认关闭,需管理员手动开启)。
*/
export const MOBILE_TABS = ["home", "library", "compose", "messages", "me"] as const;
export const MOBILE_TABS = ["home", "library", "snippets", "compose", "messages", "me"] as const;
export type MobileTabKey = (typeof MOBILE_TABS)[number];
/** 底栏默认全量配置 */
export const DEFAULT_MOBILE_TABS: MobileTabKey[] = [...MOBILE_TABS];
/** 底栏默认启用配置(白名单子集:snippets 默认不启用,移动端入口由首页导航抽屉承担) */
export const DEFAULT_MOBILE_TABS: MobileTabKey[] = ["home", "library", "compose", "messages", "me"];
/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认全量 */
/** 配置子集解析:去空白、去重、只留白名单内元素;结果为空视为非法,回落默认集合 */
export function normalizeMobileTabs(raw: unknown): MobileTabKey[] {
if (!Array.isArray(raw)) return [...DEFAULT_MOBILE_TABS];
const seen = new Set<string>();

View File

@@ -105,10 +105,11 @@ export interface RtChatMessageData {
};
}
// 群成员关系变更:被踢/群解散/被邀请
// 群成员关系变更:被踢/群解散/被邀请/新成员加入
export interface RtChatMembershipData {
room_id: number;
status: "kicked" | "dissolved" | "invited";
status: "kicked" | "dissolved" | "invited" | "joined";
user_id?: number;
}
// 消息撤回

View File

@@ -76,9 +76,11 @@ export const PERM_META: Record<
[PERMS.ADS]: { label: "广告与赞助", desc: "管理广告位与赞助配置、审批投放" },
};
/** 站长/超管:群聊监管旁路(可见全部群含私聊、撤回任意消息) */
export function canOverseeChat(role?: string | null): boolean {
return role === ROLES.OWNER || role === ROLES.SUPER_ADMIN;
/** 全站消息监管(可见全部群含私聊、撤回任意消息):站长固有;超管需先被授予「消息管理」 */
export function canOverseeChat(user?: RoleUserLike | null): boolean {
if (!user) return false;
if (isOwner(user.role)) return true;
return user.role === ROLES.SUPER_ADMIN && !!user.can_manage_messages;
}
/** 彻底删除帖/评:仅站长 */
@@ -152,10 +154,10 @@ export function canModerateAny(user: RoleUserLike | null | undefined): boolean {
return user.role === ROLES.BOARD_ADMIN && (user.board_ids?.length ?? 0) > 0;
}
/** 站点级消息管理(站长/超管固有,或 can_manage_messages) */
/** 站点级消息管理:站长固有;超管及其他角色需站长授予 can_manage_messages */
export function hasSiteMessagePerm(user: RoleUserLike | null | undefined): boolean {
if (!user) return false;
if (canOverseeChat(user.role)) return true;
if (isOwner(user.role)) return true;
return !!user.can_manage_messages;
}
@@ -201,7 +203,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
colorToken: "--accent",
softToken: "--accent-soft",
grantDesc:
"用户与角色管理、封禁、外观主题、公告;全站内容软删与置顶/加精;群聊监管(可见全部会话、可撤回任意消息)。不可彻底删除内容,不可动站长账号,亦无站长专属的用户内容档案。",
"用户与角色管理、封禁、外观主题、公告;全站内容软删与置顶/加精;站长授予「消息管理」后方可进入后台消息管理并全站会话监管(可见全部会话、可撤回任意消息)。不可彻底删除内容,不可动站长账号,亦无站长专属的用户内容档案。",
},
[ROLES.ADMIN]: {
key: ROLES.ADMIN,
@@ -219,7 +221,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
colorToken: "--accent",
softToken: "--accent-soft",
grantDesc:
"仅审核并软删被授权板块内普通用户的帖子与评论;不可处置管理员/站长发布的内容,无消息管理、公告、置顶/加精、用户管理与外观设置;须至少勾选一个板块。",
"仅审核并软删被授权板块内普通用户的帖子与评论;可在被授权板块内单独置顶帖子(板块置顶)。不可处置管理员/站长发布的内容,无消息管理、公告、全站置顶/加精、用户管理与外观设置;须至少勾选一个板块。",
},
[ROLES.USER]: {
key: ROLES.USER,

View File

@@ -0,0 +1,48 @@
/** 片段语言霓虹色:Hero 分布条 / 卡片顶条共用,未收录语言回落主题色 */
const LANG_COLORS: Record<string, string> = {
go: "#00d8ff",
css: "#c792ea",
scss: "#e879c6",
html: "#ff6d4d",
javascript: "#f7df1e",
jsx: "#61dafb",
typescript: "#4e9cff",
tsx: "#4e9cff",
vue: "#41d1a6",
python: "#ffd43b",
rust: "#ff7a59",
java: "#ffb86c",
kotlin: "#c87bff",
swift: "#ff9e64",
dart: "#54c5f8",
c: "#7fd4ff",
cpp: "#7fd4ff",
csharp: "#b78bff",
php: "#a6b1ff",
ruby: "#ff5f56",
lua: "#9c9cff",
r: "#7ea6ff",
sql: "#ff9e64",
graphql: "#ff6bd6",
bash: "#89e051",
shell: "#89e051",
powershell: "#6ea8ff",
dockerfile: "#3ab0ff",
nginx: "#5fd08a",
yaml: "#ff6b81",
toml: "#ffd166",
ini: "#ffd166",
json: "#8bc34a",
jsonc: "#8bc34a",
xml: "#ffab70",
markdown: "#9aa7ff",
regex: "#f78c6c",
diff: "#b0bec5",
makefile: "#ffd166",
prisma: "#5eead4",
plaintext: "#94a3b8",
};
export function snippetLangColor(lang: string): string {
return LANG_COLORS[lang.toLowerCase()] ?? "var(--accent)";
}

View File

@@ -14,6 +14,8 @@ import {
pagePath,
parseEntityPath,
postPath,
snippetPath,
snippetsPath,
userPath,
} from "./urlStyle.ts";
@@ -89,6 +91,16 @@ describe("路径构造器", () => {
assert.equal(entityPath("default", "user", 9), "/u/9");
assert.equal(entityPath("html", "announcement", 9), "/announcement-9.html");
});
it("代码片段三风格(html/discuz 同模板)", () => {
assert.equal(snippetsPath("default"), "/snippets");
assert.equal(snippetsPath("html"), "/snippets.html");
assert.equal(snippetsPath("discuz"), "/snippets.html");
assert.equal(snippetPath("default", 12), "/snippets/12");
assert.equal(snippetPath("html", 12), "/snippet-12.html");
assert.equal(snippetPath("discuz", 12), "/snippet-12.html");
assert.equal(snippetPath("default", 0), "");
assert.equal(snippetPath("html", -1), "");
});
it("评论锚点使用帖内相对楼层号", () => {
assert.equal(commentAnchor(1, true, 147), "#comment-1");
assert.equal(commentAnchor(3, false, 147), "#comment-3-r147");
@@ -111,6 +123,14 @@ describe("matchPrettyPath", () => {
assert.deepEqual(matchPrettyPath("/p-about.html"), { style: "html", kind: "page", slug: "about" });
assert.deepEqual(matchPrettyPath("/leaderboard.html"), { style: "html", kind: "leaderboard" });
assert.deepEqual(matchPrettyPath("/rank.html"), { style: "discuz", kind: "leaderboard" });
assert.deepEqual(matchPrettyPath("/snippets.html", "html"), { style: "html", kind: "snippets" });
assert.deepEqual(matchPrettyPath("/snippets.html", "discuz"), { style: "discuz", kind: "snippets" });
assert.deepEqual(matchPrettyPath("/snippet-12.html", "discuz"), { style: "discuz", kind: "snippet", id: 12 });
assert.deepEqual(matchPrettyPath("/snippet-12.html"), { style: "html", kind: "snippet", id: 12 });
assert.equal(matchPrettyPath("/snippet-abc.html"), null);
assert.equal(matchPrettyPath("/snippet-.html"), null);
assert.equal(matchPrettyPath("/snippets-.html"), null);
assert.equal(matchPrettyPath("/snippet-12.htmlx"), null);
});
it("书库两风格同模板:prefer 优先当前风格,缺省首条兜底", () => {
assert.equal(matchPrettyPath("/library.html", "html")?.style, "html");
@@ -187,6 +207,13 @@ describe("matchStandardPath / parseEntityPath", () => {
assert.deepEqual(matchStandardPath("/library/abc/read"), { kind: "libraryRead", slug: "abc" });
assert.equal(matchStandardPath("/library/a/b"), null);
assert.equal(matchStandardPath("/libraryx"), null);
// 片段:列表与详情;new/edit 子路径不得命中详情
assert.deepEqual(matchStandardPath("/snippets"), { kind: "snippets" });
assert.deepEqual(matchStandardPath("/snippets/"), { kind: "snippets" });
assert.deepEqual(matchStandardPath("/snippets/12"), { kind: "snippet", id: 12 });
assert.equal(matchStandardPath("/snippets/new"), null);
assert.equal(matchStandardPath("/snippets/12/edit"), null);
assert.equal(matchStandardPath("/snippetsx"), null);
});
it("parseEntityPath 兼容标准与全部模板", () => {
assert.deepEqual(parseEntityPath("/post/12"), { kind: "post", id: 12 });
@@ -211,4 +238,10 @@ describe("matchStandardPath / parseEntityPath", () => {
assert.deepEqual(parseEntityPath("/library/abc/read"), { kind: "libraryRead", slug: "abc" });
assert.deepEqual(parseEntityPath("/library-abc-read-2.html"), { kind: "libraryRead", slug: "abc" });
});
it("parseEntityPath 兼容片段路径", () => {
assert.deepEqual(parseEntityPath("/snippets"), { kind: "snippets" });
assert.deepEqual(parseEntityPath("/snippets/12"), { kind: "snippet", id: 12 });
assert.deepEqual(parseEntityPath("/snippets.html"), { kind: "snippets" });
assert.deepEqual(parseEntityPath("/snippet-12.html"), { kind: "snippet", id: 12 });
});
});

View File

@@ -7,14 +7,21 @@
* default: /post/123 /u/5 /announcement/1 /p/about /leaderboard /library /library/{slug} /library/{slug}/read?c={书内章号}
* html: /post-123.html /user-5.html /announcement-1.html /p-about.html /leaderboard.html /library.html /library-{slug}.html (章节:/library-{slug}.html?sec={书内章号})
* discuz: /thread-123.html /space-uid-5.html /notice-1.html /page-about.html /rank.html (书库两风格同 html 模板)
* 书库章节走详情路径 + ?sec= 查询参数(章内子目录锚点 #e-{sec}-{章内序号});旧版 -read-{n} 路径
* 不再生成,但正则保留可解析(middleware 改写回标准读页),旧链接不失效。
* 代码片段:default /snippets /snippets/{id};html/discuz 两风格统一 /snippets.html /snippet-{id}.html
*/
export type UrlStyle = "default" | "html" | "discuz";
export type EntityKind = "post" | "user" | "announcement";
/** 全部可反解的实体类型;page/libraryDoc/libraryRead 的 id 是 slug(字符串),leaderboard/library 是固定单页无 id */
export type PageKind = EntityKind | "page" | "leaderboard" | "library" | "libraryDoc" | "libraryRead";
/** 全部可反解的实体类型;page/libraryDoc/libraryRead 的 id 是 slug(字符串),leaderboard/library/snippets 是固定单页无 id */
export type PageKind =
| EntityKind
| "page"
| "leaderboard"
| "library"
| "libraryDoc"
| "libraryRead"
| "snippets"
| "snippet";
export const URL_STYLES: readonly UrlStyle[] = ["default", "html", "discuz"];
@@ -64,6 +71,18 @@ const LIBRARY_TEMPLATES: Record<Exclude<UrlStyle, "default">, { list: string; do
},
};
/** 代码片段伪静态模板:两风格统一(同书库,非 Discuz 原生概念) */
const SNIPPET_TEMPLATES: Record<Exclude<UrlStyle, "default">, { list: string; detail: (id: number) => string }> = {
html: {
list: "/snippets.html",
detail: (id) => `/snippet-${id}.html`,
},
discuz: {
list: "/snippets.html",
detail: (id) => `/snippet-${id}.html`,
},
};
/** id 非法(≤0/非有限数)时返回空串,调用方按异常路径处理 */
function guardId(id: number): number | null {
return Number.isFinite(id) && id > 0 ? id : null;
@@ -155,6 +174,19 @@ export function libraryDocReadPath(style: UrlStyle, slug: string, chapterNo?: nu
return no ? `${base}?c=${no}` : base;
}
/** 片段列表路径:default 标准路径,html/discuz 伪静态(两风格统一模板) */
export function snippetsPath(style: UrlStyle): string {
return style === "default" ? "/snippets" : SNIPPET_TEMPLATES[style].list;
}
/** 片段详情路径;id 非法(≤0/非有限数)时返回空串 */
export function snippetPath(style: UrlStyle, id: number): string {
const n = guardId(id);
if (n == null) return "";
if (style === "default") return `/snippets/${n}`;
return SNIPPET_TEMPLATES[style].detail(n);
}
/** 实体对应风格下的标准/pretty 路径(内部与 middleware 共用) */
export function entityPath(style: UrlStyle, kind: EntityKind, id: number): string {
if (kind === "post") return postPath(style, id);
@@ -173,7 +205,9 @@ export type PrettyMatch =
| { style: Exclude<UrlStyle, "default">; kind: "libraryDoc"; slug: string }
| { style: Exclude<UrlStyle, "default">; kind: "libraryRead"; slug: string; id: number }
| { style: Exclude<UrlStyle, "default">; kind: "library" }
| { style: Exclude<UrlStyle, "default">; kind: "leaderboard" };
| { style: Exclude<UrlStyle, "default">; kind: "leaderboard" }
| { style: Exclude<UrlStyle, "default">; kind: "snippets" }
| { style: Exclude<UrlStyle, "default">; kind: "snippet"; id: number };
// 精确锚定:id 仅纯数字,天然排除 /post/123/edit 这类子路径与伪造路径;
// page/libraryDoc 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用。
@@ -188,6 +222,8 @@ const PRETTY_PATTERNS: { re: RegExp; style: Exclude<UrlStyle, "default">; kind:
{ re: /^\/library\.html$/, style: "html", kind: "library" },
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "html", kind: "libraryRead" },
{ re: /^\/library-([^/]+)\.html$/, style: "html", kind: "libraryDoc" },
{ re: /^\/snippets\.html$/, style: "html", kind: "snippets" },
{ re: /^\/snippet-(\d+)\.html$/, style: "html", kind: "snippet" },
{ re: /^\/thread-(\d+)\.html$/, style: "discuz", kind: "post" },
{ re: /^\/space-uid-(\d+)\.html$/, style: "discuz", kind: "user" },
{ re: /^\/notice-(\d+)\.html$/, style: "discuz", kind: "announcement" },
@@ -196,6 +232,8 @@ const PRETTY_PATTERNS: { re: RegExp; style: Exclude<UrlStyle, "default">; kind:
{ re: /^\/library\.html$/, style: "discuz", kind: "library" },
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "discuz", kind: "libraryRead" },
{ re: /^\/library-([^/]+)\.html$/, style: "discuz", kind: "libraryDoc" },
{ re: /^\/snippets\.html$/, style: "discuz", kind: "snippets" },
{ re: /^\/snippet-(\d+)\.html$/, style: "discuz", kind: "snippet" },
];
const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
@@ -208,6 +246,9 @@ const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
{ re: /^\/library\/?$/, kind: "library" },
{ re: /^\/library\/([^/]+)\/read$/, kind: "libraryRead" },
{ re: /^\/library\/([^/]+)$/, kind: "libraryDoc" },
// (\d+)$ 纯数字锚定:/snippets/new 与 /snippets/123/edit 不会命中详情模式
{ re: /^\/snippets\/?$/, kind: "snippets" },
{ re: /^\/snippets\/(\d+)$/, kind: "snippet" },
];
/**
@@ -230,7 +271,7 @@ export function matchPrettyPath(
hit = { style: p.style, kind: p.kind, slug: m[1] };
} else if (p.kind === "libraryRead") {
hit = { style: p.style, kind: p.kind, slug: m[1], id: Number(m[2]) };
} else if (p.kind === "library" || p.kind === "leaderboard") {
} else if (p.kind === "library" || p.kind === "leaderboard" || p.kind === "snippets") {
hit = { style: p.style, kind: p.kind };
} else {
hit = { style: p.style, kind: p.kind, id: Number(m[1]) };
@@ -249,7 +290,7 @@ export function matchStandardPath(pathname: string): StandardMatch | null {
if (p.kind === "page" || p.kind === "libraryDoc" || p.kind === "libraryRead") {
return { kind: p.kind, slug: m[1] };
}
if (p.kind === "library" || p.kind === "leaderboard") return { kind: p.kind };
if (p.kind === "library" || p.kind === "leaderboard" || p.kind === "snippets") return { kind: p.kind };
return { kind: p.kind, id: Number(m[1]) };
}
return null;
@@ -261,7 +302,9 @@ export type StandardMatch =
| { kind: "libraryDoc"; slug: string }
| { kind: "libraryRead"; slug: string }
| { kind: "library" }
| { kind: "leaderboard" };
| { kind: "leaderboard" }
| { kind: "snippets" }
| { kind: "snippet"; id: number };
/**
* 反解任意实体路径(标准 + 全部伪静态风格),供 isActive 前缀判断、面包屑等
@@ -281,6 +324,7 @@ export function parseEntityPath(pathname: string): StandardMatch | null {
if (pretty.kind === "libraryRead") return { kind: "libraryRead", slug: decodeSlug(pretty.slug) };
if (pretty.kind === "leaderboard") return { kind: "leaderboard" };
if (pretty.kind === "library") return { kind: "library" };
if (pretty.kind === "snippets") return { kind: "snippets" };
return { kind: pretty.kind, id: pretty.id };
}

View File

@@ -10,6 +10,8 @@ import {
matchStandardPath,
normalizeUrlStyle,
pagePathFromSlug,
snippetPath,
snippetsPath,
standardEntityPath,
type PageKind,
type UrlStyle,
@@ -185,6 +187,7 @@ function couldBeEntityPath(pathname: string): boolean {
pathname.startsWith("/announcement/") ||
pathname.startsWith("/p/") ||
pathname.startsWith("/library") ||
pathname.startsWith("/snippets") ||
pathname.startsWith("/leaderboard") ||
pathname.endsWith(".html")
);
@@ -199,6 +202,8 @@ function standardTarget(m: { kind: PageKind; id?: number; slug?: string }): stri
if (m.kind === "libraryDoc") return `/library/${m.slug ?? ""}`;
if (m.kind === "libraryRead") return `/library/${m.slug ?? ""}/read?c=${m.id ?? 0}`;
if (m.kind === "library") return "/library";
if (m.kind === "snippets") return "/snippets";
if (m.kind === "snippet") return `/snippets/${m.id ?? 0}`;
if (m.kind === "leaderboard") return "/leaderboard";
return standardEntityPath(m.kind, m.id ?? 0);
}
@@ -216,6 +221,8 @@ function styledPath(
return libraryDocReadPath(style, m.slug ?? "", chapterNo ?? m.id ?? 1);
}
if (m.kind === "library") return libraryPath(style);
if (m.kind === "snippets") return snippetsPath(style);
if (m.kind === "snippet") return snippetPath(style, m.id ?? 0);
if (m.kind === "leaderboard") return leaderboardPath(style);
return entityPath(style, m.kind, m.id ?? 0);
}

View File

@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.