feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等

This commit is contained in:
2026-10-09 18:53:38 +08:00
parent 5598e1cbb7
commit da72843c70
64 changed files with 7047 additions and 248 deletions

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

@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/freefire/jiang13-bbs/middleware"
@@ -240,11 +241,32 @@ func (h *Handlers) JoinChatRoom(c *gin.Context) {
if !ok {
return
}
_, _, err := h.Chat.Join(claims.ID, roomID)
member, joined, err := h.Chat.Join(claims.ID, roomID)
if err != nil {
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
return
}
// 首次加入则向房间广播系统提示 + 成员变更
if joined && member != nil {
displayName := h.Chat.DisplayName(claims.ID)
if displayName != "" {
sysMsg, _ := h.Chat.SendSystemMessage(roomID, displayName+" 加入了群聊")
if sysMsg != nil && sysMsg.ID > 0 {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMessage,
Data: sysMsg,
})
}
}
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{
"room_id": roomID,
"status": "joined",
"user_id": claims.ID,
},
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -300,6 +322,35 @@ func (h *Handlers) InviteChatMembers(c *gin.Context) {
Data: gin.H{"room_id": roomID, "status": "invited"},
})
}
// 被邀请者入群时向房间广播系统提示 + 成员变更
if len(added) > 0 {
var names []string
for _, uid := range added {
if n := h.Chat.DisplayName(uid); n != "" {
names = append(names, n)
}
}
if len(names) > 0 {
content := strings.Join(names, "、") + " 加入了群聊"
sysMsg, _ := h.Chat.SendSystemMessage(roomID, content)
if sysMsg != nil && sysMsg.ID > 0 {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMessage,
Data: sysMsg,
})
}
}
for _, uid := range added {
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
Type: realtime.EventChatMembership,
Data: gin.H{
"room_id": roomID,
"status": "joined",
"user_id": uid,
},
})
}
}
c.JSON(http.StatusOK, gin.H{"added": added})
}

View File

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

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

@@ -64,6 +64,7 @@ func InitDB(dsn string) error {
&Badge{}, &UserBadge{},
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{},
&ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{},
&CodeSnippet{}, &SnippetVersion{}, &SnippetComment{}, &SnippetCommentEditHistory{},
); err != nil {
return fmt.Errorf("自动迁移失败: %w", err)
}

View File

