feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等
220
LICENSE
@@ -1,38 +1,202 @@
|
|||||||
姜十三论坛 专有软件许可协议
|
Apache License
|
||||||
Proprietary Software License Agreement
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
Copyright (c) 2026 姜十三. All Rights Reserved.
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
版权所有 (c) 2026 姜十三。保留所有权利。
|
|
||||||
|
|
||||||
本软件(包括但不限于源代码、目标代码、文档、图像及其他随附资料,
|
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. 以任何目的(包括商业、内部生产、教学培训等)使用、运行或部署
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
本软件的全部或任何部分;
|
the copyright owner that is granting the License.
|
||||||
2. 复制、影印、转载、镜像、收录到任何数据库或以任何方式再现本软件;
|
|
||||||
3. 修改、改编、翻译、合并、反向工程、反编译、反汇编本软件,或基于
|
|
||||||
本软件创作衍生作品;
|
|
||||||
4. 出售、出租、出借、分销、再许可、托管(SaaS)或以其他方式向第三方
|
|
||||||
提供本软件或其任何副本;
|
|
||||||
5. 删除、遮盖或更改本软件中的版权声明、商标或其他权利声明。
|
|
||||||
|
|
||||||
仅可出于个人学习、评估是否购买授权的目的浏览本仓库中的源代码;该等
|
"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
@@ -1,101 +1,128 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
# 姜十三论坛(jiang13-bbs)
|
# 姜十三论坛(jiang13-bbs)
|
||||||
|
|
||||||
一个基于 Go + Next.js 的现代社区论坛系统,采用 SSR 直出 + HttpOnly Cookie 认证方案,具备完整的帖子/评论/通知/签到/管理后台功能。
|
一个基于 **Go + Next.js** 构建的现代化社区论坛系统,SSR 直出、HttpOnly Cookie 认证,内置内容社区、代码片段、书库、积分体系、实时群聊与完整管理后台,开箱即用。
|
||||||
|
|
||||||
> **许可声明:** 本项目为专有软件(Proprietary),源码可见但不构成开源授权。详见 [LICENSE](./LICENSE)。未经书面许可,不得使用、部署或分发。
|
[](./LICENSE)
|
||||||
|
[](./VERSION)
|
||||||
|
[](https://go.dev/)
|
||||||
|
[](https://nextjs.org/)
|
||||||
|
[](#贡献指南)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [项目截图](#项目截图)
|
||||||
|
- [功能特性](#功能特性)
|
||||||
|
- [技术栈](#技术栈)
|
||||||
|
- [快速开始](#快速开始)
|
||||||
|
- [配置说明](#配置说明)
|
||||||
|
- [Docker 部署](#docker-部署)
|
||||||
|
- [项目结构](#项目结构)
|
||||||
|
- [安全设计](#安全设计)
|
||||||
|
- [开发与测试](#开发与测试)
|
||||||
|
- [更新日志](#更新日志)
|
||||||
|
- [贡献指南](#贡献指南)
|
||||||
|
- [许可证](#许可证)
|
||||||
|
- [联系方式](#联系方式)
|
||||||
|
- [赞赏支持](#赞赏支持)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目截图
|
||||||
|
|
||||||
|
| 首页信息流 | 帖子详情 |
|
||||||
|
| :---: | :---: |
|
||||||
|
|  |  |
|
||||||
|
| 代码片段广场 | 书库在线阅读 |
|
||||||
|
|  |  |
|
||||||
|
| 管理后台仪表盘 | 访问统计分析 |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
<!--
|
||||||
|
后台设置类截图(邮件 / 过滤 / 维护 / 安全 / 存储,含移动端与暗色模式):
|
||||||
|
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 |
|
| 后端 | Go 1.27 · Gin · GORM · PostgreSQL 17 |
|
||||||
| 前端 | Next.js 16 (App Router) · TypeScript · Tailwind CSS v4 |
|
| 前端 | Next.js 16(App Router)· React 19 · TypeScript · Tailwind CSS v4 |
|
||||||
| 认证 | JWT (HttpOnly Cookie) + Refresh Token 轮转 + CSRF 双提交 + Token Version 撤销 |
|
| 认证 | JWT(HttpOnly Cookie)+ Refresh Token 轮转 + CSRF 双提交 + Token Version 撤销 |
|
||||||
| 部署 | 官方:`deploy/` Docker Compose(Next SSR + Go + Postgres,反代自备);本地开发仅 Compose 起数据库 |
|
| 实时 | WebSocket(gorilla/websocket) |
|
||||||
|
| 存储 | 本地存储 / S3(MinIO),Markdown 引擎 Goldmark,代码高亮 Shiki |
|
||||||
---
|
| 部署 | Docker Compose:Next SSR + Go API + PostgreSQL(反向代理自备) |
|
||||||
|
|
||||||
## 功能概览
|
|
||||||
|
|
||||||
### 用户
|
|
||||||
|
|
||||||
- 注册 / 登录 / 登出(注册即登录,免二次跳转)
|
|
||||||
- 个人资料编辑:昵称、头像、邮箱、个性签名
|
|
||||||
- 修改密码(改密后全端踢下线)
|
|
||||||
- 个人主页:发帖列表、评论列表(分页)、签到积分
|
|
||||||
- 每日签到(积分 + 连续签到天数)
|
|
||||||
|
|
||||||
### 内容
|
|
||||||
|
|
||||||
- 帖子发帖 / 编辑 / 删除(作者本人或管理员)
|
|
||||||
- 帖子类型:普通帖 / 问答帖
|
|
||||||
- 帖子置顶 / 推荐(仅管理员)
|
|
||||||
- 评论楼层制:主评论 = 楼层,子评论不占楼层,最多 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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -104,8 +131,8 @@ jiang13-bbs/
|
|||||||
### 前置条件
|
### 前置条件
|
||||||
|
|
||||||
- Go 1.27+
|
- Go 1.27+
|
||||||
- Node.js 20+ / pnpm 或 npm
|
- Node.js 20+(npm)
|
||||||
- PostgreSQL 17(或通过 Docker Compose 启动)
|
- PostgreSQL 17(可通过 Docker Compose 启动)
|
||||||
|
|
||||||
### 1. 启动数据库
|
### 1. 启动数据库
|
||||||
|
|
||||||
@@ -123,9 +150,9 @@ go mod download
|
|||||||
go run ./cmd/jiang13
|
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. 启动前端
|
### 3. 启动前端
|
||||||
|
|
||||||
@@ -136,13 +163,17 @@ npm install
|
|||||||
npm run dev
|
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
|
```ini
|
||||||
[server]
|
[server]
|
||||||
@@ -152,108 +183,189 @@ HTTP_PORT = 3001
|
|||||||
DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
|
DSN = postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable
|
||||||
|
|
||||||
[security]
|
[security]
|
||||||
; 留空则自动生成并持久化到 data/.jwt_secret
|
; 留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值
|
||||||
; 生产环境务必显式指定强随机值
|
|
||||||
JWT_SECRET =
|
JWT_SECRET =
|
||||||
|
; 32 字节随机值的标准 Base64(openssl rand -base64 32),用于加密邮件 / S3 凭据
|
||||||
|
SETTINGS_MASTER_KEY =
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
DATA = data
|
DATA = data
|
||||||
|
|
||||||
[app]
|
[app]
|
||||||
DEV_MODE = true
|
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
|
```bash
|
||||||
# SSR 与 middleware 直连后端(本地开发)
|
# SSR 与 middleware 直连后端
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||||
|
|
||||||
# 部署到 Cloudflare Workers 等边缘环境时必须显式配置
|
# 仅服务端可见;生产构建必须显式配置
|
||||||
# BACKEND_URL=https://your-backend.example.com
|
# 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 | 用途 | 属性 |
|
| Cookie | 用途 | 属性 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| `j13_token` | Access JWT(15 分钟) | HttpOnly · SameSite=Lax |
|
| `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 |
|
| `j13_csrf` | CSRF Token(JS 可读) | SameSite=Lax |
|
||||||
|
|
||||||
- **SSR 直出登录态:** `middleware.ts` 在页面/RSC 请求前本地解码 access token 的 exp,过期时静默调后端 refresh 轮转,新 cookie 注入请求头供 SSR 使用并透传浏览器。
|
- **SSR 直出登录态**:`middleware.ts` 在渲染前检查 Access Token 过期时间,过期则静默轮转,新 Cookie 同时供 SSR 使用并透传浏览器
|
||||||
- **客户端续期:** `lib/api.ts` 的 `fetchWithRefresh` 遇到 401 自动调 `/api/auth/refresh` 重试。
|
- **Refresh Token 一次性轮转 + 盗用检测**:已吊销 Token 被重放时,撤销该用户全部 Refresh Token
|
||||||
- **撤销机制:** 改密码 / 封禁 / 管理员强制下线时递增 `User.TokenVersion`,所有旧 JWT 立即失效。
|
- **Token Version 撤销**:改密 / 封禁 / 强制下线时递增版本号,旧 JWT 立即失效
|
||||||
- **盗用检测:** Refresh Token 一次性轮转,已吊销 token 重放时撤销该用户全部 refresh token。
|
- **速率限制**:登录、注册、发帖(新用户 24h 冷静期)、评论、上传等分级限流,管理员豁免
|
||||||
|
- **安全响应头**:CSP、HSTS、X-Frame-Options: DENY、X-Content-Type-Options、Referrer-Policy
|
||||||
生产 HTTPS 下 cookie 名自动启用 `__Host-` 前缀。
|
- 生产 HTTPS 下 Cookie 自动启用 `__Host-` 前缀,要求前后端必须**同源部署**(不能拆分到子域)
|
||||||
|
|
||||||
若本机曾跑过早期构建,浏览器里可能还留着 `Path=/api/auth` 的 `j13_refresh`(HttpOnly,页面脚本删不掉)。打开开发者工具 → Application → Cookies,删掉该条,或对该源执行「清除站点数据」。之后只应存在 `Path=/` 的三枚 cookie。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 部署
|
## 开发与测试
|
||||||
|
|
||||||
**官方运行时是 `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
|
```bash
|
||||||
cd deploy
|
# 后端测试
|
||||||
docker compose pull
|
cd backend && go test ./...
|
||||||
docker compose up -d
|
|
||||||
|
# 前端测试(Node 内置 test runner)
|
||||||
|
cd frontend && npm test
|
||||||
|
|
||||||
|
# 前端代码检查
|
||||||
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
后台一键更新、预编译包不在本期。Cloudflare Workers 不是官方运行时。
|
开发约定:
|
||||||
|
|
||||||
### 生产环境要点
|
- Next.js 16 中 `params` / `searchParams` / `cookies()` / `headers()` 均需 `await`
|
||||||
|
- 权限校验只在 Go 后端执行,前端不做判定
|
||||||
1. **官方 Compose:** `DEV_MODE=false`,强随机 `JWT_SECRET` 与 `POSTGRES_PASSWORD`;由你现有的 Nginx/Caddy 提供 HTTPS,并把页面与 `/api`、`/api/ws`、`/uploads` 挂到同一 Host。
|
- 配色仅修改 `frontend/app/globals.css` 中的 CSS 令牌,不在组件内硬编码颜色
|
||||||
2. **CORS 与 Canonical:** 设 `SITE_URL` 为对外 origin(不要改 `router.go` 写死域名)。官方 Compose 会同时注入 api 与 web;web 用它生成 Canonical / sitemap,不要填 `http://api:3001`。
|
- 所有公开页面保持 SSR / ISR,SEO 关键内容必须在初始 HTML 中
|
||||||
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`。
|
版本发布与部署契约变更记录见 [deploy/CHANGELOG.md](./deploy/CHANGELOG.md),当前版本 **v0.2.9**。
|
||||||
- **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)。
|
## 贡献指南
|
||||||
|
|
||||||
|
欢迎提交 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="微信打赏" />
|
||||||
|
|
||||||
|
<img src="docs/images/zfb.png" width="220" alt="支付宝打赏" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">微信打赏 | 支付宝打赏</p>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
@@ -240,11 +241,32 @@ func (h *Handlers) JoinChatRoom(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _, err := h.Chat.Join(claims.ID, roomID)
|
member, joined, err := h.Chat.Join(claims.ID, roomID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||||
return
|
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})
|
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"},
|
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})
|
c.JSON(http.StatusOK, gin.H{"added": added})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type Handlers struct {
|
|||||||
Badge *service.BadgeService
|
Badge *service.BadgeService
|
||||||
LeaderboardSvc *service.LeaderboardService
|
LeaderboardSvc *service.LeaderboardService
|
||||||
LibrarySvc *service.LibraryService
|
LibrarySvc *service.LibraryService
|
||||||
|
SnippetSvc *service.SnippetService
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||||
|
|||||||
424
backend/handler/snippet.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ func InitDB(dsn string) error {
|
|||||||
&Badge{}, &UserBadge{},
|
&Badge{}, &UserBadge{},
|
||||||
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{},
|
&PostPollVote{}, &PostLotteryEntry{}, &Ad{}, &SidebarWidget{}, &FriendLink{},
|
||||||
&ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{},
|
&ImportRecord{}, &LibraryDoc{}, &LibraryFile{}, &LibrarySection{},
|
||||||
|
&CodeSnippet{}, &SnippetVersion{}, &SnippetComment{}, &SnippetCommentEditHistory{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("自动迁移失败: %w", err)
|
return fmt.Errorf("自动迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -576,6 +576,75 @@ type LibrarySection struct {
|
|||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
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)
|
// SiteSetting 站点级键值设置(如历史在线峰值 peak_online)
|
||||||
type SiteSetting struct {
|
type SiteSetting struct {
|
||||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).
|
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).
|
||||||
WithSetting(settingSvc).
|
WithSetting(settingSvc).
|
||||||
WithUploadsDir(filepath.Join(cfg.DataDir, "uploads"))
|
WithUploadsDir(filepath.Join(cfg.DataDir, "uploads"))
|
||||||
|
snippetSvc := service.NewSnippetService(model.DB)
|
||||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||||
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
||||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||||
@@ -165,6 +166,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
Badge: badgeSvc,
|
Badge: badgeSvc,
|
||||||
LeaderboardSvc: leaderboardSvc,
|
LeaderboardSvc: leaderboardSvc,
|
||||||
LibrarySvc: librarySvc,
|
LibrarySvc: librarySvc,
|
||||||
|
SnippetSvc: snippetSvc,
|
||||||
}
|
}
|
||||||
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
||||||
notifSvc.OnNotifyNew = func(userID uint) {
|
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", h.LibraryList)
|
||||||
pubAPI.GET("/library/:slug", h.LibraryDetail)
|
pubAPI.GET("/library/:slug", h.LibraryDetail)
|
||||||
pubAPI.GET("/library/files/:fid/download", h.LibraryFileDownload)
|
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)
|
pubAPI.GET("/site-state", h.SiteState)
|
||||||
// 自适应图片变体(?u=<源图URL>&w=<白名单宽度>,公开只读、immutable 缓存)
|
// 自适应图片变体(?u=<源图URL>&w=<白名单宽度>,公开只读、immutable 缓存)
|
||||||
pubAPI.GET("/img", h.ImageVariant)
|
pubAPI.GET("/img", h.ImageVariant)
|
||||||
@@ -308,6 +317,19 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// 时间线:从 Git releases 页导入更新日志
|
// 时间线:从 Git releases 页导入更新日志
|
||||||
api.POST("/timeline/from-releases", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromReleases)
|
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
|
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
|
||||||
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
|
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)
|
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))
|
boardsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermBoards))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -767,6 +767,18 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
|
|||||||
return s.removeMember(roomID, targetID)
|
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)。
|
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true)。
|
||||||
// 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览,
|
// 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览,
|
||||||
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。
|
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const (
|
|||||||
|
|
||||||
// HeaderNavBuiltinKeys 内置菜单 key 白名单(顺序即默认顺序);
|
// HeaderNavBuiltinKeys 内置菜单 key 白名单(顺序即默认顺序);
|
||||||
// 与前端 lib/headerNav.ts 的 HEADER_NAV_BUILTIN_KEYS 保持一致。
|
// 与前端 lib/headerNav.ts 的 HEADER_NAV_BUILTIN_KEYS 保持一致。
|
||||||
var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library"}
|
var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library", "snippets"}
|
||||||
|
|
||||||
// HeaderNavItem 顶部导航菜单项:
|
// HeaderNavItem 顶部导航菜单项:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -25,18 +25,19 @@ func newHeaderNavTestService(t *testing.T) *SettingService {
|
|||||||
func TestHeaderNavDefaultWhenMissing(t *testing.T) {
|
func TestHeaderNavDefaultWhenMissing(t *testing.T) {
|
||||||
s := newHeaderNavTestService(t)
|
s := newHeaderNavTestService(t)
|
||||||
got := s.HeaderNav()
|
got := s.HeaderNav()
|
||||||
if len(got) != 3 ||
|
if len(got) != 4 ||
|
||||||
got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) ||
|
got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) ||
|
||||||
got[1] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "leaderboard"}) ||
|
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)
|
t.Fatalf("missing row should fall back to builtin defaults, got %+v", got)
|
||||||
}
|
}
|
||||||
pub, err := s.Public()
|
pub, err := s.Public()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(pub.HeaderNav) != 3 {
|
if len(pub.HeaderNav) != 4 {
|
||||||
t.Fatalf("Public().HeaderNav len = %d, want 3", len(pub.HeaderNav))
|
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) {
|
func TestParseHeaderNavJSONTolerant(t *testing.T) {
|
||||||
// 坏 JSON 回落默认
|
// 坏 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)
|
t.Fatalf("bad json should fall back to defaults, got %+v", got)
|
||||||
}
|
}
|
||||||
// 空数组 = 全部隐藏
|
// 空数组 = 全部隐藏
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const (
|
|||||||
RateAdSubmit = "ad_submit" // 广告申购 5/分钟(按 IP)
|
RateAdSubmit = "ad_submit" // 广告申购 5/分钟(按 IP)
|
||||||
RateCaptcha = "captcha" // 验证码刷新 30/分钟
|
RateCaptcha = "captcha" // 验证码刷新 30/分钟
|
||||||
RateTelemetry = "telemetry" // 页面浏览埋点 120/分钟(按 IP)
|
RateTelemetry = "telemetry" // 页面浏览埋点 120/分钟(按 IP)
|
||||||
|
RateSnippet = "snippet" // 代码片段创建/Fork 10/分钟
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultRateLimiter 创建默认速率限制器
|
// DefaultRateLimiter 创建默认速率限制器
|
||||||
@@ -102,5 +103,6 @@ func DefaultRateLimiter() *RateLimiter {
|
|||||||
rl.SetLimit(RateAdSubmit, 5) // 广告申购 5/分钟
|
rl.SetLimit(RateAdSubmit, 5) // 广告申购 5/分钟
|
||||||
rl.SetLimit(RateCaptcha, 30) // 验证码 30/分钟
|
rl.SetLimit(RateCaptcha, 30) // 验证码 30/分钟
|
||||||
rl.SetLimit(RateTelemetry, 120) // 埋点 120/分钟
|
rl.SetLimit(RateTelemetry, 120) // 埋点 120/分钟
|
||||||
|
rl.SetLimit(RateSnippet, 10) // 代码片段 10/分钟
|
||||||
return rl
|
return rl
|
||||||
}
|
}
|
||||||
|
|||||||
1022
backend/service/snippet.go
Normal file
BIN
docs/images/wechat.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/images/zfb.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/images/书库.png
Normal file
|
After Width: | Height: | Size: 325 KiB |
BIN
docs/images/代码片段.png
Normal file
|
After Width: | Height: | Size: 551 KiB |
BIN
docs/images/帖子详情.png
Normal file
|
After Width: | Height: | Size: 395 KiB |
BIN
docs/images/管理后台.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
docs/images/访问统计.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
docs/images/首页.png
Normal file
|
After Width: | Height: | Size: 344 KiB |
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"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 { useEffect, useState } from "react";
|
||||||
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
|
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
|
||||||
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
|
||||||
@@ -19,6 +19,7 @@ const BUILTIN_ICONS: Record<HeaderNavBuiltinKey, React.ReactNode> = {
|
|||||||
home: <Home size={14} />,
|
home: <Home size={14} />,
|
||||||
leaderboard: <Trophy size={14} />,
|
leaderboard: <Trophy size={14} />,
|
||||||
library: <BookOpen size={14} />,
|
library: <BookOpen size={14} />,
|
||||||
|
snippets: <CodeXml size={14} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */
|
/** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */
|
||||||
@@ -26,6 +27,7 @@ const BUILTIN_HINTS: Record<HeaderNavBuiltinKey, string> = {
|
|||||||
home: "站点首页 /",
|
home: "站点首页 /",
|
||||||
leaderboard: "排行榜页(地址随伪静态风格变化)",
|
leaderboard: "排行榜页(地址随伪静态风格变化)",
|
||||||
library: "书库页(地址随伪静态风格变化)",
|
library: "书库页(地址随伪静态风格变化)",
|
||||||
|
snippets: "片段广场页(地址随伪静态风格变化)",
|
||||||
};
|
};
|
||||||
|
|
||||||
type BuiltinSwitches = Record<HeaderNavBuiltinKey, boolean>;
|
type BuiltinSwitches = Record<HeaderNavBuiltinKey, boolean>;
|
||||||
@@ -42,6 +44,7 @@ function switchesOf(items: HeaderNavItem[]): BuiltinSwitches {
|
|||||||
home: present.has("home"),
|
home: present.has("home"),
|
||||||
leaderboard: present.has("leaderboard"),
|
leaderboard: present.has("leaderboard"),
|
||||||
library: present.has("library"),
|
library: present.has("library"),
|
||||||
|
snippets: present.has("snippets"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
277
frontend/app/admin/snippets/SnippetsAdmin.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/app/admin/snippets/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
@@ -874,6 +874,10 @@ export default function ChatRoomClient({
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.status === "joined") {
|
||||||
|
void refreshMembers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.status === "kicked") {
|
if (data.status === "kicked") {
|
||||||
toast("你已被移出该群");
|
toast("你已被移出该群");
|
||||||
} else {
|
} else {
|
||||||
@@ -883,7 +887,7 @@ export default function ChatRoomClient({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
return off;
|
return off;
|
||||||
}, [roomId, router]);
|
}, [roomId, router, refreshMembers]);
|
||||||
|
|
||||||
// 禁言/解禁实时通知:更新成员列表 + 插入系统提示
|
// 禁言/解禁实时通知:更新成员列表 + 插入系统提示
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
--ink: #161a22;
|
--ink: #161a22;
|
||||||
--ink-2: #4b5260;
|
--ink-2: #4b5260;
|
||||||
--ink-3: #5b6270;
|
--ink-3: #5b6270;
|
||||||
|
--ink-4: #949aab; /* 比 --ink-3 更浅:首页帖子列表 meta 文字(作者/时间/统计) */
|
||||||
--line: #e4e7ec; /* 装饰性分隔/卡片边(非交互,配合阴影与底色高差) */
|
--line: #e4e7ec; /* 装饰性分隔/卡片边(非交互,配合阴影与底色高差) */
|
||||||
--line-2: #7c8698; /* 功能性边界:输入框/按钮/分页等可交互控件,满足 1.4.11 ≥3:1 */
|
--line-2: #7c8698; /* 功能性边界:输入框/按钮/分页等可交互控件,满足 1.4.11 ≥3:1 */
|
||||||
|
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
--ink: #eef1f6;
|
--ink: #eef1f6;
|
||||||
--ink-2: #b6bdc9;
|
--ink-2: #b6bdc9;
|
||||||
--ink-3: #98a1b2;
|
--ink-3: #98a1b2;
|
||||||
|
--ink-4: #71798a; /* 比 --ink-3 更淡:首页帖子列表 meta 文字(作者/时间/统计) */
|
||||||
--line: rgba(238, 241, 246, 0.08); /* 装饰性分隔/卡片边 */
|
--line: rgba(238, 241, 246, 0.08); /* 装饰性分隔/卡片边 */
|
||||||
--line-2: rgba(238, 241, 246, 0.38); /* 功能性控件边界,1.4.11 ≥3:1 */
|
--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);
|
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 {
|
.btn-danger {
|
||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
@@ -6425,8 +6456,10 @@ a.j13-mention-token:hover {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */
|
/* ----- 点赞/收藏按钮:未激活描边态,hover 主色浅底轻浮起;激活态浅底主色描边 ----- */
|
||||||
.j13-like {
|
.j13-like,
|
||||||
|
.j13-fav {
|
||||||
|
border-radius: 999px;
|
||||||
border-color: var(--line-2);
|
border-color: var(--line-2);
|
||||||
color: var(--ink-2);
|
color: var(--ink-2);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -6434,41 +6467,82 @@ a.j13-mention-token:hover {
|
|||||||
border-color 0.15s ease,
|
border-color 0.15s ease,
|
||||||
color 0.15s ease,
|
color 0.15s ease,
|
||||||
background-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;
|
transition: transform 0.15s ease;
|
||||||
}
|
}
|
||||||
.j13-like:hover:not(:disabled),
|
.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);
|
border-color: var(--accent);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
/* 已赞态 hover 再加深一档,并给同色系投影;心形轻微放大(不改变布局) */
|
/* hover 浮起 + 同色系投影;心形/书签轻微放大(不改变布局) */
|
||||||
.j13-like:hover:not(:disabled) {
|
.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);
|
box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
}
|
}
|
||||||
.j13-like.is-liked:hover:not(:disabled) {
|
.j13-like:active:not(:disabled),
|
||||||
background: color-mix(in srgb, var(--accent) 17%, transparent);
|
.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);
|
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);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
.j13-like:focus-visible {
|
.j13-like:focus-visible,
|
||||||
|
.j13-fav:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.j13-like,
|
.j13-like,
|
||||||
.j13-like svg {
|
.j13-fav,
|
||||||
|
.j13-like svg,
|
||||||
|
.j13-fav svg {
|
||||||
transition: none;
|
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: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;
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7134,6 +7208,10 @@ a.j13-mention-token:hover {
|
|||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
font-variant-numeric: tabular-nums;
|
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 { color: var(--accent); }
|
||||||
.link-accent:hover { color: var(--accent-hi); }
|
.link-accent:hover { color: var(--accent-hi); }
|
||||||
|
|
||||||
@@ -9074,12 +9152,13 @@ html.dark .prose-content pre[data-theme] span {
|
|||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
前台 main 纸面 + 首页三栏「白纸一体」
|
前台 main 纸面 + 首页三栏「白纸一体」
|
||||||
main 铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
|
main 恒定铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
|
||||||
1px --line 描边与页头下边线围合成框;站长配置了自定义站点
|
1px --line 描边与页头下边线围合成框;即使站长配置了自定义站点
|
||||||
背景图时让位(保持透出画布)。左栏无框化,内容直接坐在纸面上;
|
背景图也不让位——header/main/footer 连成一张白纸保证内容可读,
|
||||||
右栏保留卡片板块感(j13-module 原样)。
|
背景图只在页缘(1440px 画布之外)露出。左栏无框化,内容直接坐
|
||||||
|
在纸面上;右栏保留卡片板块感(j13-module 原样)。
|
||||||
============================================================ */
|
============================================================ */
|
||||||
html:not(.j13-has-site-bg) #main {
|
#main {
|
||||||
background: var(--main-bg);
|
background: var(--main-bg);
|
||||||
border-left: 1px solid var(--line);
|
border-left: 1px solid var(--line);
|
||||||
border-right: 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);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export default function RegisterClient({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-line shrink-0"
|
className="btn btn-code shrink-0"
|
||||||
disabled={sending || countdown > 0 || !email}
|
disabled={sending || countdown > 0 || !email}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
// 点击即锁定,防止重复发送
|
// 点击即锁定,防止重复发送
|
||||||
@@ -201,7 +201,7 @@ export default function RegisterClient({
|
|||||||
email,
|
email,
|
||||||
purpose: "register",
|
purpose: "register",
|
||||||
});
|
});
|
||||||
setSentMessage(r.message);
|
setSentMessage("验证码已发送,请查收邮箱");
|
||||||
startCountdown();
|
startCountdown();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
@@ -211,7 +211,7 @@ export default function RegisterClient({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sending
|
{sending
|
||||||
? "正在排队…"
|
? "发送中…"
|
||||||
: countdown > 0
|
: countdown > 0
|
||||||
? `${countdown}s 后可重发`
|
? `${countdown}s 后可重发`
|
||||||
: "发送验证码"}
|
: "发送验证码"}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function ResetPasswordPage() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-line shrink-0"
|
className="btn btn-code shrink-0"
|
||||||
disabled={sending || busy || !email}
|
disabled={sending || busy || !email}
|
||||||
onClick={() => void sendCode()}
|
onClick={() => void sendCode()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
fetchAnnouncementsPage,
|
fetchAnnouncementsPage,
|
||||||
fetchPublishedSitePages,
|
fetchPublishedSitePages,
|
||||||
fetchLibraryList,
|
fetchLibraryList,
|
||||||
|
fetchSnippets,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { canonicalPath, resolveSiteOrigin } from "@/lib/siteUrl";
|
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";
|
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||||
|
|
||||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
@@ -40,6 +41,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: url(snippetsPath(style)),
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.5,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: url("/about"),
|
url: url("/about"),
|
||||||
lastModified: new Date(),
|
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;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|||||||
111
frontend/app/snippets/[id]/edit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
frontend/app/snippets/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/app/snippets/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
frontend/app/snippets/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,9 +55,9 @@ export default function FavoriteButton({
|
|||||||
variant === "compact"
|
variant === "compact"
|
||||||
? "px-2.5 py-1.5 gap-1 text-[12.5px]"
|
? "px-2.5 py-1.5 gap-1 text-[12.5px]"
|
||||||
: variant === "article"
|
: 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";
|
: "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 (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -66,12 +66,7 @@ export default function FavoriteButton({
|
|||||||
data-tip={favorited ? "取消收藏" : "收藏"}
|
data-tip={favorited ? "取消收藏" : "收藏"}
|
||||||
aria-label={favorited ? "取消收藏" : "收藏"}
|
aria-label={favorited ? "取消收藏" : "收藏"}
|
||||||
aria-pressed={favorited}
|
aria-pressed={favorited}
|
||||||
className={`inline-flex items-center justify-center tabular-nums rounded-full border transition-colors duration-150 disabled:opacity-50 ${
|
className={`j13-fav inline-flex items-center justify-center tabular-nums border disabled:opacity-50 ${variant === "article" ? "j13-like-article" : ""} ${favorited ? "is-favorited" : ""} ${sizeCls}`}
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
<Bookmark size={iconSize} fill={favorited ? "currentColor" : "none"} />
|
<Bookmark size={iconSize} fill={favorited ? "currentColor" : "none"} />
|
||||||
{variant === "article" && <span>{favorited ? "已收藏" : "收藏"}</span>}
|
{variant === "article" && <span>{favorited ? "已收藏" : "收藏"}</span>}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
X,
|
X,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
CodeXml,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
||||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
@@ -37,7 +38,7 @@ import BadgeChip from "./BadgeChip";
|
|||||||
import BrandLockup from "./BrandLockup";
|
import BrandLockup from "./BrandLockup";
|
||||||
import { useAllowMessages } from "./MessagesPolicyProvider";
|
import { useAllowMessages } from "./MessagesPolicyProvider";
|
||||||
import { useSiteBrand, useUrlStyle } from "./SiteBrandProvider";
|
import { useSiteBrand, useUrlStyle } from "./SiteBrandProvider";
|
||||||
import { leaderboardPath, libraryPath } from "@/lib/urlStyle";
|
import { leaderboardPath, libraryPath, snippetsPath } from "@/lib/urlStyle";
|
||||||
import {
|
import {
|
||||||
HEADER_NAV_BUILTIN_LABELS,
|
HEADER_NAV_BUILTIN_LABELS,
|
||||||
isHeaderNavExternal,
|
isHeaderNavExternal,
|
||||||
@@ -84,6 +85,7 @@ export default function Header({
|
|||||||
const urlStyle = useUrlStyle();
|
const urlStyle = useUrlStyle();
|
||||||
const lbHref = leaderboardPath(urlStyle);
|
const lbHref = leaderboardPath(urlStyle);
|
||||||
const libHref = libraryPath(urlStyle);
|
const libHref = libraryPath(urlStyle);
|
||||||
|
const snipHref = snippetsPath(urlStyle);
|
||||||
// 顶部主导航由后台「基本信息 → 顶部导航」配置(顺序/显隐/自定义链接),
|
// 顶部主导航由后台「基本信息 → 顶部导航」配置(顺序/显隐/自定义链接),
|
||||||
// 配置为空时整个 nav 不渲染(含左侧分隔线);移动端导航走底部公共菜单
|
// 配置为空时整个 nav 不渲染(含左侧分隔线);移动端导航走底部公共菜单
|
||||||
const headerNav = useSiteBrand().header_nav;
|
const headerNav = useSiteBrand().header_nav;
|
||||||
@@ -91,16 +93,22 @@ export default function Header({
|
|||||||
home: <Home size={15} />,
|
home: <Home size={15} />,
|
||||||
leaderboard: <Trophy size={15} />,
|
leaderboard: <Trophy size={15} />,
|
||||||
library: <BookOpen size={15} />,
|
library: <BookOpen size={15} />,
|
||||||
|
snippets: <CodeXml size={15} />,
|
||||||
};
|
};
|
||||||
const builtinNavHref: Record<HeaderNavBuiltinKey, string> = {
|
const builtinNavHref: Record<HeaderNavBuiltinKey, string> = {
|
||||||
home: "/",
|
home: "/",
|
||||||
leaderboard: lbHref,
|
leaderboard: lbHref,
|
||||||
library: libHref,
|
library: libHref,
|
||||||
|
snippets: snipHref,
|
||||||
};
|
};
|
||||||
const isBuiltinNavActive = (key: HeaderNavBuiltinKey): boolean => {
|
const isBuiltinNavActive = (key: HeaderNavBuiltinKey): boolean => {
|
||||||
if (!pathname) return false;
|
if (!pathname) return false;
|
||||||
if (key === "home") return pathname === "/";
|
if (key === "home") return pathname === "/";
|
||||||
if (key === "leaderboard") return pathname === lbHref || pathname.startsWith(`${lbHref}/`);
|
if (key === "leaderboard") return pathname === lbHref || pathname.startsWith(`${lbHref}/`);
|
||||||
|
if (key === "snippets") {
|
||||||
|
// 片段详情标准路径与伪静态(/snippet-*.html)统一按前缀高亮
|
||||||
|
return pathname.startsWith("/snippets") || pathname.startsWith("/snippet-");
|
||||||
|
}
|
||||||
// 书库详情/阅读页标准路径与伪静态(/library-*.html)统一按 /library 前缀高亮
|
// 书库详情/阅读页标准路径与伪静态(/library-*.html)统一按 /library 前缀高亮
|
||||||
return pathname.startsWith("/library");
|
return pathname.startsWith("/library");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ export default function LikeButton({
|
|||||||
variant === "compact"
|
variant === "compact"
|
||||||
? "px-2.5 py-1.5 gap-1 text-[12.5px]"
|
? "px-2.5 py-1.5 gap-1 text-[12.5px]"
|
||||||
: variant === "article"
|
: 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";
|
: "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 (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -75,7 +75,7 @@ export default function LikeButton({
|
|||||||
data-tip={liked ? "取消点赞" : "点赞"}
|
data-tip={liked ? "取消点赞" : "点赞"}
|
||||||
aria-label={liked ? "取消点赞" : "点赞"}
|
aria-label={liked ? "取消点赞" : "点赞"}
|
||||||
aria-pressed={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"}>
|
<span key={String(liked)} className={liked ? "j13-heart-pop inline-flex" : "inline-flex"}>
|
||||||
<IconHeart size={iconSize} filled={liked} />
|
<IconHeart size={iconSize} filled={liked} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import MdTimeline from "@/components/markdown/MdTimeline";
|
|||||||
import MdEntries from "@/components/markdown/MdEntries";
|
import MdEntries from "@/components/markdown/MdEntries";
|
||||||
import MdDetails from "@/components/markdown/MdDetails";
|
import MdDetails from "@/components/markdown/MdDetails";
|
||||||
import MdVideo from "@/components/markdown/MdVideo";
|
import MdVideo from "@/components/markdown/MdVideo";
|
||||||
|
import MdSnippet from "@/components/markdown/MdSnippet";
|
||||||
|
|
||||||
type MarkdownBodyProps = {
|
type MarkdownBodyProps = {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -92,6 +93,10 @@ export default async function MarkdownBody({
|
|||||||
nodes.push(<MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />);
|
nodes.push(<MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (seg.type === "snippet") {
|
||||||
|
nodes.push(<MdSnippet key={`snippet-${i}`} id={seg.id} />);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const kind = seg.kind as HideKind;
|
const kind = seg.kind as HideKind;
|
||||||
if (seg.locked) {
|
if (seg.locked) {
|
||||||
nodes.push(
|
nodes.push(
|
||||||
|
|||||||
@@ -113,6 +113,18 @@ export default function MarkdownBodyClient({
|
|||||||
if (seg.type === "video") {
|
if (seg.type === "video") {
|
||||||
return <MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />;
|
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;
|
const kind = seg.kind as HideKind;
|
||||||
if (seg.locked) {
|
if (seg.locked) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { toast } from "@/lib/toast";
|
|||||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||||
import ImageInsertModal from "@/components/ImageInsertModal";
|
import ImageInsertModal from "@/components/ImageInsertModal";
|
||||||
import TimelineEditor from "@/components/TimelineEditor";
|
import TimelineEditor from "@/components/TimelineEditor";
|
||||||
|
import SnippetPickerModal from "@/components/SnippetPickerModal";
|
||||||
import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
|
import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
|
||||||
import EmojiPicker from "@/components/EmojiPicker";
|
import EmojiPicker from "@/components/EmojiPicker";
|
||||||
import { resolveTimelineBlockForEdit, parseVideoInput } from "@/lib/directiveBlocks";
|
import { resolveTimelineBlockForEdit, parseVideoInput } from "@/lib/directiveBlocks";
|
||||||
@@ -260,6 +261,7 @@ export default function MarkdownEditor({
|
|||||||
const [activeWheel, setActiveWheel] = useState<"fg" | "bg" | null>(null);
|
const [activeWheel, setActiveWheel] = useState<"fg" | "bg" | null>(null);
|
||||||
const [colorHsv, setColorHsv] = useState<HSV>({ h: 0, s: 1, v: 1 });
|
const [colorHsv, setColorHsv] = useState<HSV>({ h: 0, s: 1, v: 1 });
|
||||||
const [videoInput, setVideoInput] = useState("");
|
const [videoInput, setVideoInput] = useState("");
|
||||||
|
const [snippetPickerOpen, setSnippetPickerOpen] = useState(false);
|
||||||
const [toolNarrow, setToolNarrow] = useState(false);
|
const [toolNarrow, setToolNarrow] = useState(false);
|
||||||
const [uploadError, setUploadError] = useState("");
|
const [uploadError, setUploadError] = useState("");
|
||||||
const [modKey, setModKey] = useState("Ctrl");
|
const [modKey, setModKey] = useState("Ctrl");
|
||||||
@@ -1670,6 +1672,7 @@ export default function MarkdownEditor({
|
|||||||
{!isComment && (
|
{!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={() => openPopover("video")}>视频…</button>
|
||||||
)}
|
)}
|
||||||
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); setSnippetPickerOpen(true); }}>代码片段…</button>
|
||||||
{toolNarrow && (
|
{toolNarrow && (
|
||||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
|
<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}
|
{popover === "video" && videoPopover}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isComment && !toolNarrow && (
|
||||||
|
<ToolBtn
|
||||||
|
label="代码片段"
|
||||||
|
icon={<Code2 size={15} />}
|
||||||
|
onClick={() => setSnippetPickerOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{allowHide && !toolNarrow ? (
|
{allowHide && !toolNarrow ? (
|
||||||
<div className="j13-md-popover-anchor relative">
|
<div className="j13-md-popover-anchor relative">
|
||||||
<ToolBtn
|
<ToolBtn
|
||||||
@@ -1897,6 +1907,12 @@ export default function MarkdownEditor({
|
|||||||
onInsertUrl={insertImageUrl}
|
onInsertUrl={insertImageUrl}
|
||||||
onPickMedia={(item) => insertAtCursor(``)}
|
onPickMedia={(item) => insertAtCursor(``)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SnippetPickerModal
|
||||||
|
open={snippetPickerOpen}
|
||||||
|
onClose={() => setSnippetPickerOpen(false)}
|
||||||
|
onPick={(id) => insertAtCursor(`[snippet id=${id}]`)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default async function PostRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
compact ? "gap-2.5 sm:gap-3" : "gap-3.5 sm:gap-4"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
197
frontend/components/SnippetActions.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
frontend/components/SnippetCodeView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/components/SnippetCommentCountChip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
702
frontend/components/SnippetCommentSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
frontend/components/SnippetEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
612
frontend/components/SnippetExplorer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
frontend/components/SnippetHero.tsx
Normal 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);
|
||||||
|
}
|
||||||
236
frontend/components/SnippetLangSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
frontend/components/SnippetPickerModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
frontend/components/SnippetVersionModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Images,
|
Images,
|
||||||
Link2,
|
Link2,
|
||||||
|
CodeXml,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { User } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||||
@@ -118,6 +119,12 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
visible: (u) => hasPerm(u, PERMS.LIBRARY),
|
visible: (u) => hasPerm(u, PERMS.LIBRARY),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/snippets",
|
||||||
|
label: "片段管理",
|
||||||
|
icon: CodeXml,
|
||||||
|
visible: (u) => isStaff(u.role),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/friend-links",
|
href: "/admin/friend-links",
|
||||||
label: "友情链接",
|
label: "友情链接",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CalendarDays, CalendarCheck, LogIn, Loader2 } from "lucide-react";
|
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 {
|
try {
|
||||||
setStatus(await apiDoCheckin());
|
setStatus(await apiDoCheckin());
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
124
frontend/components/markdown/MdSnippet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,14 +14,36 @@ export const SHIKI_LANG_IDS = [
|
|||||||
"tsx",
|
"tsx",
|
||||||
"jsx",
|
"jsx",
|
||||||
"json",
|
"json",
|
||||||
|
"jsonc",
|
||||||
"yaml",
|
"yaml",
|
||||||
"toml",
|
"toml",
|
||||||
|
"ini",
|
||||||
"bash",
|
"bash",
|
||||||
"sql",
|
"sql",
|
||||||
"html",
|
"html",
|
||||||
"css",
|
"css",
|
||||||
"scss",
|
"scss",
|
||||||
"diff",
|
"diff",
|
||||||
|
"rust",
|
||||||
|
"ruby",
|
||||||
|
"powershell",
|
||||||
|
"nginx",
|
||||||
|
"dockerfile",
|
||||||
|
"kotlin",
|
||||||
|
"java",
|
||||||
|
"c",
|
||||||
|
"cpp",
|
||||||
|
"csharp",
|
||||||
|
"php",
|
||||||
|
"swift",
|
||||||
|
"dart",
|
||||||
|
"lua",
|
||||||
|
"vue",
|
||||||
|
"graphql",
|
||||||
|
"xml",
|
||||||
|
"regex",
|
||||||
|
"makefile",
|
||||||
|
"prisma",
|
||||||
"plaintext",
|
"plaintext",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -38,8 +60,44 @@ export const SHIKI_LANG_ALIAS: Record<string, string> = {
|
|||||||
yml: "yaml",
|
yml: "yaml",
|
||||||
text: "plaintext",
|
text: "plaintext",
|
||||||
txt: "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 = {
|
export const SHIKI_THEMES = {
|
||||||
light: "github-light",
|
light: "github-light",
|
||||||
dark: "github-dark-dimmed",
|
dark: "github-dark-dimmed",
|
||||||
@@ -84,10 +142,30 @@ export function getSiteHighlighter(): Promise<HighlighterCore> {
|
|||||||
import("@shikijs/langs/css"),
|
import("@shikijs/langs/css"),
|
||||||
import("@shikijs/langs/scss"),
|
import("@shikijs/langs/scss"),
|
||||||
import("@shikijs/langs/diff"),
|
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
|
// plaintext / text / txt 为 Shiki 特殊语言,无需 grammar
|
||||||
aardioGrammar,
|
aardioGrammar,
|
||||||
],
|
],
|
||||||
langAlias: SHIKI_LANG_ALIAS,
|
langAlias: buildCoreLangAlias(),
|
||||||
engine: createOnigurumaEngine(import("shiki/wasm")),
|
engine: createOnigurumaEngine(import("shiki/wasm")),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4987,3 +4987,403 @@ export async function apiAdminSetFriendLinkStatus(
|
|||||||
export async function apiAdminDeleteFriendLink(id: number): Promise<{ ok: boolean }> {
|
export async function apiAdminDeleteFriendLink(id: number): Promise<{ ok: boolean }> {
|
||||||
return apiOperations(`/api/admin/friend-links/${id}`, "DELETE");
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export type ContentSegment =
|
|||||||
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
|
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
|
||||||
| { type: "entries"; items: EntryItem[]; rawBody: string }
|
| { type: "entries"; items: EntryItem[]; rawBody: string }
|
||||||
| { type: "details"; title: string; body: 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 {
|
function isTimelineClose(line: string): boolean {
|
||||||
return line.trim().toLowerCase() === "[/timeline]";
|
return line.trim().toLowerCase() === "[/timeline]";
|
||||||
@@ -57,6 +58,18 @@ export function parseDetailsOpen(trimmed: string): string {
|
|||||||
return title || DETAILS_DEFAULT_TITLE;
|
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] 视频嵌入(整行单标记) ---------- */
|
/* ---------- [bilibili] / [youtube] 视频嵌入(整行单标记) ---------- */
|
||||||
|
|
||||||
const VIDEO_LINE_RE = /^\[(bilibili|youtube)\]([^\[\]]+)\[\/\1\]$/i;
|
const VIDEO_LINE_RE = /^\[(bilibili|youtube)\]([^\[\]]+)\[\/\1\]$/i;
|
||||||
@@ -122,6 +135,7 @@ export function splitContentSegments(
|
|||||||
| { type: "entries"; body: string }
|
| { type: "entries"; body: string }
|
||||||
| { type: "details"; title: string; body: 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 }
|
||||||
> = [];
|
> = [];
|
||||||
let buf: string[] = [];
|
let buf: string[] = [];
|
||||||
let inCode = false;
|
let inCode = false;
|
||||||
@@ -228,6 +242,15 @@ export function splitContentSegments(
|
|||||||
continue;
|
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:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
|
||||||
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
|
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
|
||||||
if (isEntriesOffOpen(trimmed)) {
|
if (isEntriesOffOpen(trimmed)) {
|
||||||
@@ -273,6 +296,10 @@ export function splitContentSegments(
|
|||||||
out.push({ type: "video", provider: part.provider, id: part.id });
|
out.push({ type: "video", provider: part.provider, id: part.id });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (part.type === "snippet") {
|
||||||
|
out.push({ type: "snippet", id: part.id });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const hideSegs = splitHideSegments(part.text) as HideSegment[];
|
const hideSegs = splitHideSegments(part.text) as HideSegment[];
|
||||||
for (const s of hideSegs) {
|
for (const s of hideSegs) {
|
||||||
if (s.type === "md") {
|
if (s.type === "md") {
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ describe("normalizeHeaderNav", () => {
|
|||||||
assert.deepEqual(normalizeHeaderNav("home"), DEFAULT_HEADER_NAV);
|
assert.deepEqual(normalizeHeaderNav("home"), DEFAULT_HEADER_NAV);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("默认三项顺序与注册表一致", () => {
|
it("默认内置项顺序与注册表一致", () => {
|
||||||
assert.equal(DEFAULT_HEADER_NAV.length, 3);
|
assert.equal(DEFAULT_HEADER_NAV.length, HEADER_NAV_BUILTIN_KEYS.length);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
DEFAULT_HEADER_NAV.map((i) => (i.type === "builtin" ? i.key : "")),
|
DEFAULT_HEADER_NAV.map((i) => (i.type === "builtin" ? i.key : "")),
|
||||||
[...HEADER_NAV_BUILTIN_KEYS],
|
[...HEADER_NAV_BUILTIN_KEYS],
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import { footerURLAllowed, isExternalFooterURL, runeCount } from "./brand.ts";
|
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];
|
export type HeaderNavBuiltinKey = (typeof HEADER_NAV_BUILTIN_KEYS)[number];
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export const HEADER_NAV_BUILTIN_LABELS: Record<HeaderNavBuiltinKey, string> = {
|
|||||||
home: "首页",
|
home: "首页",
|
||||||
leaderboard: "排行榜",
|
leaderboard: "排行榜",
|
||||||
library: "书库",
|
library: "书库",
|
||||||
|
snippets: "片段",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HeaderNavBuiltinItem = { type: "builtin"; key: HeaderNavBuiltinKey };
|
export type HeaderNavBuiltinItem = { type: "builtin"; key: HeaderNavBuiltinKey };
|
||||||
|
|||||||
@@ -105,10 +105,11 @@ export interface RtChatMessageData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 群成员关系变更:被踢/群解散/被邀请
|
// 群成员关系变更:被踢/群解散/被邀请/新成员加入
|
||||||
export interface RtChatMembershipData {
|
export interface RtChatMembershipData {
|
||||||
room_id: number;
|
room_id: number;
|
||||||
status: "kicked" | "dissolved" | "invited";
|
status: "kicked" | "dissolved" | "invited" | "joined";
|
||||||
|
user_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息撤回
|
// 消息撤回
|
||||||
|
|||||||
48
frontend/lib/snippetLangColors.ts
Normal 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)";
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
pagePath,
|
pagePath,
|
||||||
parseEntityPath,
|
parseEntityPath,
|
||||||
postPath,
|
postPath,
|
||||||
|
snippetPath,
|
||||||
|
snippetsPath,
|
||||||
userPath,
|
userPath,
|
||||||
} from "./urlStyle.ts";
|
} from "./urlStyle.ts";
|
||||||
|
|
||||||
@@ -89,6 +91,16 @@ describe("路径构造器", () => {
|
|||||||
assert.equal(entityPath("default", "user", 9), "/u/9");
|
assert.equal(entityPath("default", "user", 9), "/u/9");
|
||||||
assert.equal(entityPath("html", "announcement", 9), "/announcement-9.html");
|
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("评论锚点使用帖内相对楼层号", () => {
|
it("评论锚点使用帖内相对楼层号", () => {
|
||||||
assert.equal(commentAnchor(1, true, 147), "#comment-1");
|
assert.equal(commentAnchor(1, true, 147), "#comment-1");
|
||||||
assert.equal(commentAnchor(3, false, 147), "#comment-3-r147");
|
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("/p-about.html"), { style: "html", kind: "page", slug: "about" });
|
||||||
assert.deepEqual(matchPrettyPath("/leaderboard.html"), { style: "html", kind: "leaderboard" });
|
assert.deepEqual(matchPrettyPath("/leaderboard.html"), { style: "html", kind: "leaderboard" });
|
||||||
assert.deepEqual(matchPrettyPath("/rank.html"), { style: "discuz", 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 优先当前风格,缺省首条兜底", () => {
|
it("书库两风格同模板:prefer 优先当前风格,缺省首条兜底", () => {
|
||||||
assert.equal(matchPrettyPath("/library.html", "html")?.style, "html");
|
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.deepEqual(matchStandardPath("/library/abc/read"), { kind: "libraryRead", slug: "abc" });
|
||||||
assert.equal(matchStandardPath("/library/a/b"), null);
|
assert.equal(matchStandardPath("/library/a/b"), null);
|
||||||
assert.equal(matchStandardPath("/libraryx"), 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 兼容标准与全部模板", () => {
|
it("parseEntityPath 兼容标准与全部模板", () => {
|
||||||
assert.deepEqual(parseEntityPath("/post/12"), { kind: "post", id: 12 });
|
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"), { kind: "libraryRead", slug: "abc" });
|
||||||
assert.deepEqual(parseEntityPath("/library-abc-read-2.html"), { 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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,14 +7,21 @@
|
|||||||
* default: /post/123 /u/5 /announcement/1 /p/about /leaderboard /library /library/{slug} /library/{slug}/read?c={书内章号}
|
* 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={书内章号})
|
* 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 模板)
|
* discuz: /thread-123.html /space-uid-5.html /notice-1.html /page-about.html /rank.html (书库两风格同 html 模板)
|
||||||
* 书库章节走详情路径 + ?sec= 查询参数(章内子目录锚点 #e-{sec}-{章内序号});旧版 -read-{n} 路径
|
* 代码片段:default /snippets /snippets/{id};html/discuz 两风格统一 /snippets.html /snippet-{id}.html
|
||||||
* 不再生成,但正则保留可解析(middleware 改写回标准读页),旧链接不失效。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type UrlStyle = "default" | "html" | "discuz";
|
export type UrlStyle = "default" | "html" | "discuz";
|
||||||
export type EntityKind = "post" | "user" | "announcement";
|
export type EntityKind = "post" | "user" | "announcement";
|
||||||
/** 全部可反解的实体类型;page/libraryDoc/libraryRead 的 id 是 slug(字符串),leaderboard/library 是固定单页无 id */
|
/** 全部可反解的实体类型;page/libraryDoc/libraryRead 的 id 是 slug(字符串),leaderboard/library/snippets 是固定单页无 id */
|
||||||
export type PageKind = EntityKind | "page" | "leaderboard" | "library" | "libraryDoc" | "libraryRead";
|
export type PageKind =
|
||||||
|
| EntityKind
|
||||||
|
| "page"
|
||||||
|
| "leaderboard"
|
||||||
|
| "library"
|
||||||
|
| "libraryDoc"
|
||||||
|
| "libraryRead"
|
||||||
|
| "snippets"
|
||||||
|
| "snippet";
|
||||||
|
|
||||||
export const URL_STYLES: readonly UrlStyle[] = ["default", "html", "discuz"];
|
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/非有限数)时返回空串,调用方按异常路径处理 */
|
/** id 非法(≤0/非有限数)时返回空串,调用方按异常路径处理 */
|
||||||
function guardId(id: number): number | null {
|
function guardId(id: number): number | null {
|
||||||
return Number.isFinite(id) && id > 0 ? id : 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;
|
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 共用) */
|
/** 实体对应风格下的标准/pretty 路径(内部与 middleware 共用) */
|
||||||
export function entityPath(style: UrlStyle, kind: EntityKind, id: number): string {
|
export function entityPath(style: UrlStyle, kind: EntityKind, id: number): string {
|
||||||
if (kind === "post") return postPath(style, id);
|
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: "libraryDoc"; slug: string }
|
||||||
| { style: Exclude<UrlStyle, "default">; kind: "libraryRead"; slug: string; id: number }
|
| { style: Exclude<UrlStyle, "default">; kind: "libraryRead"; slug: string; id: number }
|
||||||
| { style: Exclude<UrlStyle, "default">; kind: "library" }
|
| { 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 这类子路径与伪造路径;
|
// 精确锚定:id 仅纯数字,天然排除 /post/123/edit 这类子路径与伪造路径;
|
||||||
// page/libraryDoc 的 slug 捕获保持 URL 原样(encoded),rewrite 落点直接复用。
|
// 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\.html$/, style: "html", kind: "library" },
|
||||||
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "html", kind: "libraryRead" },
|
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "html", kind: "libraryRead" },
|
||||||
{ re: /^\/library-([^/]+)\.html$/, style: "html", kind: "libraryDoc" },
|
{ 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: /^\/thread-(\d+)\.html$/, style: "discuz", kind: "post" },
|
||||||
{ re: /^\/space-uid-(\d+)\.html$/, style: "discuz", kind: "user" },
|
{ re: /^\/space-uid-(\d+)\.html$/, style: "discuz", kind: "user" },
|
||||||
{ re: /^\/notice-(\d+)\.html$/, style: "discuz", kind: "announcement" },
|
{ 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\.html$/, style: "discuz", kind: "library" },
|
||||||
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "discuz", kind: "libraryRead" },
|
{ re: /^\/library-(.+)-read-(\d+)\.html$/, style: "discuz", kind: "libraryRead" },
|
||||||
{ re: /^\/library-([^/]+)\.html$/, style: "discuz", kind: "libraryDoc" },
|
{ 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 }[] = [
|
const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
|
||||||
@@ -208,6 +246,9 @@ const STANDARD_PATTERNS: { re: RegExp; kind: PageKind }[] = [
|
|||||||
{ re: /^\/library\/?$/, kind: "library" },
|
{ re: /^\/library\/?$/, kind: "library" },
|
||||||
{ re: /^\/library\/([^/]+)\/read$/, kind: "libraryRead" },
|
{ re: /^\/library\/([^/]+)\/read$/, kind: "libraryRead" },
|
||||||
{ re: /^\/library\/([^/]+)$/, kind: "libraryDoc" },
|
{ 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] };
|
hit = { style: p.style, kind: p.kind, slug: m[1] };
|
||||||
} else if (p.kind === "libraryRead") {
|
} else if (p.kind === "libraryRead") {
|
||||||
hit = { style: p.style, kind: p.kind, slug: m[1], id: Number(m[2]) };
|
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 };
|
hit = { style: p.style, kind: p.kind };
|
||||||
} else {
|
} else {
|
||||||
hit = { style: p.style, kind: p.kind, id: Number(m[1]) };
|
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") {
|
if (p.kind === "page" || p.kind === "libraryDoc" || p.kind === "libraryRead") {
|
||||||
return { kind: p.kind, slug: m[1] };
|
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 { kind: p.kind, id: Number(m[1]) };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -261,7 +302,9 @@ export type StandardMatch =
|
|||||||
| { kind: "libraryDoc"; slug: string }
|
| { kind: "libraryDoc"; slug: string }
|
||||||
| { kind: "libraryRead"; slug: string }
|
| { kind: "libraryRead"; slug: string }
|
||||||
| { kind: "library" }
|
| { kind: "library" }
|
||||||
| { kind: "leaderboard" };
|
| { kind: "leaderboard" }
|
||||||
|
| { kind: "snippets" }
|
||||||
|
| { kind: "snippet"; id: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 反解任意实体路径(标准 + 全部伪静态风格),供 isActive 前缀判断、面包屑等
|
* 反解任意实体路径(标准 + 全部伪静态风格),供 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 === "libraryRead") return { kind: "libraryRead", slug: decodeSlug(pretty.slug) };
|
||||||
if (pretty.kind === "leaderboard") return { kind: "leaderboard" };
|
if (pretty.kind === "leaderboard") return { kind: "leaderboard" };
|
||||||
if (pretty.kind === "library") return { kind: "library" };
|
if (pretty.kind === "library") return { kind: "library" };
|
||||||
|
if (pretty.kind === "snippets") return { kind: "snippets" };
|
||||||
return { kind: pretty.kind, id: pretty.id };
|
return { kind: pretty.kind, id: pretty.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
matchStandardPath,
|
matchStandardPath,
|
||||||
normalizeUrlStyle,
|
normalizeUrlStyle,
|
||||||
pagePathFromSlug,
|
pagePathFromSlug,
|
||||||
|
snippetPath,
|
||||||
|
snippetsPath,
|
||||||
standardEntityPath,
|
standardEntityPath,
|
||||||
type PageKind,
|
type PageKind,
|
||||||
type UrlStyle,
|
type UrlStyle,
|
||||||
@@ -185,6 +187,7 @@ function couldBeEntityPath(pathname: string): boolean {
|
|||||||
pathname.startsWith("/announcement/") ||
|
pathname.startsWith("/announcement/") ||
|
||||||
pathname.startsWith("/p/") ||
|
pathname.startsWith("/p/") ||
|
||||||
pathname.startsWith("/library") ||
|
pathname.startsWith("/library") ||
|
||||||
|
pathname.startsWith("/snippets") ||
|
||||||
pathname.startsWith("/leaderboard") ||
|
pathname.startsWith("/leaderboard") ||
|
||||||
pathname.endsWith(".html")
|
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 === "libraryDoc") return `/library/${m.slug ?? ""}`;
|
||||||
if (m.kind === "libraryRead") return `/library/${m.slug ?? ""}/read?c=${m.id ?? 0}`;
|
if (m.kind === "libraryRead") return `/library/${m.slug ?? ""}/read?c=${m.id ?? 0}`;
|
||||||
if (m.kind === "library") return "/library";
|
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";
|
if (m.kind === "leaderboard") return "/leaderboard";
|
||||||
return standardEntityPath(m.kind, m.id ?? 0);
|
return standardEntityPath(m.kind, m.id ?? 0);
|
||||||
}
|
}
|
||||||
@@ -216,6 +221,8 @@ function styledPath(
|
|||||||
return libraryDocReadPath(style, m.slug ?? "", chapterNo ?? m.id ?? 1);
|
return libraryDocReadPath(style, m.slug ?? "", chapterNo ?? m.id ?? 1);
|
||||||
}
|
}
|
||||||
if (m.kind === "library") return libraryPath(style);
|
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);
|
if (m.kind === "leaderboard") return leaderboardPath(style);
|
||||||
return entityPath(style, m.kind, m.id ?? 0);
|
return entityPath(style, m.kind, m.id ?? 0);
|
||||||
}
|
}
|
||||||
|
|||||||
4
frontend/next-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
import "./.next/types/root-params.d.ts";
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||