@@ -576,6 +576,75 @@ type LibrarySection struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// CodeSnippet 代码片段:用户所有,公开进广场,私有仅自己可见
type CodeSnippet struct {
ID uint `gorm:"primaryKey" json:"id"`
Title string `gorm:"size:120;not null" json:"title"`
Description string `gorm:"type:text" json:"description"`
Language string `gorm:"size:32;not null;index" json:"language"`
Code string `gorm:"type:text;not null" json:"code"`
Visibility string `gorm:"size:16;not null;default:private;index" json:"visibility"` // public/private
UserID uint `gorm:"index;not null" json:"user_id"`
ForkedFromID *uint `gorm:"index" json:"forked_from_id"`
CopyCount int `gorm:"not null;default:0" json:"copy_count"`
ViewCount int `gorm:"not null;default:0" json:"view_count"`
CommentCount int `gorm:"not null;default:0" json:"comment_count"`
ForkCount int `gorm:"not null;default:0" json:"fork_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SnippetVersion 编辑前旧值快照(同 PostEditHistory 思路)
type SnippetVersion struct {
ID uint `gorm:"primaryKey" json:"id"`
SnippetID uint `gorm:"index;not null" json:"snippet_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
OldTitle string `gorm:"size:120;not null;default:''" json:"old_title"`
OldLanguage string `gorm:"size:32;not null;default:''" json:"old_language"`
OldDescription string `gorm:"type:text" json:"old_description"`
OldCode string `gorm:"type:text;not null" json:"old_code"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// SnippetComment 片段评论(楼层树:ParentID 空=主评,非空=回复;RootID=所属主楼)
type SnippetComment struct {
ID uint `gorm:"primaryKey" json:"id"`
SnippetID uint `gorm:"index;not null" json:"snippet_id"`
UserID uint `gorm:"index;not null" json:"user_id,omitempty"`
ParentID *uint `gorm:"index" json:"parent_id"`
RootID *uint `gorm:"index" json:"root_id"`
Depth int `gorm:"not null;default:0" json:"depth"`
Content string `gorm:"type:text;not null" json:"content"`
DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"`
DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// SnippetCommentEditHistory 片段评论修订快照(每次编辑前的旧正文;登录用户可见)
type SnippetCommentEditHistory struct {
ID uint `gorm:"primaryKey" json:"id"`
CommentID uint `gorm:"index;not null" json:"comment_id"`
EditorID uint `gorm:"index;not null" json:"editor_id"`
OldContent string `gorm:"type:text;not null" json:"old_content"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
const (
SnippetVisibilityPublic = "public"
SnippetVisibilityPrivate = "private"
)
// NormalizeSnippetVisibility 归一化可见性:空或未知值一律回落 private
func NormalizeSnippetVisibility(v string) string {
if v == SnippetVisibilityPublic {
return SnippetVisibilityPublic
}
return SnippetVisibilityPrivate
}
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
type SiteSetting struct {
Key string `gorm:"primaryKey;size:64" json:"key"`

View File

@@ -104,6 +104,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).
WithSetting(settingSvc).
WithUploadsDir(filepath.Join(cfg.DataDir, "uploads"))
snippetSvc := service.NewSnippetService(model.DB)
adminUserSvc := service.NewAdminUserService(model.DB)
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
moderationSvc := service.NewModerationService(model.DB, notifSvc)
@@ -165,6 +166,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
Badge: badgeSvc,
LeaderboardSvc: leaderboardSvc,
LibrarySvc: librarySvc,
SnippetSvc: snippetSvc,
}
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
notifSvc.OnNotifyNew = func(userID uint) {
@@ -229,6 +231,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
pubAPI.GET("/library", h.LibraryList)
pubAPI.GET("/library/:slug", h.LibraryDetail)
pubAPI.GET("/library/files/:fid/download", h.LibraryFileDownload)
// 代码片段(公开广场/详情/评论/Fork 列表)
pubAPI.GET("/snippets", h.SnippetList)
pubAPI.GET("/snippets/languages", h.SnippetLanguages)
pubAPI.GET("/snippets/stats", h.SnippetStats)
pubAPI.GET("/snippets/:id", h.SnippetDetail)
pubAPI.GET("/snippets/:id/comments", h.SnippetComments)
pubAPI.GET("/snippets/:id/forks", h.SnippetForks)
pubAPI.GET("/site-state", h.SiteState)
// 自适应图片变体(?u=<源图URL>&w=<白名单宽度>,公开只读、immutable 缓存)
pubAPI.GET("/img", h.ImageVariant)
@@ -308,6 +317,19 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
// 时间线:从 Git releases 页导入更新日志
api.POST("/timeline/from-releases", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromReleases)
// 代码片段(登录区)
api.POST("/snippets", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateSnippet)), h.SnippetCreate)
api.GET("/snippets/mine", h.SnippetMine)
api.PUT("/snippets/:id", h.SnippetUpdate)
api.DELETE("/snippets/:id", h.SnippetDelete)
api.POST("/snippets/:id/fork", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateSnippet)), h.SnippetFork)
api.POST("/snippets/:id/copy", h.SnippetCopy)
api.GET("/snippets/:id/history", h.SnippetHistory)
api.POST("/snippets/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.SnippetCommentCreate)
api.PUT("/snippets/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.SnippetCommentUpdate)
api.DELETE("/snippets/:id/comments/:cid", h.SnippetCommentDelete)
api.GET("/snippets/:id/comments/:cid/history", h.SnippetCommentHistory)
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
{
@@ -508,6 +530,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
usersAPI.GET("/users/:id/messages", h.AdminUserAuditMessages)
}
// 代码片段管理(任意 staff)
staffAPI.GET("/snippets", h.AdminSnippetList)
staffAPI.DELETE("/snippets/:id", h.AdminSnippetDelete)
staffAPI.PUT("/snippets/:id/visibility", h.AdminSnippetSetVisibility)
// 板块管理(仅站长):增删改、排序、版主授权
boardsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermBoards))
{

View File

@@ -767,6 +767,18 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
return s.removeMember(roomID, targetID)
}
// DisplayName 取用户群聊展示名(昵称优先,退回用户名);用户不存在/封禁时返回空串
func (s *ChatService) DisplayName(userID uint) string {
var u model.User
if err := s.db.Select("id", "nickname", "username").First(&u, userID).Error; err != nil {
return ""
}
if u.Nickname != "" {
return u.Nickname
}
return u.Username
}
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true)。
// 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览,
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。

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

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

1022
backend/service/snippet.go Normal file

File diff suppressed because it is too large Load Diff

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

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

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

@@ -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 */
@@ -139,6 +140,7 @@
--ink: #eef1f6;
--ink-2: #b6bdc9;
--ink-3: #98a1b2;
--ink-4: #71798a; /* 比 --ink-3 更淡:首页帖子列表 meta 文字(作者/时间/统计) */
--line: rgba(238, 241, 246, 0.08); /* 装饰性分隔/卡片边 */
--line-2: rgba(238, 241, 246, 0.38); /* 功能性控件边界,1.4.11 ≥3:1 */
@@ -4006,6 +4008,35 @@ a.j13-mention-token:hover {
color: var(--accent);
}
/* 验证码发送按钮:与输入框同圆角,主色浅底强调可点;hover 实心浮起 */
.btn-code {
background: var(--accent-soft);
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
color: var(--accent);
font-weight: 600;
border-radius: var(--r-sm);
padding: 10px 16px;
}
.btn-code:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-on);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.1),
0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent);
}
.btn-code:active:not(:disabled) {
transform: translateY(0);
box-shadow: none;
}
.btn-code:disabled {
background: transparent;
border-color: var(--line-2);
color: var(--ink-3);
font-weight: 500;
cursor: not-allowed;
}
.btn-danger {
background: var(--danger-soft);
color: var(--danger);
@@ -6425,8 +6456,10 @@ a.j13-mention-token:hover {
user-select: none;
}
/* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */
.j13-like {
/* ----- 点赞/收藏按钮:未激活描边态,hover 主色浅底轻浮起;激活态浅底主色描边 ----- */
.j13-like,
.j13-fav {
border-radius: 999px;
border-color: var(--line-2);
color: var(--ink-2);
background: var(--panel);
@@ -6434,41 +6467,82 @@ a.j13-mention-token:hover {
border-color 0.15s ease,
color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.15s ease;
box-shadow 0.15s ease,
transform 0.15s ease;
}
.j13-like svg {
.j13-like svg,
.j13-fav svg {
transition: transform 0.15s ease;
}
.j13-like:hover:not(:disabled),
.j13-like.is-liked {
.j13-fav:hover:not(:disabled),
.j13-like.is-liked,
.j13-fav.is-favorited {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-soft);
}
/* 已赞态 hover 再加深一档,并给同色系投影;心形轻微放大(不改变布局) */
.j13-like:hover:not(:disabled) {
/* hover 浮起 + 同色系投影;心形/书签轻微放大(不改变布局) */
.j13-like:hover:not(:disabled),
.j13-fav:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 22%, transparent);
}
.j13-like.is-liked:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 17%, transparent);
.j13-like:active:not(:disabled),
.j13-fav:active:not(:disabled) {
transform: translateY(0);
box-shadow: none;
}
.j13-like:hover:not(:disabled) svg {
/* 文章页大按钮:12px 圆角矩形 + 幽灵底,弱化存在感;激活态浅底主色描边(文字恒为主色,保证可读) */
.j13-like-article {
border-radius: var(--r-sm);
border-color: transparent;
background: color-mix(in srgb, var(--ink) 5%, transparent);
}
.j13-like-article:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
}
.j13-like-article.is-liked,
.j13-like-article.is-favorited {
color: var(--accent);
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
box-shadow: none;
}
.j13-like-article.is-liked:hover:not(:disabled),
.j13-like-article.is-favorited:hover:not(:disabled) {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: var(--accent);
}
.j13-like:hover:not(:disabled) svg,
.j13-fav:hover:not(:disabled) svg {
transform: scale(1.15);
}
.j13-like:active:not(:disabled) svg {
.j13-like:active:not(:disabled) svg,
.j13-fav:active:not(:disabled) svg {
transform: scale(0.95);
}
.j13-like:focus-visible {
.j13-like:focus-visible,
.j13-fav:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.j13-like,
.j13-like svg {
.j13-fav,
.j13-like svg,
.j13-fav svg {
transition: none;
}
.j13-like:hover:not(:disabled),
.j13-fav:hover:not(:disabled),
.j13-like:active:not(:disabled),
.j13-fav:active:not(:disabled),
.j13-like:hover:not(:disabled) svg,
.j13-like:active:not(:disabled) svg {
.j13-fav:hover:not(:disabled) svg,
.j13-like:active:not(:disabled) svg,
.j13-fav:active:not(:disabled) svg {
transform: none;
}
}
@@ -7134,6 +7208,10 @@ a.j13-mention-token:hover {
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
/* 帖子列表行:除标题外的 meta 文字(作者/时间/回复/评论数/浏览数/板块胶囊)调浅一档,
让标题(var(--ink))更突出;hover 仍可被 above-link 的 accent 覆盖。 */
.j13-post-row .meta { color: var(--ink-4); }
.j13-post-row .j13-board-tag { color: var(--ink-4); }
.link-accent { color: var(--accent); }
.link-accent:hover { color: var(--accent-hi); }
@@ -9074,12 +9152,13 @@ html.dark .prose-content pre[data-theme] span {
/* ============================================================
前台 main 纸面 + 首页三栏「白纸一体」
main 铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
1px --line 描边与页头下边线围合成框;站长配置了自定义站点
背景图时让位(保持透出画布)。左栏无框化,内容直接坐在纸面上;
右栏保留卡片板块感(j13-module 原样)。
main 恒定铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
1px --line 描边与页头下边线围合成框;即使站长配置了自定义站点
背景图也不让位——header/main/footer 连成一张白纸保证内容可读,
背景图只在页缘(1440px 画布之外)露出。左栏无框化,内容直接坐
在纸面上;右栏保留卡片板块感(j13-module 原样)。
============================================================ */
html:not(.j13-has-site-bg) #main {
#main {
background: var(--main-bg);
border-left: 1px solid var(--line);
border-right: 1px solid var(--line);
@@ -10399,3 +10478,209 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
box-shadow: var(--shadow-lg);
}
/* ===== 片段广场:编辑器窗格卡片 ===== */
/* 卡片内 shiki 双主题着色(独立作用域,不依赖 .prose-content)。
新版 shiki 的 token span 只声明 --shiki-light/--shiki-dark 变量,color 由这里应用 */
.j13-sn-code {
color: var(--shiki-light, var(--ink-2));
}
.j13-sn-code span[style*="--shiki-light"] {
color: var(--shiki-light);
background: transparent;
}
html.dark .j13-sn-code {
color: var(--shiki-dark, var(--ink-2));
}
html.dark .j13-sn-code span[style*="--shiki-dark"] {
color: var(--shiki-dark) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
}
/* 语言下拉弹层 */
.j13-sn-lang-menu {
animation: j13-sn-pop 140ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: top left;
}
@keyframes j13-sn-pop {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* 搜索软聚焦:光晕替代硬边框 */
.j13-sn-search {
transition: box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;
}
.j13-sn-search:focus {
outline: none;
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
/* ===== 片段广场:赛博控制台 Hero(明暗双主题自适应) ===== */
.j13-hero {
/* 浅色:白玻璃底 + 墨色文字,辉光收敛 */
--hero-bg: #f7f9fc;
--hero-ink: #0f172a;
--hero-ink-2: #475569;
--hero-ink-3: #8593ab;
--hero-line: rgba(15, 23, 42, 0.10);
--hero-chip-bg: rgba(15, 23, 42, 0.035);
--hero-chip-icon: rgba(15, 23, 42, 0.06);
--hero-track: rgba(15, 23, 42, 0.07);
background:
radial-gradient(ellipse 60% 80% at 12% -20%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 65%),
radial-gradient(ellipse 45% 70% at 88% 115%, rgba(13, 148, 136, 0.08), transparent 70%),
radial-gradient(ellipse 35% 45% at 60% 40%, rgba(124, 58, 237, 0.06), transparent 70%),
var(--hero-bg);
border: 1px solid var(--line);
box-shadow: var(--shadow-lg);
}
html.dark .j13-hero {
/* 深色:深夜底 + 霓虹文字,辉光拉满 */
--hero-bg: #0a0e16;
--hero-ink: #e8edf7;
--hero-ink-2: #9aa7c2;
--hero-ink-3: #5f6b88;
--hero-line: rgba(148, 163, 205, 0.16);
--hero-chip-bg: rgba(148, 163, 205, 0.07);
--hero-chip-icon: rgba(148, 163, 205, 0.11);
--hero-track: rgba(148, 163, 205, 0.10);
background:
radial-gradient(ellipse 60% 80% at 12% -20%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 65%),
radial-gradient(ellipse 45% 70% at 88% 115%, rgba(94, 234, 212, 0.14), transparent 70%),
radial-gradient(ellipse 35% 45% at 60% 40%, rgba(167, 139, 250, 0.10), transparent 70%),
var(--hero-bg);
border: 1px solid rgba(148, 163, 205, 0.22);
box-shadow:
0 0 0 1px rgba(10, 14, 22, 0.6),
0 18px 50px -18px color-mix(in srgb, var(--accent) 28%, transparent),
inset 0 1px 0 rgba(232, 237, 247, 0.06);
}
/* 网格底纹(铺满整个面板,仅底部极淡渐出避免压文字) */
.j13-hero-grid {
background-image:
linear-gradient(rgba(15, 23, 42, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(15, 23, 42, 0.05) 1px, transparent 1px);
background-size: 30px 30px;
mask-image: linear-gradient(to bottom, black 82%, rgba(0, 0, 0, 0.35) 100%);
-webkit-mask-image: linear-gradient(to bottom, black 82%, rgba(0, 0, 0, 0.35) 100%);
}
html.dark .j13-hero-grid {
background-image:
linear-gradient(rgba(148, 163, 205, 0.09) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 205, 0.09) 1px, transparent 1px);
}
/* 水平扫描光束(自上而下巡航) */
.j13-hero-scan {
position: absolute;
left: 0;
right: 0;
height: 72px;
top: -72px;
background: linear-gradient(
to bottom,
transparent,
color-mix(in srgb, var(--accent) 8%, transparent) 60%,
color-mix(in srgb, var(--accent) 30%, transparent)
);
filter: blur(0.5px);
animation: j13-hero-scan 8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
pointer-events: none;
}
@keyframes j13-hero-scan {
0% { transform: translateY(0); opacity: 0; }
8% { opacity: 1; }
60% { opacity: 1; }
68%, 100% { transform: translateY(140%); opacity: 0; }
}
/* 大标题霓虹渐变 */
.j13-hero-title {
background: linear-gradient(100deg, var(--hero-ink) 20%, var(--accent) 55%, #5eead4 85%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 34px color-mix(in srgb, var(--accent) 32%, transparent);
}
/* 统计数字进入 */
.j13-hero-stat {
animation: j13-hero-rise 560ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes j13-hero-rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* 语言均衡条:加载展开 */
.j13-hero-langbar {
transform-origin: left center;
animation: j13-hero-bar 800ms cubic-bezier(0.22, 1, 0.36, 1) 260ms both;
}
@keyframes j13-hero-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* 呼吸状态灯 */
.j13-hero-pulse {
animation: j13-hero-pulse 2.2s ease-in-out infinite;
}
@keyframes j13-hero-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
50% { opacity: 0.6; box-shadow: 0 0 0 6px transparent; }
}
/* 光标闪烁 */
.j13-hero-caret {
animation: j13-hero-caret 1.1s steps(2, start) infinite;
}
@keyframes j13-hero-caret {
to { visibility: hidden; }
}
/* 语言跑马灯 */
.j13-hero-ticker {
overflow: hidden;
mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
-webkit-mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
}
.j13-hero-ticker-track {
display: inline-flex;
align-items: center;
gap: 2.25rem;
white-space: nowrap;
animation: j13-hero-ticker 36s linear infinite;
}
.j13-hero-ticker:hover .j13-hero-ticker-track {
animation-play-state: paused;
}
@keyframes j13-hero-ticker {
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.j13-hero-scan, .j13-hero-pulse, .j13-hero-caret, .j13-hero-ticker-track { animation: none; }
.j13-hero-stat, .j13-hero-langbar { animation-duration: 1ms; }
}
/* ===== 片段卡片:语言霓虹 ===== */
.j13-snc {
position: relative;
}
/* 顶部霓虹语言条 */
.j13-snc::before {
content: "";
position: absolute;
top: 0;
left: 14px;
right: 14px;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--sn-lang, var(--accent)), transparent 75%);
opacity: 0.75;
transition: opacity 200ms ease, left 200ms ease, right 200ms ease;
}
.j13-snc:hover::before {
opacity: 1;
left: 10px;
right: 10px;
}
.j13-snc:hover {
box-shadow:
0 10px 34px -14px color-mix(in srgb, var(--sn-lang, var(--accent)) 45%, transparent),
var(--shadow-lg) !important;
}

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,49 @@
import type { Metadata } from "next";
import { fetchSnippets, fetchSnippetLanguages, fetchSnippetStats, type SnippetStats } from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
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();
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}
/>
</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

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

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,612 @@
"use client";
import { useMemo, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import {
Search,
X,
Copy,
Eye,
MessageSquare,
GitFork,
Plus,
Lock,
Loader2,
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 { hasAuthCookieHint } from "@/lib/cookies";
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" },
];
function CardSkeleton() {
return (
<div
className="rounded-xl overflow-hidden animate-pulse"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<div className="px-3.5 pt-3.5 pb-2.5">
<div className="h-2.5 w-16 rounded" style={{ background: "var(--panel-2)" }} />
<div className="mt-2.5 h-3.5 w-3/4 rounded" style={{ background: "var(--panel-2)" }} />
</div>
<div className="mx-3.5 rounded-lg h-[128px]" style={{ background: "var(--panel-2)" }} />
<div className="px-3.5 py-3 mt-1">
<div className="h-2.5 w-2/3 rounded" style={{ background: "var(--panel-2)" }} />
</div>
</div>
);
}
export default function SnippetExplorer({
initialItems,
initialTotal,
languages,
urlStyle,
stats,
}: {
initialItems: SnippetListItem[];
initialTotal: number;
languages: string[];
urlStyle: UrlStyle;
stats: SnippetStats | null;
}) {
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 isLoggedIn = typeof window !== "undefined" ? hasAuthCookieHint() : false;
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} />
{/* 工具台: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)" }}>
{loading ? (
<Loader2 size={12} className="animate-spin" />
) : (
<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> 个片段
{loading ? ",加载中…" : ""}
</span>
</div>
{/* 列表 / 空态 */}
{loading && items.length === 0 ? (
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
) : items.length === 0 && !loading ? (
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,254 @@
"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";
import { hasAuthCookieHint } from "@/lib/cookies";
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 }: { stats: SnippetStats | null }) {
const isLoggedIn = typeof window !== "undefined" ? hasAuthCookieHint() : false;
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,7 @@ import {
Layers,
Images,
Link2,
CodeXml,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -118,6 +119,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

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

@@ -4987,3 +4987,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

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

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