Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7b0686947 | |||
| da72843c70 | |||
| 5598e1cbb7 | |||
| 46e0cdc0b3 | |||
| 8cfdfcbbf0 | |||
| 11f2131e4b | |||
| 7912b67ddc | |||
| 99c0f0319d | |||
| 3e98052c16 | |||
| ff2ab286fb |
2
.gitignore
vendored
@@ -8,6 +8,8 @@ out/
|
|||||||
.vercel/
|
.vercel/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-debug.log*
|
next-debug.log*
|
||||||
|
# postbuild 抽取的字体声明(scripts/extract-font-css.mjs 生成)
|
||||||
|
frontend/public/fonts/
|
||||||
|
|
||||||
# ===== Go 构建产物 =====
|
# ===== Go 构建产物 =====
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
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>
|
||||||
|
|||||||
@@ -94,6 +94,41 @@ func (h *Handlers) AdminSetUserMessages(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"user": user})
|
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminGetUserPerms 读取账号级渠道权限(超管/站长,授权弹窗回显用)
|
||||||
|
func (h *Handlers) AdminGetUserPerms(c *gin.Context) {
|
||||||
|
id, ok := parseAdminUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
perms, err := h.AdminUser.GetPermOverrides(middleware.CurrentActor(c), id)
|
||||||
|
if err != nil {
|
||||||
|
respondAdminUserError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"perm_overrides": perms})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSetUserPerms 授予/撤销账号级渠道权限(超管/站长)
|
||||||
|
func (h *Handlers) AdminSetUserPerms(c *gin.Context) {
|
||||||
|
id, ok := parseAdminUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Perms []string `json:"perms"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.AdminUser.SetPermOverrides(middleware.CurrentActor(c), id, body.Perms)
|
||||||
|
if err != nil {
|
||||||
|
respondAdminUserError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||||
|
}
|
||||||
|
|
||||||
// AdminSetUserBan 封禁 / 解封用户
|
// AdminSetUserBan 封禁 / 解封用户
|
||||||
func (h *Handlers) AdminSetUserBan(c *gin.Context) {
|
func (h *Handlers) AdminSetUserBan(c *gin.Context) {
|
||||||
id, ok := parseAdminUserID(c)
|
id, ok := parseAdminUserID(c)
|
||||||
|
|||||||
@@ -382,6 +382,9 @@ func meUserBody(user *model.User, boardIDs []uint, badges []model.UserBadge) gin
|
|||||||
"role": user.Role,
|
"role": user.Role,
|
||||||
"board_ids": boardIDs,
|
"board_ids": boardIDs,
|
||||||
"can_manage_messages": user.CanManageMessages,
|
"can_manage_messages": user.CanManageMessages,
|
||||||
|
// 生效权限码(角色默认 ∪ 账号级渠道授予):前端据此渲染后台入口/按钮,
|
||||||
|
// 最终权限以后端 RequirePerm 校验为准
|
||||||
|
"perms": service.EffectivePerms(user.Role, user.PermOverrides, user.CanManageMessages),
|
||||||
"level": user.Level,
|
"level": user.Level,
|
||||||
"total_points": user.TotalPoints,
|
"total_points": user.TotalPoints,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
@@ -42,6 +44,26 @@ func chatRoomID(c *gin.Context) (uint, bool) {
|
|||||||
return uint(id), true
|
return uint(id), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatMuteDurationCN 禁言时长中文格式化,供系统提示消息使用
|
||||||
|
func formatMuteDurationCN(minutes int) string {
|
||||||
|
switch {
|
||||||
|
case minutes >= 30*24*60:
|
||||||
|
return "30 天"
|
||||||
|
case minutes >= 7*24*60:
|
||||||
|
return "7 天"
|
||||||
|
case minutes >= 3*24*60:
|
||||||
|
return "3 天"
|
||||||
|
case minutes >= 24*60:
|
||||||
|
return "1 天"
|
||||||
|
case minutes >= 12*60:
|
||||||
|
return "12 小时"
|
||||||
|
case minutes >= 60:
|
||||||
|
return "1 小时"
|
||||||
|
default:
|
||||||
|
return strconv.Itoa(minutes) + " 分钟"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handlers) chatOversee(userID uint) bool {
|
func (h *Handlers) chatOversee(userID uint) bool {
|
||||||
actor, err := h.Auth.LoadActor(userID)
|
actor, err := h.Auth.LoadActor(userID)
|
||||||
if err != nil || actor == nil {
|
if err != nil || actor == nil {
|
||||||
@@ -219,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})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,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})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +394,7 @@ func (h *Handlers) KickChatMember(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChatMemberMute 禁言/解禁成员
|
// SetChatMemberMute 禁言/解禁成员;duration_minutes 可选:0/缺省=永久,>0=指定分钟数
|
||||||
func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
claims := middleware.CurrentUser(c)
|
||||||
roomID, ok := chatRoomID(c)
|
roomID, ok := chatRoomID(c)
|
||||||
@@ -336,16 +408,57 @@ func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
Muted bool `json:"muted"`
|
Muted bool `json:"muted"`
|
||||||
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, h.chatOversee(claims.ID)); err != nil {
|
duration := time.Duration(body.DurationMinutes) * time.Minute
|
||||||
|
targetName, err := h.Chat.SetMemberMute(claims.ID, roomID, uint(targetID), body.Muted, duration, h.chatOversee(claims.ID))
|
||||||
|
if err != nil {
|
||||||
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
c.JSON(chatErrToStatus(err), gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted})
|
// 向房间内广播禁言/解禁事件,客户端据此更新输入框状态与成员列表
|
||||||
|
var mutedUntil *time.Time
|
||||||
|
if body.Muted && body.DurationMinutes > 0 {
|
||||||
|
t := time.Now().Add(duration)
|
||||||
|
mutedUntil = &t
|
||||||
|
}
|
||||||
|
// 落库一条系统提示消息,刷新后仍可见(走 SendSystemMessage 统一处理房间 last_message_id 等)
|
||||||
|
var sysContent string
|
||||||
|
if body.Muted {
|
||||||
|
if body.DurationMinutes > 0 {
|
||||||
|
sysContent = targetName + " 已被禁言 " + formatMuteDurationCN(body.DurationMinutes)
|
||||||
|
} else {
|
||||||
|
sysContent = targetName + " 已被禁言 永久"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sysContent = targetName + " 的禁言已被解除"
|
||||||
|
}
|
||||||
|
sysMsg, _ := h.Chat.SendSystemMessage(roomID, sysContent)
|
||||||
|
// 广播系统消息到房间(sender_id=0,前端按系统消息渲染);
|
||||||
|
// Data 与普通消息一致直接放消息体(扁平结构),前端按 room_id 匹配后才能即时上屏
|
||||||
|
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.EventChatMemberMuted,
|
||||||
|
Data: gin.H{
|
||||||
|
"room_id": roomID,
|
||||||
|
"user_id": uint(targetID),
|
||||||
|
"muted": body.Muted,
|
||||||
|
"muted_until": mutedUntil,
|
||||||
|
"duration_minutes": body.DurationMinutes,
|
||||||
|
"target_name": targetName,
|
||||||
|
"operator_id": claims.ID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "muted": body.Muted, "duration_minutes": body.DurationMinutes})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetChatMemberRole 站长任命/撤销群管理员
|
// SetChatMemberRole 站长任命/撤销群管理员
|
||||||
@@ -619,7 +732,9 @@ func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage, ne
|
|||||||
}
|
}
|
||||||
env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data}
|
env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data}
|
||||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env)
|
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env)
|
||||||
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览
|
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览:
|
||||||
|
// 已撤回正文不得残留在侧栏预览里。预览回滚到上一条真实消息由 RecallMessage
|
||||||
|
// 回退 last_message_id 完成,本推送只负责即时抹掉正文、不产生角标与置顶。
|
||||||
if h.Chat == nil {
|
if h.Chat == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,38 +82,6 @@ func (h *Handlers) PostComments(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommentLocation 评论定位:返回该评论的楼层号(含软删占位,与楼层分页口径一致),
|
|
||||||
// 供通知/主页评论深链 #comment-{id} 跨页时换到正确页码。0 楼 = 评论不存在。
|
|
||||||
func (h *Handlers) CommentLocation(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 待审/被拒帖子的评论不对公众开放
|
|
||||||
var viewerID uint
|
|
||||||
var loadActor func() *service.Actor
|
|
||||||
if claims := middleware.CurrentUser(c); claims != nil {
|
|
||||||
viewerID = claims.ID
|
|
||||||
loadActor = h.actorLoader(claims.ID)
|
|
||||||
}
|
|
||||||
if err := h.Post.EnsurePostVisible(uint(id), viewerID, loadActor); err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
floor := h.Comment.FloorNumber(uint(id), uint(cid))
|
|
||||||
if floor <= 0 {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"floor": floor})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateCommentRequest 评论请求
|
// CreateCommentRequest 评论请求
|
||||||
type CreateCommentRequest struct {
|
type CreateCommentRequest struct {
|
||||||
Content string `json:"content" binding:"required,min=1,max=5000"`
|
Content string `json:"content" binding:"required,min=1,max=5000"`
|
||||||
|
|||||||
@@ -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 决定新帖/新评的初始状态:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
@@ -91,7 +92,11 @@ func (h *Handlers) AdminCreateLibraryDoc(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := h.LibrarySvc.Create(&in)
|
var creatorID uint
|
||||||
|
if claims := middleware.CurrentUser(c); claims != nil {
|
||||||
|
creatorID = claims.ID
|
||||||
|
}
|
||||||
|
d, err := h.LibrarySvc.Create(&in, creatorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -242,6 +247,109 @@ func (h *Handlers) AdminDeleteLibraryFile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 管理端:导出(迁移 / 备份) ----------
|
||||||
|
|
||||||
|
// writeLibraryExport 装载完成后统一以附件形式流式下发 ZIP;
|
||||||
|
// 注:一旦开始写响应体,中途 IO 错误无法再改成 JSON 错误,只能记录在 c.Errors。
|
||||||
|
func (h *Handlers) writeLibraryExport(c *gin.Context, exp *service.LibraryExport) {
|
||||||
|
c.Header("Content-Type", "application/zip")
|
||||||
|
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(exp.Filename))
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
if err := exp.WriteZip(c.Writer); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminExportLibraryDoc 导出单本书(元信息 + 章节 + 附件 + 本地封面,ZIP)
|
||||||
|
func (h *Handlers) AdminExportLibraryDoc(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的条目 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exp, err := h.LibrarySvc.BuildBookExport(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrLibraryNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "条目不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeLibraryExport(c, exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminExportAllLibrary 导出全部在用书籍(单 ZIP,library.json 索引)
|
||||||
|
func (h *Handlers) AdminExportAllLibrary(c *gin.Context) {
|
||||||
|
exp, err := h.LibrarySvc.BuildAllExport()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "导出失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.writeLibraryExport(c, exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminImportLibrary 上传导出 ZIP 恢复书籍(mode=create 默认跳过冲突 / overwrite 覆盖同名)
|
||||||
|
func (h *Handlers) AdminImportLibrary(c *gin.Context) {
|
||||||
|
claims := middleware.CurrentUser(c)
|
||||||
|
if claims == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const overhead = 64 << 10 // multipart 边界开销
|
||||||
|
limit := int64(service.LibraryImportMaxBytes) + overhead
|
||||||
|
if c.Request.ContentLength > limit {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||||
|
|
||||||
|
fh, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
var maxErr *http.MaxBytesError
|
||||||
|
if errors.As(err, &maxErr) {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包过大(不能超过 512MB)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择书库导出的 ZIP 备份包"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fh.Size > service.LibraryImportMaxBytes {
|
||||||
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "导入包不能超过 512MB"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := c.PostForm("mode")
|
||||||
|
if mode == "" {
|
||||||
|
mode = service.LibraryImportModeCreate
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath, err := saveMultipartToTemp(fh, "library-import-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "读取导入包失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpPath)
|
||||||
|
|
||||||
|
rep, err := h.LibrarySvc.ImportLibraryZip(tmpPath, mode, claims.ID)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrLibraryImportBadZip),
|
||||||
|
errors.Is(err, service.ErrLibraryImportFormat),
|
||||||
|
errors.Is(err, service.ErrLibraryImportVer),
|
||||||
|
errors.Is(err, service.ErrLibraryImportEmpty),
|
||||||
|
errors.Is(err, service.ErrLibraryImportMode):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败:" + err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"report": rep})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 管理端:章节 ----------
|
// ---------- 管理端:章节 ----------
|
||||||
|
|
||||||
// AdminCreateLibrarySection 新建章节(parent_id 空=章,非空=节)
|
// AdminCreateLibrarySection 新建章节(parent_id 空=章,非空=节)
|
||||||
|
|||||||
@@ -5,9 +5,18 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// notificationView 通知列表项:附带关联评论的楼层相对锚点,
|
||||||
|
// 供前端拼 #comment-{floor}(主楼)或 #comment-{floor}-r{comment_id}(楼中楼)。
|
||||||
|
type notificationView struct {
|
||||||
|
*model.Notification
|
||||||
|
CommentFloor uint `json:"comment_floor"`
|
||||||
|
CommentIsRoot bool `json:"comment_is_root"`
|
||||||
|
}
|
||||||
|
|
||||||
// Notifications 获取当前用户的通知列表(分页)
|
// Notifications 获取当前用户的通知列表(分页)
|
||||||
func (h *Handlers) Notifications(c *gin.Context) {
|
func (h *Handlers) Notifications(c *gin.Context) {
|
||||||
claims := middleware.CurrentUser(c)
|
claims := middleware.CurrentUser(c)
|
||||||
@@ -19,8 +28,24 @@ func (h *Handlers) Notifications(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
commentIDs := make([]uint, 0, len(list))
|
||||||
|
for _, n := range list {
|
||||||
|
if n.CommentID > 0 {
|
||||||
|
commentIDs = append(commentIDs, n.CommentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anchors := h.Comment.AnchorsByCommentIDs(commentIDs)
|
||||||
|
views := make([]notificationView, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
v := notificationView{Notification: &list[i]}
|
||||||
|
if a, ok := anchors[list[i].CommentID]; ok {
|
||||||
|
v.CommentFloor = a.Floor
|
||||||
|
v.CommentIsRoot = a.IsRoot
|
||||||
|
}
|
||||||
|
views = append(views, v)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"notifications": nonNilSlice(list),
|
"notifications": nonNilSlice(views),
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"size": size,
|
"size": size,
|
||||||
|
|||||||
@@ -262,6 +262,10 @@ func (h *Handlers) SendEmailCode(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP())
|
wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP())
|
||||||
|
if errors.Is(e, service.ErrEmailRegistered) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": e.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if !h.quotaResponse(c, wait, e) {
|
if !h.quotaResponse(c, wait, e) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -313,7 +317,8 @@ func (h *Handlers) PublicObject(c *gin.Context) {
|
|||||||
defer r.Close()
|
defer r.Close()
|
||||||
c.Header("Content-Type", m)
|
c.Header("Content-Type", m)
|
||||||
c.Header("X-Content-Type-Options", "nosniff")
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
c.Header("Cache-Control", "private, no-store")
|
// 对象 ID 按上传随机生成不覆盖,直出内容可短缓存(302 预签名分支不缓存,防过期地址复用)
|
||||||
|
c.Header("Cache-Control", "public, max-age=86400")
|
||||||
c.Status(200)
|
c.Status(200)
|
||||||
_, _ = io.Copy(c.Writer, r)
|
_, _ = io.Copy(c.Writer, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type updateSettingsRequest struct {
|
|||||||
BrandMark *string `json:"brand_mark"`
|
BrandMark *string `json:"brand_mark"`
|
||||||
BrandLogoSize *string `json:"brand_logo_size"`
|
BrandLogoSize *string `json:"brand_logo_size"`
|
||||||
BrandLogoFit *string `json:"brand_logo_fit"`
|
BrandLogoFit *string `json:"brand_logo_fit"`
|
||||||
|
BrandSloganVisible *bool `json:"brand_slogan_visible"`
|
||||||
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
||||||
|
|
||||||
PointsCheckinBase *int `json:"points_checkin_base"`
|
PointsCheckinBase *int `json:"points_checkin_base"`
|
||||||
@@ -120,6 +121,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
|||||||
"brand_mark": saved.BrandMark,
|
"brand_mark": saved.BrandMark,
|
||||||
"brand_logo_size": saved.BrandLogoSize,
|
"brand_logo_size": saved.BrandLogoSize,
|
||||||
"brand_logo_fit": saved.BrandLogoFit,
|
"brand_logo_fit": saved.BrandLogoFit,
|
||||||
|
"brand_slogan_visible": saved.BrandSloganVisible,
|
||||||
"footer_links": saved.FooterLinks,
|
"footer_links": saved.FooterLinks,
|
||||||
|
|
||||||
"points_checkin_base": saved.PointsCheckinBase,
|
"points_checkin_base": saved.PointsCheckinBase,
|
||||||
@@ -160,6 +162,7 @@ func (req *updateSettingsRequest) hasAny() bool {
|
|||||||
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
||||||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
||||||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
||||||
|
req.BrandSloganVisible != nil ||
|
||||||
req.FooterLinks != nil ||
|
req.FooterLinks != nil ||
|
||||||
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
|
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
|
||||||
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
|
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
|
||||||
@@ -540,6 +543,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.BrandSloganVisible != nil {
|
||||||
|
if err := h.Setting.SetBrandSloganVisible(*req.BrandSloganVisible); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.FooterLinks != nil {
|
if req.FooterLinks != nil {
|
||||||
if err := h.Setting.SetFooterLinks(*req.FooterLinks); err != nil {
|
if err := h.Setting.SetFooterLinks(*req.FooterLinks); err != nil {
|
||||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||||
|
|||||||
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))
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"github.com/freefire/jiang13-bbs/service"
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -102,7 +103,7 @@ func (h *Handlers) UploadImage(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
att, err := h.Upload.SaveImage(claims.ID, f)
|
att, err := h.Upload.SaveImage(claims.ID, f, c.PostForm("source"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var maxErr *http.MaxBytesError
|
var maxErr *http.MaxBytesError
|
||||||
if errors.As(err, &maxErr) {
|
if errors.As(err, &maxErr) {
|
||||||
@@ -314,7 +315,9 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadLibraryCoverFromMedia 把本人媒体库图片复制一份作书籍封面素材,只返回 URL,不绑定条目
|
// UploadLibraryCoverFromMedia 把本人媒体库图片用作书籍封面素材。
|
||||||
|
// 若该图已是封面类型(source=library_cover),直接复用原记录,不再复制;
|
||||||
|
// 否则复制一份并标记为封面类型(与原图解耦,删除原图不影响封面)。
|
||||||
func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
|
func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
AttachmentID uint `json:"attachment_id"`
|
AttachmentID uint `json:"attachment_id"`
|
||||||
@@ -328,7 +331,14 @@ func (h *Handlers) UploadLibraryCoverFromMedia(c *gin.Context) {
|
|||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID)
|
|
||||||
|
// 已是封面类型的图直接复用,避免重复复制产生冗余记录
|
||||||
|
if existing, err := h.Upload.FindMediaBySource(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover); err == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"url": existing.URL, "attachment": existing})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
att, err := h.Upload.CopyImageFromMedia(claims.ID, req.AttachmentID, model.AttachmentSourceLibraryCover)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -404,6 +414,23 @@ func (h *Handlers) MediaLibraryThumb(c *gin.Context) {
|
|||||||
c.Data(http.StatusOK, "image/webp", data)
|
c.Data(http.StatusOK, "image/webp", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ImageVariant 公开图片变体(GET /api/img?u=<源图URL>&w=<白名单宽度>):
|
||||||
|
// 供全站 srcset 消费,源内容不可变 → immutable 长缓存;失败 404 由前端 onError 兜底
|
||||||
|
func (h *Handlers) ImageVariant(c *gin.Context) {
|
||||||
|
w, err := strconv.Atoi(c.Query("w"))
|
||||||
|
if err != nil || !service.IsVariantWidth(w) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的尺寸"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, m, err := h.Upload.Variant(c.Query("u"), w)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "图片不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
c.Data(http.StatusOK, m, data)
|
||||||
|
}
|
||||||
|
|
||||||
func brandTooLarge(slot string) string {
|
func brandTooLarge(slot string) string {
|
||||||
if slot == service.BrandSlotFavicon {
|
if slot == service.BrandSlotFavicon {
|
||||||
return "Favicon 不能超过 512KB"
|
return "Favicon 不能超过 512KB"
|
||||||
|
|||||||
@@ -85,7 +85,32 @@ func (m *AuthMiddleware) RequireStaff() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequirePerm 功能点鉴权,必须接在 RequireStaff 之后
|
// RequireActor 必须登录并加载实时 Actor(不要求管理角色),
|
||||||
|
// 供账号级渠道权限(非管理角色也可被单独授予)所在路由组使用;
|
||||||
|
// 功能点鉴权由后续 RequirePerm 完成
|
||||||
|
func (m *AuthMiddleware) RequireActor() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
claims, ok := m.parseToken(c)
|
||||||
|
if !ok {
|
||||||
|
if c.GetBool(AccountBannedKey) {
|
||||||
|
bannedJSON(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor, err := m.auth.LoadActor(claims.ID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权限执行该操作"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("user", claims)
|
||||||
|
c.Set(ActorKey, actor)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequirePerm 功能点鉴权,必须接在 RequireStaff / RequireActor 之后
|
||||||
func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc {
|
func (m *AuthMiddleware) RequirePerm(perm string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
actor := CurrentActor(c)
|
actor := CurrentActor(c)
|
||||||
|
|||||||
19
backend/middleware/upload_cache.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UploadStaticCache 上传文件长缓存:/uploads 下文件名均为上传时随机生成、
|
||||||
|
// 写入后不覆盖(SaveAvatar/SaveImage 等均新建文件),URL 内容恒定,可安全
|
||||||
|
// immutable。PageSpeed「缓存生命周期」修复项。
|
||||||
|
func UploadStaticCache() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if strings.HasPrefix(c.Request.URL.Path, "/uploads/") {
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ func InitDB(dsn string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
|
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{}, &Blob{},
|
||||||
&User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{},
|
&User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{},
|
||||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -78,6 +79,9 @@ func InitDB(dsn string) error {
|
|||||||
// 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund)
|
// 每次启动执行,幂等自愈,保证与 CreditTx 增量口径一致(均排除 bounty_refund)
|
||||||
backfillUserTotalPoints(db)
|
backfillUserTotalPoints(db)
|
||||||
|
|
||||||
|
// 书库:历史条目 creator_id=0 时介绍页无创建者可展示,回填为站长
|
||||||
|
backfillLibraryCreator(db)
|
||||||
|
|
||||||
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
|
// login_logs.success 早期 default=true 与 GORM 零值省略叠加,
|
||||||
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
|
// 会把失败登录错存为成功;AutoMigrate 不会改列默认值,这里幂等修正
|
||||||
if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil {
|
if err := db.Exec(`ALTER TABLE login_logs ALTER COLUMN success SET DEFAULT false`).Error; err != nil {
|
||||||
@@ -146,6 +150,7 @@ func dropStaleChatFKConstraints(db *gorm.DB) error {
|
|||||||
pairs := []struct{ table, constraint string }{
|
pairs := []struct{ table, constraint string }{
|
||||||
{"chat_rooms", "fk_chat_rooms_last_message"},
|
{"chat_rooms", "fk_chat_rooms_last_message"},
|
||||||
{"notifications", "fk_notifications_room"},
|
{"notifications", "fk_notifications_room"},
|
||||||
|
{"chat_messages", "fk_chat_messages_sender"},
|
||||||
}
|
}
|
||||||
for _, p := range pairs {
|
for _, p := range pairs {
|
||||||
db.Exec(fmt.Sprintf(`ALTER TABLE IF EXISTS %s DROP CONSTRAINT IF EXISTS %s`, p.table, p.constraint))
|
db.Exec(fmt.Sprintf(`ALTER TABLE IF EXISTS %s DROP CONSTRAINT IF EXISTS %s`, p.table, p.constraint))
|
||||||
@@ -368,6 +373,29 @@ func backfillUserTotalPoints(db *gorm.DB) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// backfillLibraryCreator 为历史书库条目(creator_id=0,特性上线前创建或早期导入)
|
||||||
|
// 回填站长为创建者,保证介绍页「创建者」可展示。幂等:仅影响 creator_id=0 的行;
|
||||||
|
// 站长不存在(空库)时跳过,等下次启动自愈。
|
||||||
|
func backfillLibraryCreator(db *gorm.DB) {
|
||||||
|
var owner User
|
||||||
|
err := db.Select("id").Where("role = ?", RoleOwner).Order("id ASC").First(&owner).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[model] 回填书库创建者前置查询失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := db.Exec(`UPDATE library_docs SET creator_id = ? WHERE creator_id = 0`, owner.ID)
|
||||||
|
if res.Error != nil {
|
||||||
|
log.Printf("[model] 回填书库创建者失败: %v", res.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.RowsAffected > 0 {
|
||||||
|
log.Printf("[model] 已回填书库条目创建者为站长 %d 行", res.RowsAffected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
|
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
|
||||||
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
|
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
|
||||||
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。
|
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ type User struct {
|
|||||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||||
Banned bool `gorm:"default:false" json:"banned"`
|
Banned bool `gorm:"default:false" json:"banned"`
|
||||||
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
|
CanManageMessages bool `gorm:"not null;default:false" json:"can_manage_messages"` // 站点消息管理(仅站长可授;站长/超管固有全站监管)
|
||||||
|
PermOverrides string `gorm:"size:256;default:''" json:"-"` // 账号级渠道权限覆盖 JSON;仅后台权限服务读取,序列化跳过
|
||||||
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
||||||
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
|
TotalPoints int `gorm:"not null;default:0" json:"total_points"` // 累计获得积分(经验值,定级依据;消费不减)
|
||||||
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
|
Level int `gorm:"-" json:"level"` // 用户等级(AfterFind 由 TotalPoints 计算,纯展示不入库)
|
||||||
@@ -361,12 +362,13 @@ type PostContentUnlock struct {
|
|||||||
|
|
||||||
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
|
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
|
||||||
type PostAttachment struct {
|
type PostAttachment struct {
|
||||||
ObjectID string `gorm:"size:64;index" json:"-"`
|
ObjectID string `gorm:"size:96;index" json:"-"`
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
|
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
|
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
|
||||||
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
|
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名(blob 去重后即内容 hash)
|
||||||
|
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
|
||||||
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
|
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
|
||||||
Size int `gorm:"not null;default:0" json:"size"`
|
Size int `gorm:"not null;default:0" json:"size"`
|
||||||
PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费
|
PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费
|
||||||
@@ -529,11 +531,17 @@ type LibraryDoc struct {
|
|||||||
Title string `gorm:"size:200;not null" json:"title"`
|
Title string `gorm:"size:200;not null" json:"title"`
|
||||||
Description string `gorm:"type:text" json:"description"` // markdown 介绍,可空
|
Description string `gorm:"type:text" json:"description"` // markdown 介绍,可空
|
||||||
CoverURL string `gorm:"size:512" json:"cover_url"` // 可选图片封面(/uploads/images)
|
CoverURL string `gorm:"size:512" json:"cover_url"` // 可选图片封面(/uploads/images)
|
||||||
|
CoverWidth int `gorm:"not null;default:0" json:"cover_width"` // 封面自然宽(0=未知,前端回退探测)
|
||||||
|
CoverHeight int `gorm:"not null;default:0" json:"cover_height"` // 封面自然高(0=未知,前端回退探测)
|
||||||
Published bool `gorm:"not null;index" json:"published"`
|
Published bool `gorm:"not null;index" json:"published"`
|
||||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||||
// EntriesAuto 全章节条目卡模式:开启后章节内容免写 [entries] 壳,
|
// EntriesAuto 全章节条目卡模式:开启后章节内容免写 [entries] 壳,
|
||||||
// 「## 标题 + @tags/@summary/字段::值/@source 指令行」自动识别为条目卡(个别章节用 [entries:off] 例外)
|
// 「## 标题 + @tags/@summary/字段::值/@source 指令行」自动识别为条目卡(个别章节用 [entries:off] 例外)
|
||||||
EntriesAuto bool `gorm:"not null;default:false" json:"entries_auto"`
|
EntriesAuto bool `gorm:"not null;default:false" json:"entries_auto"`
|
||||||
|
// Author 书籍作者(自由文本,可填多位,如「鲁迅 译 / 某某注」),仅展示用
|
||||||
|
Author string `gorm:"size:100;not null;default:''" json:"author"`
|
||||||
|
// CreatorID 条目创建者(站点用户;0=历史数据或导入时无对应用户),创建后不变
|
||||||
|
CreatorID uint `gorm:"index;not null;default:0" json:"creator_id"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
@@ -568,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"`
|
||||||
@@ -652,12 +729,21 @@ const (
|
|||||||
AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
|
AttachmentKindImage = "image" // 通用图片(帖子插图等,媒体库统一管理)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
|
// 附件来源(Source):媒体库分组依据,决定"从图库选图"时是否可直接复用
|
||||||
|
const (
|
||||||
|
AttachmentSourcePost = "post" // 帖子/文章编辑器插图(默认)
|
||||||
|
AttachmentSourceLibraryCover = "library_cover" // 书库封面(选用时已复制过,再次选可直接引用)
|
||||||
|
AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attachment 用户上传的附件(头像与帖子插图等公开图片)
|
||||||
type Attachment struct {
|
type Attachment struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
|
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
|
||||||
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
|
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
|
||||||
|
Source string `gorm:"size:24;not null;default:'post'" json:"source"`
|
||||||
URL string `gorm:"size:512;not null" json:"url"`
|
URL string `gorm:"size:512;not null" json:"url"`
|
||||||
|
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
|
||||||
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
|
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
|
||||||
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
|
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
|
||||||
Width int `gorm:"not null;default:0" json:"width"`
|
Width int `gorm:"not null;default:0" json:"width"`
|
||||||
@@ -665,6 +751,20 @@ type Attachment struct {
|
|||||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blob 内容寻址物理文件:对【最终存储字节】算 SHA-256,同内容多次上传共享一份底层存储,
|
||||||
|
// 业务行(Attachment/PostAttachment 及未来的聊天文件消息)只引用 hash,按引用计数回收。
|
||||||
|
// 公开(帖子插图/头像)与私有(付费附件、未来私聊文件)是两个互不可见的命名空间:
|
||||||
|
// 同字节也各自存一份,避免私有文件借公开链接(/api/media/<id>)被越权读取。
|
||||||
|
type Blob struct {
|
||||||
|
Hash string `gorm:"primaryKey;size:64" json:"-"` // 最终存储字节的 SHA-256 hex(图片为转码后的字节)
|
||||||
|
Public bool `gorm:"primaryKey" json:"-"`
|
||||||
|
RefCount int64 `gorm:"not null;default:0" json:"-"`
|
||||||
|
Size int `gorm:"not null;default:0" json:"-"`
|
||||||
|
MIME string `gorm:"size:128;not null;default:''" json:"-"`
|
||||||
|
ObjectID string `gorm:"size:96;not null;default:''" json:"-"` // S3 后端的 StoredObject ID;本地后端为空
|
||||||
|
CreatedAt time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
// UserBoard 板块管理员的板块授权(多对多;仅 role=board_admin 的行生效)
|
// UserBoard 板块管理员的板块授权(多对多;仅 role=board_admin 的行生效)
|
||||||
type UserBoard struct {
|
type UserBoard struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
@@ -736,6 +836,7 @@ type ChatRoomMember struct {
|
|||||||
Role string `gorm:"size:16;not null;default:member" json:"role"`
|
Role string `gorm:"size:16;not null;default:member" json:"role"`
|
||||||
LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"`
|
LastReadMessageID uint `gorm:"not null;default:0" json:"last_read_message_id"`
|
||||||
Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言
|
Muted bool `gorm:"not null;default:false" json:"muted"` // 被群主禁言
|
||||||
|
MutedUntil *time.Time `json:"muted_until,omitempty"` // 禁言截止时间;nil 表示永久
|
||||||
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段
|
PinnedAt *time.Time `json:"pinned_at,omitempty"` // 该用户个人置顶时间;大厅强制置顶不依赖此字段
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -747,17 +848,18 @@ type ChatRoomMember struct {
|
|||||||
type ChatMessage struct {
|
type ChatMessage struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_id"`
|
RoomID uint `gorm:"index:idx_chat_room_created,priority:1;not null" json:"room_id"`
|
||||||
SenderID uint `gorm:"index;not null" json:"sender_id"`
|
SenderID uint `gorm:"index" json:"sender_id"` // 0 表示系统消息(is_system=true)
|
||||||
Content string `gorm:"type:varchar(2000);not null" json:"content"`
|
Content string `gorm:"type:varchar(2000);not null" json:"content"`
|
||||||
ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用
|
ReplyToID uint `gorm:"not null;default:0;index" json:"reply_to_id"` // 引用的消息 ID,0 表示无引用
|
||||||
ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示
|
ReplySnap string `gorm:"size:512;not null;default:''" json:"reply_snap"` // 引用快照「昵称: 摘要」,原文撤回后仍可展示
|
||||||
MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用)
|
MentionIDs string `gorm:"size:512;not null;default:''" json:"-"` // 被@用户 ID 逗号分隔(落通知用)
|
||||||
RecalledAt *time.Time `json:"recalled_at,omitempty"`
|
RecalledAt *time.Time `json:"recalled_at,omitempty"`
|
||||||
RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"`
|
RecalledBy uint `gorm:"not null;default:0" json:"recalled_by"`
|
||||||
|
IsSystem bool `gorm:"not null;default:false" json:"is_system"` // 系统提示(如禁言通知),sender_id=0
|
||||||
CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"`
|
CreatedAt time.Time `gorm:"index:idx_chat_room_created,priority:2" json:"created_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
Sender User `gorm:"foreignKey:SenderID" json:"sender,omitempty"`
|
Sender User `gorm:"foreignKey:SenderID;constraint:-" json:"sender,omitempty"` // constraint:- 禁用 FK,支持 sender_id=0 的系统消息
|
||||||
// RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称
|
// RecalledBy=0 时不建 FK;Preload 仅用于已撤回消息展示撤回者昵称
|
||||||
Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"`
|
Recaller *User `gorm:"foreignKey:RecalledBy;constraint:-" json:"recaller,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ type EmailChallenge struct {
|
|||||||
|
|
||||||
// Every remote object pins the immutable configuration revision that wrote it.
|
// Every remote object pins the immutable configuration revision that wrote it.
|
||||||
type StoredObject struct {
|
type StoredObject struct {
|
||||||
ID string `gorm:"primaryKey;size:64" json:"id"`
|
ID string `gorm:"primaryKey;size:96" json:"id"`
|
||||||
ConfigName string `gorm:"size:64;index" json:"-"`
|
ConfigName string `gorm:"size:64;index" json:"-"`
|
||||||
Key string `gorm:"size:512" json:"-"`
|
Key string `gorm:"size:512" json:"-"`
|
||||||
MIME string `gorm:"size:128" json:"-"`
|
MIME string `gorm:"size:128" json:"-"`
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const (
|
|||||||
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
|
EventNotificationNew = "notification:new" // 新站内通知(如群聊 @),推 user:{id}
|
||||||
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
|
EventFeedChanged = "feed:changed" // 帖子流有公开新内容(三期),全员广播
|
||||||
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
|
EventChatRecalled = "chat:message_recalled" // 消息撤回,推 chat:{roomID}
|
||||||
|
EventChatMemberMuted = "chat:member_muted" // 成员被禁言/解禁,推 chat:{roomID}
|
||||||
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
|
EventChatUnread = "chat:unread" // 未读增量,推 user:{id}(未订阅房间也能实时角标)
|
||||||
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
|
EventModerationChanged = "moderation:changed" // 待审队列变化,推 staff 房间(客户端各自 HTTP 校准角标)
|
||||||
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
|
EventSessionReplaced = "session:replaced" // 登录态被顶/被剔除,推 user:{id}(旧设备即时感知并提示重新登录)
|
||||||
|
|||||||
@@ -101,7 +101,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
postFileSvc.WithOperations(ops)
|
postFileSvc.WithOperations(ops)
|
||||||
pointsSvc := service.NewPointsService(model.DB)
|
pointsSvc := service.NewPointsService(model.DB)
|
||||||
leaderboardSvc := service.NewLeaderboardService(model.DB)
|
leaderboardSvc := service.NewLeaderboardService(model.DB)
|
||||||
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).WithSetting(settingSvc)
|
librarySvc := service.NewLibraryService(model.DB, cfg.DataDir).
|
||||||
|
WithSetting(settingSvc).
|
||||||
|
WithUploadsDir(filepath.Join(cfg.DataDir, "uploads"))
|
||||||
|
snippetSvc := service.NewSnippetService(model.DB)
|
||||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
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)
|
||||||
@@ -163,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) {
|
||||||
@@ -183,6 +187,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// 挂在 OptionalAuth 之后可顺带拿到登录用户
|
// 挂在 OptionalAuth 之后可顺带拿到登录用户
|
||||||
r.Use(middleware.VisitCapture(visitSvc))
|
r.Use(middleware.VisitCapture(visitSvc))
|
||||||
|
|
||||||
|
// 上传文件长缓存(必须先于 r.Static 注册;文件名随机不覆盖,可 immutable)
|
||||||
|
r.Use(middleware.UploadStaticCache())
|
||||||
|
|
||||||
// 上传文件静态服务(data/uploads → /uploads)
|
// 上传文件静态服务(data/uploads → /uploads)
|
||||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||||
|
|
||||||
@@ -209,7 +216,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
|
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
|
||||||
pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment)
|
pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment)
|
||||||
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
||||||
pubAPI.GET("/posts/:id/comments/:cid", h.CommentLocation)
|
|
||||||
pubAPI.GET("/users/:id", h.UserProfile)
|
pubAPI.GET("/users/:id", h.UserProfile)
|
||||||
pubAPI.GET("/users/:id/comments", h.UserComments)
|
pubAPI.GET("/users/:id/comments", h.UserComments)
|
||||||
pubAPI.GET("/users/:id/favorites", h.UserFavorites)
|
pubAPI.GET("/users/:id/favorites", h.UserFavorites)
|
||||||
@@ -225,7 +231,16 @@ 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 缓存)
|
||||||
|
pubAPI.GET("/img", h.ImageVariant)
|
||||||
pubAPI.GET("/media/:object", h.PublicObject)
|
pubAPI.GET("/media/:object", h.PublicObject)
|
||||||
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
pubAPI.POST("/auth/code", middleware.CSRFMiddleware(), h.SendEmailCode)
|
||||||
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
|
pubAPI.POST("/auth/reset-password", middleware.CSRFMiddleware(), h.ResetPassword)
|
||||||
@@ -302,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)
|
||||||
{
|
{
|
||||||
@@ -376,23 +404,34 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
|
// 群聊监管:移出后挂到 RequireAuth 组,允许「仅群管/消息 flag」用户访问
|
||||||
// (见下方 msgAPI)
|
// (见下方 msgAPI)
|
||||||
|
|
||||||
// 站点公告文章管理(管理员及以上)
|
// 渠道内容管理(公告/单页/书库/广告):RequireActor 允许任意登录账号进入,
|
||||||
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
// 由 RequirePerm 按渠道权限码放行——角色默认管理员及以上,
|
||||||
|
// 超管/站长亦可将单个渠道授予板块管理员或普通用户(账号级授权)
|
||||||
|
permAPI := r.Group("/api/admin", authMW.RequireActor(), middleware.CSRFMiddleware())
|
||||||
|
|
||||||
|
// 站点公告文章管理(PermAnnouncements)
|
||||||
|
announceAPI := permAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
||||||
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
||||||
announceAPI.POST("/announcements", h.AdminCreateAnnouncement)
|
announceAPI.POST("/announcements", h.AdminCreateAnnouncement)
|
||||||
announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
|
announceAPI.PUT("/announcements/:id", h.AdminUpdateAnnouncement)
|
||||||
announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin)
|
announceAPI.POST("/announcements/:id/pin", h.AdminToggleAnnouncementPin)
|
||||||
announceAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
|
announceAPI.DELETE("/announcements/:id", h.AdminDeleteAnnouncement)
|
||||||
announceAPI.GET("/pages", h.AdminListSitePages)
|
|
||||||
announceAPI.POST("/pages", h.AdminCreateSitePage)
|
|
||||||
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
|
||||||
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
|
||||||
announceAPI.DELETE("/pages/:id/purge", h.AdminPurgeSitePage)
|
|
||||||
|
|
||||||
// 书库管理(管理员及以上,同公告/单页权限)
|
// 站点单页管理(PermPages)
|
||||||
libraryAPI := staffAPI.Group("/library", authMW.RequirePerm(service.PermAnnouncements))
|
pagesAPI := permAPI.Group("", authMW.RequirePerm(service.PermPages))
|
||||||
|
pagesAPI.GET("/pages", h.AdminListSitePages)
|
||||||
|
pagesAPI.POST("/pages", h.AdminCreateSitePage)
|
||||||
|
pagesAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
||||||
|
pagesAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
||||||
|
pagesAPI.DELETE("/pages/:id/purge", h.AdminPurgeSitePage)
|
||||||
|
|
||||||
|
// 书库管理(PermLibrary)
|
||||||
|
libraryAPI := permAPI.Group("/library", authMW.RequirePerm(service.PermLibrary))
|
||||||
{
|
{
|
||||||
libraryAPI.GET("/docs", h.AdminListLibraryDocs)
|
libraryAPI.GET("/docs", h.AdminListLibraryDocs)
|
||||||
|
libraryAPI.GET("/export", h.AdminExportAllLibrary)
|
||||||
|
libraryAPI.POST("/import", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminImportLibrary)
|
||||||
|
libraryAPI.GET("/docs/:id/export", h.AdminExportLibraryDoc)
|
||||||
libraryAPI.POST("/docs", h.AdminCreateLibraryDoc)
|
libraryAPI.POST("/docs", h.AdminCreateLibraryDoc)
|
||||||
libraryAPI.PUT("/docs/:id", h.AdminUpdateLibraryDoc)
|
libraryAPI.PUT("/docs/:id", h.AdminUpdateLibraryDoc)
|
||||||
libraryAPI.DELETE("/docs/:id", h.AdminDeleteLibraryDoc)
|
libraryAPI.DELETE("/docs/:id", h.AdminDeleteLibraryDoc)
|
||||||
@@ -409,8 +448,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
libraryAPI.POST("/cover/from-media", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadLibraryCoverFromMedia)
|
libraryAPI.POST("/cover/from-media", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadLibraryCoverFromMedia)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 广告位管理(管理员及以上)
|
// 广告位管理(PermAds)
|
||||||
adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
adsAPI := permAPI.Group("", authMW.RequirePerm(service.PermAds))
|
||||||
adsAPI.GET("/ads/config", h.AdminGetAdsConfig)
|
adsAPI.GET("/ads/config", h.AdminGetAdsConfig)
|
||||||
adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig)
|
adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig)
|
||||||
adsAPI.GET("/ads", h.AdminListAds)
|
adsAPI.GET("/ads", h.AdminListAds)
|
||||||
@@ -477,6 +516,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
usersAPI.GET("/users", h.AdminListUsers)
|
usersAPI.GET("/users", h.AdminListUsers)
|
||||||
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
|
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
|
||||||
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
|
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
|
||||||
|
usersAPI.GET("/users/:id/perms", h.AdminGetUserPerms)
|
||||||
|
usersAPI.PUT("/users/:id/perms", h.AdminSetUserPerms)
|
||||||
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
|
usersAPI.PUT("/users/:id/ban", h.AdminSetUserBan)
|
||||||
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
|
usersAPI.GET("/users/:id/login-logs", h.AdminUserLoginLogs)
|
||||||
// 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页
|
// 积分管理:手动调整(加分计累计/扣分仅扣余额)与积分流水分页
|
||||||
@@ -489,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))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
@@ -10,13 +12,110 @@ import (
|
|||||||
// 板块管理员的板块范围由 Actor.BoardIDs 在业务层二次校验。
|
// 板块管理员的板块范围由 Actor.BoardIDs 在业务层二次校验。
|
||||||
const (
|
const (
|
||||||
PermUsers = "users" // 用户与权限管理(超管/站长)
|
PermUsers = "users" // 用户与权限管理(超管/站长)
|
||||||
PermAnnouncements = "announcements" // 公告管理(管理员及以上)
|
PermAnnouncements = "announcements" // 公告管理(管理员及以上;可账号级授予)
|
||||||
|
PermPages = "pages" // 单页管理(管理员及以上;可账号级授予)
|
||||||
|
PermLibrary = "library" // 书库管理(管理员及以上;可账号级授予)
|
||||||
|
PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予)
|
||||||
PermSettings = "settings" // 站点外观设置(超管/站长)
|
PermSettings = "settings" // 站点外观设置(超管/站长)
|
||||||
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
||||||
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
||||||
PermBoards = "boards" // 板块管理(仅站长)
|
PermBoards = "boards" // 板块管理(仅站长)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GrantablePerms 可账号级授予的渠道权限码(授予者需持有 PermUsers)。
|
||||||
|
// 管理员及以上角色对这些渠道为角色自带,账号级授予只对板块管理员/普通用户生效。
|
||||||
|
var GrantablePerms = []string{PermAnnouncements, PermPages, PermLibrary, PermAds}
|
||||||
|
|
||||||
|
// ValidPermOverride 权限码是否可被账号级授予
|
||||||
|
func ValidPermOverride(p string) bool {
|
||||||
|
for _, g := range GrantablePerms {
|
||||||
|
if p == g {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizePermOverrides 白名单过滤 + 去重 + 排序,得到可落库的权限码集
|
||||||
|
func NormalizePermOverrides(perms []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, len(perms))
|
||||||
|
for _, p := range perms {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" || seen[p] || !ValidPermOverride(p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p] = true
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePermOverrides 解析 users.perm_overrides JSON 列(容错:非法内容视为空)
|
||||||
|
func ParsePermOverrides(raw string) []string {
|
||||||
|
if raw == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
var arr []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return NormalizePermOverrides(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializePermOverrides 序列化为落库 JSON(空集统一 "[]",避免 NULL/空串分歧)
|
||||||
|
func SerializePermOverrides(perms []string) string {
|
||||||
|
norm := NormalizePermOverrides(perms)
|
||||||
|
b, err := json.Marshal(norm)
|
||||||
|
if err != nil {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleDefaultPerm 角色对功能点的固有权限(不含账号级授予)
|
||||||
|
func roleDefaultPerm(role model.Role, p string) bool {
|
||||||
|
switch p {
|
||||||
|
case PermBoards:
|
||||||
|
return role == model.RoleOwner
|
||||||
|
case PermUsers, PermSettings:
|
||||||
|
return role == model.RoleSuperAdmin || role == model.RoleOwner
|
||||||
|
case PermAnnouncements, PermPages, PermLibrary, PermAds:
|
||||||
|
return model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin)
|
||||||
|
case PermModeration:
|
||||||
|
return model.IsStaff(role)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectivePerms 生效权限码列表(角色默认 ∪ 账号授予 + 消息管理),
|
||||||
|
// 供 /me 下发,前端据此渲染后台入口/按钮;最终权限仍以 HasPerm 校验为准。
|
||||||
|
func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages bool) []string {
|
||||||
|
set := map[string]bool{}
|
||||||
|
all := append([]string{
|
||||||
|
PermUsers, PermAnnouncements, PermPages, PermLibrary, PermAds,
|
||||||
|
PermSettings, PermModeration, PermBoards,
|
||||||
|
}, GrantablePerms...)
|
||||||
|
for _, p := range all {
|
||||||
|
if roleDefaultPerm(role, p) {
|
||||||
|
set[p] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range ParsePermOverrides(permOverridesRaw) {
|
||||||
|
set[p] = true
|
||||||
|
}
|
||||||
|
if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages {
|
||||||
|
set[PermMessages] = true
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
for p := range set {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
|
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
|
||||||
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
|
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
|
||||||
type Actor struct {
|
type Actor struct {
|
||||||
@@ -25,6 +124,7 @@ type Actor struct {
|
|||||||
Role model.Role
|
Role model.Role
|
||||||
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
||||||
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
||||||
|
ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsStaff 是否管理团队成员
|
// IsStaff 是否管理团队成员
|
||||||
@@ -32,23 +132,22 @@ func (a *Actor) IsStaff() bool {
|
|||||||
return a != nil && model.IsStaff(a.Role)
|
return a != nil && model.IsStaff(a.Role)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPerm 是否拥有某后台功能点
|
// HasPerm 是否拥有某后台功能点:角色固有权限 ∪ 账号级授予
|
||||||
func (a *Actor) HasPerm(p string) bool {
|
func (a *Actor) HasPerm(p string) bool {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch p {
|
if roleDefaultPerm(a.Role, p) {
|
||||||
case PermBoards:
|
return true
|
||||||
return a.Role == model.RoleOwner
|
}
|
||||||
case PermUsers, PermSettings:
|
if p == PermMessages {
|
||||||
return a.Role == model.RoleSuperAdmin || a.Role == model.RoleOwner
|
|
||||||
case PermAnnouncements:
|
|
||||||
return model.RoleLevel(a.Role) >= model.RoleLevel(model.RoleAdmin)
|
|
||||||
case PermModeration:
|
|
||||||
return a.IsStaff()
|
|
||||||
case PermMessages:
|
|
||||||
return a.HasSiteMessagePerm()
|
return a.HasSiteMessagePerm()
|
||||||
}
|
}
|
||||||
|
for _, e := range a.ExtraPerms {
|
||||||
|
if e == p {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,15 +212,16 @@ func (a *Actor) CanAssignRole(target model.Role) bool {
|
|||||||
return a.HasPerm(PermUsers)
|
return a.HasPerm(PermUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadActor 读取用户实时角色与板块授权
|
// LoadActor 读取用户实时角色、板块授权与账号级渠道权限
|
||||||
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
|
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
|
||||||
var u model.User
|
var u model.User
|
||||||
if err := s.db.Select("id", "username", "role", "can_manage_messages").First(&u, id).Error; err != nil {
|
if err := s.db.Select("id", "username", "role", "can_manage_messages", "perm_overrides").First(&u, id).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
actor := &Actor{
|
actor := &Actor{
|
||||||
ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{},
|
ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{},
|
||||||
CanManageMessages: u.CanManageMessages,
|
CanManageMessages: u.CanManageMessages,
|
||||||
|
ExtraPerms: ParsePermOverrides(u.PermOverrides),
|
||||||
}
|
}
|
||||||
if u.Role == model.RoleBoardAdmin {
|
if u.Role == model.RoleBoardAdmin {
|
||||||
var ids []uint
|
var ids []uint
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ type AdminContentComment struct {
|
|||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Floor uint `json:"floor"` // 所属楼层相对序号
|
||||||
|
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前台拼 #comment-{floor}[-r{id}]
|
||||||
Board model.Board `json:"board"`
|
Board model.Board `json:"board"`
|
||||||
User model.User `json:"user"`
|
User model.User `json:"user"`
|
||||||
}
|
}
|
||||||
@@ -270,6 +272,11 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
items := make([]AdminContentComment, 0, len(rows))
|
items := make([]AdminContentComment, 0, len(rows))
|
||||||
|
commentIDs := make([]uint, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
commentIDs = append(commentIDs, r.ID)
|
||||||
|
}
|
||||||
|
anchors := CommentAnchors(s.db, commentIDs)
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
it := AdminContentComment{
|
it := AdminContentComment{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
@@ -283,6 +290,10 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
|
|||||||
Board: boards[r.BoardID],
|
Board: boards[r.BoardID],
|
||||||
User: users[r.UserID],
|
User: users[r.UserID],
|
||||||
}
|
}
|
||||||
|
if a, ok := anchors[r.ID]; ok {
|
||||||
|
it.Floor = a.Floor
|
||||||
|
it.IsRoot = a.IsRoot
|
||||||
|
}
|
||||||
if r.DeletedAt.Valid {
|
if r.DeletedAt.Valid {
|
||||||
it.Deleted = true
|
it.Deleted = true
|
||||||
t := r.DeletedAt.Time
|
t := r.DeletedAt.Time
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type AdminUserItem struct {
|
|||||||
BoardIDs []uint `json:"board_ids"`
|
BoardIDs []uint `json:"board_ids"`
|
||||||
Banned bool `json:"banned"`
|
Banned bool `json:"banned"`
|
||||||
CanManageMessages bool `json:"can_manage_messages"`
|
CanManageMessages bool `json:"can_manage_messages"`
|
||||||
|
PermOverrides []string `json:"perm_overrides"` // 账号级渠道权限(角色固有之外的增量授予)
|
||||||
PostCount int64 `json:"post_count"`
|
PostCount int64 `json:"post_count"`
|
||||||
CommentCount int64 `json:"comment_count"`
|
CommentCount int64 `json:"comment_count"`
|
||||||
Points int `json:"points"`
|
Points int `json:"points"`
|
||||||
@@ -237,6 +238,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
|
|||||||
BoardIDs: []uint{},
|
BoardIDs: []uint{},
|
||||||
Banned: u.Banned,
|
Banned: u.Banned,
|
||||||
CanManageMessages: u.CanManageMessages,
|
CanManageMessages: u.CanManageMessages,
|
||||||
|
PermOverrides: ParsePermOverrides(u.PermOverrides),
|
||||||
Points: u.Points,
|
Points: u.Points,
|
||||||
TotalPoints: u.TotalPoints,
|
TotalPoints: u.TotalPoints,
|
||||||
Level: u.Level,
|
Level: u.Level,
|
||||||
@@ -407,6 +409,12 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 升至管理员及以上后渠道权限变为角色自带,账号级授予清空以免误导
|
||||||
|
if roleChanged && model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin) && u.PermOverrides != "" && u.PermOverrides != "[]" {
|
||||||
|
if err := tx.Model(&u).Update("perm_overrides", "[]").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -415,6 +423,53 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
|
|||||||
return s.getItem(s.db, targetID)
|
return s.getItem(s.db, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPermOverrides 读取目标账号的渠道权限(PermUsers 持有者可读,供授权弹窗回显)
|
||||||
|
func (s *AdminUserService) GetPermOverrides(operator *Actor, targetID uint) ([]string, error) {
|
||||||
|
if operator == nil || !operator.HasPerm(PermUsers) {
|
||||||
|
return nil, ErrCannotAssignRole
|
||||||
|
}
|
||||||
|
var u model.User
|
||||||
|
if err := s.db.Select("id", "perm_overrides").First(&u, targetID).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ParsePermOverrides(u.PermOverrides), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPermOverrides 授予/撤销账号级渠道权限(公告/单页/书库/广告)。
|
||||||
|
// 授予者需持有 PermUsers(超管/站长);目标为管理员及以上时渠道为角色自带,
|
||||||
|
// 授权无意义,强制清空。Actor 每次请求现查 DB,变更即时生效,无需强制下线。
|
||||||
|
func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perms []string) (*AdminUserItem, error) {
|
||||||
|
if operator == nil || !operator.HasPerm(PermUsers) {
|
||||||
|
return nil, ErrCannotAssignRole
|
||||||
|
}
|
||||||
|
if operator.ID == targetID {
|
||||||
|
return nil, ErrAdminSelfAction
|
||||||
|
}
|
||||||
|
norm := NormalizePermOverrides(perms)
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var u model.User
|
||||||
|
if err := tx.First(&u, targetID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u.Role == model.RoleOwner {
|
||||||
|
return ErrProtectedOwner
|
||||||
|
}
|
||||||
|
// 管理员及以上角色自带全部渠道权限,账号级授予保持为空
|
||||||
|
if model.RoleLevel(u.Role) >= model.RoleLevel(model.RoleAdmin) {
|
||||||
|
norm = []string{}
|
||||||
|
}
|
||||||
|
raw := SerializePermOverrides(norm)
|
||||||
|
if u.PermOverrides == raw {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tx.Model(&u).Update("perm_overrides", raw).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.getItem(s.db, targetID)
|
||||||
|
}
|
||||||
|
|
||||||
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
|
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
|
||||||
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
|
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
|
||||||
if operator == nil || operator.Role != model.RoleOwner {
|
if operator == nil || operator.Role != model.RoleOwner {
|
||||||
@@ -594,6 +649,8 @@ type AdminAuditCommentItem struct {
|
|||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||||
|
Floor uint `json:"floor"`
|
||||||
|
IsRoot bool `json:"is_root"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminAuditMessageItem 审计聊天消息行
|
// AdminAuditMessageItem 审计聊天消息行
|
||||||
@@ -686,6 +743,11 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
|
|||||||
Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
|
Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
|
||||||
return nil, 0, page, err
|
return nil, 0, page, err
|
||||||
}
|
}
|
||||||
|
commentIDs := make([]uint, 0, len(comments))
|
||||||
|
for _, c := range comments {
|
||||||
|
commentIDs = append(commentIDs, c.ID)
|
||||||
|
}
|
||||||
|
anchors := CommentAnchors(s.db, commentIDs)
|
||||||
items := make([]AdminAuditCommentItem, 0, len(comments))
|
items := make([]AdminAuditCommentItem, 0, len(comments))
|
||||||
for _, c := range comments {
|
for _, c := range comments {
|
||||||
it := AdminAuditCommentItem{
|
it := AdminAuditCommentItem{
|
||||||
@@ -695,6 +757,10 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
|
|||||||
Status: c.Status,
|
Status: c.Status,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
}
|
}
|
||||||
|
if a, ok := anchors[c.ID]; ok {
|
||||||
|
it.Floor = a.Floor
|
||||||
|
it.IsRoot = a.IsRoot
|
||||||
|
}
|
||||||
if c.DeletedAt.Valid {
|
if c.DeletedAt.Valid {
|
||||||
it.Deleted = true
|
it.Deleted = true
|
||||||
t := c.DeletedAt.Time
|
t := c.DeletedAt.Time
|
||||||
|
|||||||
@@ -107,10 +107,13 @@ var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
|
|||||||
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
|
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
|
||||||
|
|
||||||
// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
|
// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
|
||||||
|
// 邮箱非空时全库唯一(忽略大小写):后期支持邮箱登录,一邮箱只能对应一个账号
|
||||||
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
||||||
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
|
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 邮箱统一小写归一化,避免大小写变体重复注册
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
|
||||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,6 +133,16 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
|
|||||||
if count > 0 {
|
if count > 0 {
|
||||||
return errors.New("用户名已被使用")
|
return errors.New("用户名已被使用")
|
||||||
}
|
}
|
||||||
|
// 邮箱唯一(忽略大小写,兜底防绕过;入口发码处已拦一次)
|
||||||
|
if email != "" {
|
||||||
|
var emailCount int64
|
||||||
|
if err := tx.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&emailCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if emailCount > 0 {
|
||||||
|
return errors.New("该邮箱已被注册")
|
||||||
|
}
|
||||||
|
}
|
||||||
var n int64
|
var n int64
|
||||||
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
|
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -681,14 +694,14 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature stri
|
|||||||
if nickCount > 0 {
|
if nickCount > 0 {
|
||||||
return nil, errors.New("该昵称已被使用")
|
return nil, errors.New("该昵称已被使用")
|
||||||
}
|
}
|
||||||
email = strings.TrimSpace(email)
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
if email != "" {
|
if email != "" {
|
||||||
if !strings.Contains(email, "@") || len(email) > 128 {
|
if !strings.Contains(email, "@") || len(email) > 128 {
|
||||||
return nil, errors.New("邮箱格式不正确")
|
return nil, errors.New("邮箱格式不正确")
|
||||||
}
|
}
|
||||||
// 邮箱唯一性校验(排除自身)
|
// 邮箱唯一性校验(忽略大小写,排除自身),与注册口径一致
|
||||||
var count int64
|
var count int64
|
||||||
s.db.Model(&model.User{}).Where("email = ? AND id <> ?", email, userID).Count(&count)
|
s.db.Model(&model.User{}).Where("LOWER(email) = ? AND id <> ?", email, userID).Count(&count)
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
return nil, errors.New("该邮箱已被使用")
|
return nil, errors.New("该邮箱已被使用")
|
||||||
}
|
}
|
||||||
|
|||||||
223
backend/service/blob_dedup_test.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
webpenc "github.com/gen2brain/webp"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBlobTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.Blob{}, &model.Attachment{}, &model.PostAttachment{},
|
||||||
|
&model.User{}, &model.Post{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func webpFixtureBytes() []byte {
|
||||||
|
src := image.NewRGBA(image.Rect(0, 0, 20, 10))
|
||||||
|
src.Set(0, 0, color.RGBA{10, 20, 30, 255})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = webpenc.Encode(&buf, src, webpenc.Options{Quality: ImageWebPQuality})
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveImageBlobDedupAndRefcount(t *testing.T) {
|
||||||
|
db := newBlobTestDB(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := NewUploadService(db, dir)
|
||||||
|
if err := s.EnsureDir(); err != nil {
|
||||||
|
t.Fatalf("ensure dir: %v", err)
|
||||||
|
}
|
||||||
|
data := webpFixtureBytes()
|
||||||
|
|
||||||
|
att1, err := s.SaveImage(1, bytes.NewReader(data), "post")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveImage 1: %v", err)
|
||||||
|
}
|
||||||
|
att2, err := s.SaveImage(2, bytes.NewReader(data), "post")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveImage 2: %v", err)
|
||||||
|
}
|
||||||
|
if att1.URL != att2.URL || att1.BlobHash == "" || att1.BlobHash != att2.BlobHash {
|
||||||
|
t.Fatalf("identical content should share url/hash: %q vs %q", att1.URL, att2.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var blob model.Blob
|
||||||
|
if err := db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true).Error; err != nil {
|
||||||
|
t.Fatalf("load blob: %v", err)
|
||||||
|
}
|
||||||
|
if blob.RefCount != 2 {
|
||||||
|
t.Fatalf("refcount want 2, got %d", blob.RefCount)
|
||||||
|
}
|
||||||
|
blobPath := filepath.Join(dir, filepath.FromSlash(att1.URL[len("/uploads/"):]))
|
||||||
|
if fi, err := os.Stat(blobPath); err != nil || fi.IsDir() {
|
||||||
|
t.Fatalf("shared physical file missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删一个引用:文件保留
|
||||||
|
if err := s.DeleteAttachment(1, att1.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteAttachment 1: %v", err)
|
||||||
|
}
|
||||||
|
db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true)
|
||||||
|
if blob.RefCount != 1 {
|
||||||
|
t.Fatalf("refcount want 1, got %d", blob.RefCount)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(blobPath); err != nil {
|
||||||
|
t.Fatalf("blob file should survive while referenced: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一个引用删除:文件与 blob 行一起回收
|
||||||
|
if err := s.DeleteAttachment(2, att2.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteAttachment 2: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("blob file should be removed after last release, stat err=%v", err)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att1.BlobHash, true).Count(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("blob row should be gone, count=%d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveImageTranscodedJPEGDedup(t *testing.T) {
|
||||||
|
db := newBlobTestDB(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := NewUploadService(db, dir)
|
||||||
|
if err := s.EnsureDir(); err != nil {
|
||||||
|
t.Fatalf("ensure dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src := image.NewRGBA(image.Rect(0, 0, 48, 36))
|
||||||
|
src.Set(1, 2, color.RGBA{200, 100, 50, 255})
|
||||||
|
var in bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&in, src, nil); err != nil {
|
||||||
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
att1, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveImage 1: %v", err)
|
||||||
|
}
|
||||||
|
att2, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveImage 2: %v", err)
|
||||||
|
}
|
||||||
|
// hash 必须对转码后的字节计算:同源 JPEG 两次上传应命中同一个 WebP blob
|
||||||
|
if att1.URL != att2.URL || att1.BlobHash != att2.BlobHash {
|
||||||
|
t.Fatalf("transcoded JPEG should dedup: %q(%s) vs %q(%s)", att1.URL, att1.BlobHash, att2.URL, att2.BlobHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveDraftFileBlobDedupAndRefcount(t *testing.T) {
|
||||||
|
db := newBlobTestDB(t)
|
||||||
|
root := t.TempDir()
|
||||||
|
s := NewPostFileService(db, root)
|
||||||
|
if err := s.EnsureDir(); err != nil {
|
||||||
|
t.Fatalf("ensure dir: %v", err)
|
||||||
|
}
|
||||||
|
data := []byte("paid attachment content v1")
|
||||||
|
|
||||||
|
a1, err := s.SaveDraftFile(1, "report.pdf", bytes.NewReader(data), 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveDraftFile 1: %v", err)
|
||||||
|
}
|
||||||
|
a2, err := s.SaveDraftFile(2, "another-name.pdf", bytes.NewReader(data), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveDraftFile 2: %v", err)
|
||||||
|
}
|
||||||
|
if a1.BlobHash == "" || a1.BlobHash != a2.BlobHash || a1.StoredName != a2.StoredName {
|
||||||
|
t.Fatalf("same bytes should share blob: %+v vs %+v", a1, a2)
|
||||||
|
}
|
||||||
|
// 原始文件名仍各自保留
|
||||||
|
if a1.Name != "report.pdf" || a2.Name != "another-name.pdf" {
|
||||||
|
t.Fatalf("original names not preserved: %q / %q", a1.Name, a2.Name)
|
||||||
|
}
|
||||||
|
blobPath := s.FilePath(a1)
|
||||||
|
if _, err := os.Stat(blobPath); err != nil {
|
||||||
|
t.Fatalf("shared private blob missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var blob model.Blob
|
||||||
|
if err := db.First(&blob, "hash = ? AND public = ?", a1.BlobHash, false).Error; err != nil {
|
||||||
|
t.Fatalf("load private blob: %v", err)
|
||||||
|
}
|
||||||
|
if blob.RefCount != 2 {
|
||||||
|
t.Fatalf("refcount want 2, got %d", blob.RefCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteOwn(1, a1.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteOwn 1: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(blobPath); err != nil {
|
||||||
|
t.Fatalf("private blob should survive with one ref: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteOwn(2, a2.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteOwn 2: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("private blob should be removed after last release, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobPublicPrivateVisibilityIsolation(t *testing.T) {
|
||||||
|
db := newBlobTestDB(t)
|
||||||
|
root := t.TempDir()
|
||||||
|
img := NewUploadService(db, filepath.Join(root, "uploads"))
|
||||||
|
pf := NewPostFileService(db, filepath.Join(root, "private"))
|
||||||
|
if err := img.EnsureDir(); err != nil {
|
||||||
|
t.Fatalf("ensure upload dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := pf.EnsureDir(); err != nil {
|
||||||
|
t.Fatalf("ensure private dir: %v", err)
|
||||||
|
}
|
||||||
|
data := webpFixtureBytes()
|
||||||
|
|
||||||
|
att, err := img.SaveImage(1, bytes.NewReader(data), "post")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveImage: %v", err)
|
||||||
|
}
|
||||||
|
file, err := pf.SaveDraftFile(1, "x.webp", bytes.NewReader(data), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveDraftFile: %v", err)
|
||||||
|
}
|
||||||
|
if att.BlobHash != file.BlobHash {
|
||||||
|
t.Fatalf("test premise: same bytes should produce same hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同字节在公开/私有命名空间各持一条 blob,互不复用,防止越权读取
|
||||||
|
var pubs, privs int64
|
||||||
|
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, true).Count(&pubs)
|
||||||
|
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, false).Count(&privs)
|
||||||
|
if pubs != 1 || privs != 1 {
|
||||||
|
t.Fatalf("want separate public/private blobs, pubs=%d privs=%d", pubs, privs)
|
||||||
|
}
|
||||||
|
publicPath := filepath.Join(root, "uploads", filepath.FromSlash(att.URL[len("/uploads/"):]))
|
||||||
|
privatePath := pf.FilePath(file)
|
||||||
|
if publicPath == privatePath {
|
||||||
|
t.Fatal("public and private blob must not share a path")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(publicPath); err != nil {
|
||||||
|
t.Fatalf("public blob missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(privatePath); err != nil {
|
||||||
|
t.Fatalf("private blob missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
132
backend/service/blob_store.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 内容寻址存储(content-addressed storage):
|
||||||
|
// 物理文件按【最终存储字节】的 SHA-256 命名,业务行只引用 hash 并各持一份引用计数;
|
||||||
|
// 同内容重复上传不再重复落盘/传输,最后一个引用删除时才回收物理文件。
|
||||||
|
// 公开与私有是独立命名空间(见 model.Blob 注释)。
|
||||||
|
|
||||||
|
// sha256Hex 计算字节的 SHA-256 hex
|
||||||
|
func sha256Hex(data []byte) string {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashFile 计算文件内容的 SHA-256 hex,整文件流式读取不进内存
|
||||||
|
func hashFile(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireBlobRef 登记一次 blob 引用:行不存在则创建(计数=1),已存在则计数+1。
|
||||||
|
// 并发下靠 (hash, public) 主键冲突重试,保证不丢计数。
|
||||||
|
func acquireBlobRef(db *gorm.DB, hash string, public bool, size int, mimeType, objectID string) error {
|
||||||
|
for {
|
||||||
|
var b model.Blob
|
||||||
|
err := db.Where("hash = ? AND public = ?", hash, public).First(&b).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
b = model.Blob{
|
||||||
|
Hash: hash,
|
||||||
|
Public: public,
|
||||||
|
RefCount: 1,
|
||||||
|
Size: size,
|
||||||
|
MIME: mimeType,
|
||||||
|
ObjectID: objectID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&b).Error; err != nil {
|
||||||
|
if isUniqueConflict(err) {
|
||||||
|
continue // 并发首建:改为走计数+1
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.Model(&model.Blob{}).
|
||||||
|
Where("hash = ? AND public = ?", hash, public).
|
||||||
|
UpdateColumn("ref_count", gorm.Expr("ref_count + 1")).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseBlobRef 释放一次 blob 引用。计数归零时删行并返回 last=true 与该行快照
|
||||||
|
// (快照里的 ObjectID/Hash 供调用方回收物理对象)。行不存在视为幂等成功。
|
||||||
|
func releaseBlobRef(db *gorm.DB, hash string, public bool) (model.Blob, bool, error) {
|
||||||
|
var removed model.Blob
|
||||||
|
var last bool
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var b model.Blob
|
||||||
|
// 行锁:postgres 下 FOR UPDATE 串行化同 blob 的并发释放;sqlite 写事务天然串行
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
First(&b, "hash = ? AND public = ?", hash, public).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if b.RefCount <= 1 {
|
||||||
|
result := tx.Where("hash = ? AND public = ? AND ref_count <= 1", hash, public).Delete(&model.Blob{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected > 0 {
|
||||||
|
removed = b
|
||||||
|
last = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tx.Model(&model.Blob{}).
|
||||||
|
Where("hash = ? AND public = ?", hash, public).
|
||||||
|
UpdateColumn("ref_count", b.RefCount-1).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return model.Blob{}, false, err
|
||||||
|
}
|
||||||
|
return removed, last, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeLocalBlob 把临时文件原子放到内容寻址目标路径:
|
||||||
|
// 目标已存在说明同内容已落盘(字节必然一致),直接丢弃源文件;否则整体 rename。
|
||||||
|
// src 与 dst 同一路径时不做操作。
|
||||||
|
func placeLocalBlob(src, dst string) error {
|
||||||
|
if abs, err := filepath.Abs(src); err == nil {
|
||||||
|
if dstAbs, err := filepath.Abs(dst); err == nil && abs == dstAbs {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
if src != dst {
|
||||||
|
_ = os.Remove(src)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.Rename(src, dst); err != nil {
|
||||||
|
_ = os.Remove(src)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
SettingKeyBrandMark = "brand_mark"
|
SettingKeyBrandMark = "brand_mark"
|
||||||
SettingKeyBrandLogoSize = "brand_logo_size"
|
SettingKeyBrandLogoSize = "brand_logo_size"
|
||||||
SettingKeyBrandLogoFit = "brand_logo_fit"
|
SettingKeyBrandLogoFit = "brand_logo_fit"
|
||||||
|
SettingKeyBrandSloganVisible = "brand_slogan_visible"
|
||||||
|
|
||||||
BrandSlotLight = "logo_light"
|
BrandSlotLight = "logo_light"
|
||||||
BrandSlotDark = "logo_dark"
|
BrandSlotDark = "logo_dark"
|
||||||
@@ -43,6 +44,8 @@ const (
|
|||||||
BrandLogoSize3x1 = "3x1"
|
BrandLogoSize3x1 = "3x1"
|
||||||
BrandLogoSize4x1 = "4x1"
|
BrandLogoSize4x1 = "4x1"
|
||||||
BrandLogoSize169 = "16x9"
|
BrandLogoSize169 = "16x9"
|
||||||
|
// BrandLogoSizeAuto 原图比例:自由裁剪,页眉图槽按图片原始比例自适应宽度
|
||||||
|
BrandLogoSizeAuto = "auto"
|
||||||
|
|
||||||
BrandLogoFitCover = "cover"
|
BrandLogoFitCover = "cover"
|
||||||
BrandLogoFitContain = "contain"
|
BrandLogoFitContain = "contain"
|
||||||
@@ -114,7 +117,7 @@ func NormalizeBrandLogoSize(raw string) (string, bool) {
|
|||||||
switch strings.TrimSpace(raw) {
|
switch strings.TrimSpace(raw) {
|
||||||
case "", BrandLogoSizeSQ:
|
case "", BrandLogoSizeSQ:
|
||||||
return BrandLogoSizeSQ, true
|
return BrandLogoSizeSQ, true
|
||||||
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169:
|
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169, BrandLogoSizeAuto:
|
||||||
return strings.TrimSpace(raw), true
|
return strings.TrimSpace(raw), true
|
||||||
default:
|
default:
|
||||||
return "", false
|
return "", false
|
||||||
@@ -367,6 +370,15 @@ func (s *SettingService) fillBrand(out *PublicSiteSettings) error {
|
|||||||
out.BrandLogoFit = f
|
out.BrandLogoFit = f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sv, found, err := s.getValue(SettingKeyBrandSloganVisible)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
out.BrandSloganVisible = sv == "true"
|
||||||
|
} else {
|
||||||
|
out.BrandSloganVisible = true
|
||||||
|
}
|
||||||
rawLinks, found, err := s.getValue(SettingKeyFooterLinks)
|
rawLinks, found, err := s.getValue(SettingKeyFooterLinks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -483,6 +495,14 @@ func (s *SettingService) SetBrandLogoFit(raw string) error {
|
|||||||
return s.putValue(SettingKeyBrandLogoFit, v)
|
return s.putValue(SettingKeyBrandLogoFit, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetBrandSloganVisible 控制标语是否在页眉显示;true 为缺省值,存库时删 key 以保持精简。
|
||||||
|
func (s *SettingService) SetBrandSloganVisible(visible bool) error {
|
||||||
|
if visible {
|
||||||
|
return s.deleteKey(SettingKeyBrandSloganVisible)
|
||||||
|
}
|
||||||
|
return s.putValue(SettingKeyBrandSloganVisible, "false")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) setBrandURL(key, slot, url string) error {
|
func (s *SettingService) setBrandURL(key, slot, url string) error {
|
||||||
u, ok := NormalizeBrandURL(slot, url)
|
u, ok := NormalizeBrandURL(slot, url)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ func (s *ChatService) membership(tx *gorm.DB, roomID, userID uint) (*model.ChatR
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 过期禁言自动解除:惰性清理,不依赖定时任务
|
||||||
|
if m.Muted && m.MutedUntil != nil && m.MutedUntil.Before(time.Now()) {
|
||||||
|
m.Muted = false
|
||||||
|
m.MutedUntil = nil
|
||||||
|
_ = tx.Model(&model.ChatRoomMember{}).
|
||||||
|
Where("room_id = ? AND user_id = ?", roomID, userID).
|
||||||
|
Select("muted", "muted_until").
|
||||||
|
Updates(map[string]interface{}{"muted": false, "muted_until": nil}).Error
|
||||||
|
}
|
||||||
return &m, nil
|
return &m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +131,7 @@ func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, er
|
|||||||
return s.membership(s.db, roomID, userID)
|
return s.membership(s.db, roomID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送)
|
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送与系统提示)
|
||||||
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
||||||
out := make(map[uint]int64)
|
out := make(map[uint]int64)
|
||||||
if userID == 0 || len(roomIDs) == 0 {
|
if userID == 0 || len(roomIDs) == 0 {
|
||||||
@@ -132,7 +141,7 @@ func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
|||||||
s.db.Table("chat_messages AS m").
|
s.db.Table("chat_messages AS m").
|
||||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||||
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs, userID).
|
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", roomIDs, userID, false).
|
||||||
Group("m.room_id").
|
Group("m.room_id").
|
||||||
Scan(&rows)
|
Scan(&rows)
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
@@ -758,44 +767,96 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
|
|||||||
return s.removeMember(roomID, targetID)
|
return s.removeMember(roomID, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号
|
// DisplayName 取用户群聊展示名(昵称优先,退回用户名);用户不存在/封禁时返回空串
|
||||||
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted, oversee bool) error {
|
func (s *ChatService) DisplayName(userID uint) string {
|
||||||
room, err := s.getRoom(roomID)
|
var u model.User
|
||||||
if err != nil {
|
if err := s.db.Select("id", "nickname", "username").First(&u, userID).Error; err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if u.Nickname != "" {
|
||||||
|
return u.Nickname
|
||||||
|
}
|
||||||
|
return u.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true)。
|
||||||
|
// 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览,
|
||||||
|
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。
|
||||||
|
// 失败返回 nil + error。
|
||||||
|
func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) {
|
||||||
|
msg := &model.ChatMessage{
|
||||||
|
RoomID: roomID,
|
||||||
|
SenderID: 0,
|
||||||
|
Content: content,
|
||||||
|
IsSystem: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(msg).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
|
||||||
|
Update("updated_at", time.Now()).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号。
|
||||||
|
// duration 为禁言时长;0 表示永久禁言;muted=false 时忽略 duration 并清空 muted_until。
|
||||||
|
// 返回目标用户昵称(供广播系统提示使用)。
|
||||||
|
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted bool, duration time.Duration, oversee bool) (string, error) {
|
||||||
|
room, err := s.getRoom(roomID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
if room.RoomType == model.ChatRoomTypeDirect {
|
if room.RoomType == model.ChatRoomTypeDirect {
|
||||||
return ErrChatOwnerOnly
|
return "", ErrChatOwnerOnly
|
||||||
}
|
}
|
||||||
target, err := s.membership(s.db, roomID, targetID)
|
target, err := s.membership(s.db, roomID, targetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if target.Role == model.ChatRoleOwner {
|
if target.Role == model.ChatRoleOwner {
|
||||||
return ErrChatCannotMuteOwner
|
return "", ErrChatCannotMuteOwner
|
||||||
}
|
}
|
||||||
var targetUser model.User
|
var targetUser model.User
|
||||||
if err := s.db.Select("role").First(&targetUser, targetID).Error; err != nil {
|
if err := s.db.Select("role", "nickname", "username").First(&targetUser, targetID).Error; err != nil {
|
||||||
return ErrChatUserGone
|
return "", ErrChatUserGone
|
||||||
}
|
}
|
||||||
if targetUser.Role == model.RoleOwner {
|
if targetUser.Role == model.RoleOwner {
|
||||||
return ErrChatCannotMuteOwner
|
return "", ErrChatCannotMuteOwner
|
||||||
}
|
}
|
||||||
if !oversee {
|
if !oversee {
|
||||||
op, err := s.membership(s.db, roomID, operatorID)
|
op, err := s.membership(s.db, roomID, operatorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
||||||
return ErrChatMuteDenied
|
return "", ErrChatMuteDenied
|
||||||
}
|
}
|
||||||
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
|
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
|
||||||
return ErrChatMuteDenied
|
return "", ErrChatMuteDenied
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s.db.Model(&model.ChatRoomMember{}).
|
var mutedUntil *time.Time
|
||||||
|
if muted && duration > 0 {
|
||||||
|
t := time.Now().Add(duration)
|
||||||
|
mutedUntil = &t
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&model.ChatRoomMember{}).
|
||||||
Where("room_id = ? AND user_id = ?", roomID, targetID).
|
Where("room_id = ? AND user_id = ?", roomID, targetID).
|
||||||
Select("muted").Update("muted", muted).Error
|
Select("muted", "muted_until").
|
||||||
|
Updates(map[string]interface{}{"muted": muted, "muted_until": mutedUntil}).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
targetName := targetUser.Nickname
|
||||||
|
if targetName == "" {
|
||||||
|
targetName = targetUser.Username
|
||||||
|
}
|
||||||
|
return targetName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主
|
// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主
|
||||||
@@ -970,7 +1031,7 @@ func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyTo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if err := s.db.Preload("Sender").First(msg, msg.ID).Error; err != nil {
|
if err := s.db.Preload("Sender").Preload("Sender.Badges.Badge").First(msg, msg.ID).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
for _, uid := range mentionIDs {
|
for _, uid := range mentionIDs {
|
||||||
@@ -1059,7 +1120,8 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
|
|||||||
q = q.Where("id < ?", beforeID)
|
q = q.Where("id < ?", beforeID)
|
||||||
}
|
}
|
||||||
var msgs []model.ChatMessage
|
var msgs []model.ChatMessage
|
||||||
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Recaller").Find(&msgs).Error; err != nil {
|
// Sender.Badges.Badge 嵌套预加载:消息气泡需展示发送者等级与徽章(与成员列表一致)
|
||||||
|
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").Find(&msgs).Error; err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
hasMore := len(msgs) > size
|
hasMore := len(msgs) > size
|
||||||
@@ -1088,7 +1150,7 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
|||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
var msg model.ChatMessage
|
var msg model.ChatMessage
|
||||||
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil {
|
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).Preload("Sender").First(&msg).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, false, ErrChatMsgNotFound
|
return nil, false, ErrChatMsgNotFound
|
||||||
}
|
}
|
||||||
@@ -1097,6 +1159,10 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
|||||||
if msg.RecalledAt != nil {
|
if msg.RecalledAt != nil {
|
||||||
return &msg, false, nil // 幂等
|
return &msg, false, nil // 幂等
|
||||||
}
|
}
|
||||||
|
// 站长的消息禁止群主/群管/站点消息管撤回(仅全站监管可撤回)
|
||||||
|
if msg.Sender.Role == model.RoleOwner && !oversee {
|
||||||
|
return nil, false, ErrChatRecallDenied
|
||||||
|
}
|
||||||
if msg.SenderID == operatorID {
|
if msg.SenderID == operatorID {
|
||||||
if !oversee {
|
if !oversee {
|
||||||
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
|
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
|
||||||
@@ -1115,7 +1181,17 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
|||||||
}
|
}
|
||||||
msg.RecalledAt = &now
|
msg.RecalledAt = &now
|
||||||
msg.RecalledBy = operatorID
|
msg.RecalledBy = operatorID
|
||||||
_ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID)
|
// 撤回的是房间当前预览消息时,回退 last_message_id 到最近一条未撤回的非系统消息:
|
||||||
|
// 撤回不在会话列表留下「已撤回」预览,也不让房间因此停留在列表前排,避免被当成新消息提醒。
|
||||||
|
var prevID uint
|
||||||
|
_ = s.db.Model(&model.ChatMessage{}).
|
||||||
|
Where("room_id = ? AND deleted_at IS NULL AND recalled_at IS NULL AND is_system = ?", roomID, false).
|
||||||
|
Order("id DESC").Limit(1).
|
||||||
|
Select("id").Row().Scan(&prevID)
|
||||||
|
_ = s.db.Model(&model.ChatRoom{}).
|
||||||
|
Where("id = ? AND last_message_id = ?", roomID, msg.ID).
|
||||||
|
Update("last_message_id", prevID).Error
|
||||||
|
_ = s.db.Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").First(&msg, msg.ID)
|
||||||
return &msg, true, nil
|
return &msg, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1295,13 +1371,13 @@ func (s *ChatService) MarkRead(userID, roomID, messageID uint) error {
|
|||||||
Update("last_read_message_id", messageID).Error
|
Update("last_read_message_id", messageID).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnreadSummary 当前用户所有群的未读明细与总数
|
// UnreadSummary 当前用户所有群的未读明细与总数(系统提示消息不计入)
|
||||||
func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
|
func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
|
||||||
var rows []UnreadRoom
|
var rows []UnreadRoom
|
||||||
err := s.db.Table("chat_messages AS m").
|
err := s.db.Table("chat_messages AS m").
|
||||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||||
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", userID).
|
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", userID, false).
|
||||||
Group("m.room_id").
|
Group("m.room_id").
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -297,6 +297,80 @@ func (s *CommentService) FloorNumber(postID, commentID uint) int {
|
|||||||
return FloorNumber(s.db, postID, commentID)
|
return FloorNumber(s.db, postID, commentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommentAnchor 评论在所属帖子内的相对锚点:
|
||||||
|
// Floor=楼层号(含软删/待审占位,口径同 FloorNumber);IsRoot=true 为主楼,
|
||||||
|
// false 为楼中楼(锚点需附带评论 ID 才能精确定位具体回复)。
|
||||||
|
type CommentAnchor struct {
|
||||||
|
Floor uint `json:"floor"`
|
||||||
|
IsRoot bool `json:"is_root"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommentAnchors 批量查询评论锚点(两条 SQL,避免逐行 N+1);
|
||||||
|
// 评论或其主楼已不存在(如整串被彻底删除)时不出现在结果中。
|
||||||
|
func CommentAnchors(db *gorm.DB, ids []uint) map[uint]CommentAnchor {
|
||||||
|
out := make(map[uint]CommentAnchor, len(ids))
|
||||||
|
if db == nil || len(ids) == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// Unscoped:软删/待审评论仍占楼层,与 FloorNumber 同口径
|
||||||
|
var targets []model.Comment
|
||||||
|
if err := db.Unscoped().
|
||||||
|
Select("id", "post_id", "parent_id", "root_id", "created_at").
|
||||||
|
Where("id IN ?", ids).Find(&targets).Error; err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
rootIDSet := make(map[uint]struct{}, len(targets))
|
||||||
|
for _, c := range targets {
|
||||||
|
rid := c.ID
|
||||||
|
if c.ParentID != nil && c.RootID != nil {
|
||||||
|
rid = *c.RootID
|
||||||
|
}
|
||||||
|
rootIDSet[rid] = struct{}{}
|
||||||
|
}
|
||||||
|
rootIDs := make([]uint, 0, len(rootIDSet))
|
||||||
|
for rid := range rootIDSet {
|
||||||
|
rootIDs = append(rootIDs, rid)
|
||||||
|
}
|
||||||
|
// 每个主楼之前(按 created_at/id 升序)的主楼数即楼层号 - 1
|
||||||
|
type aheadRow struct {
|
||||||
|
RootID uint
|
||||||
|
Ahead int64
|
||||||
|
}
|
||||||
|
var aheadRows []aheadRow
|
||||||
|
if err := db.Unscoped().Table("comments AS f").
|
||||||
|
Select(`f.id AS root_id,
|
||||||
|
(SELECT COUNT(*) FROM comments c
|
||||||
|
WHERE c.post_id = f.post_id AND c.parent_id IS NULL
|
||||||
|
AND (c.created_at < f.created_at
|
||||||
|
OR (c.created_at = f.created_at AND c.id < f.id))) AS ahead`).
|
||||||
|
Where("f.id IN ? AND f.parent_id IS NULL", rootIDs).
|
||||||
|
Scan(&aheadRows).Error; err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
floorByRoot := make(map[uint]uint, len(aheadRows))
|
||||||
|
for _, r := range aheadRows {
|
||||||
|
floorByRoot[r.RootID] = uint(r.Ahead) + 1
|
||||||
|
}
|
||||||
|
for _, c := range targets {
|
||||||
|
isRoot := c.ParentID == nil
|
||||||
|
rid := c.ID
|
||||||
|
if !isRoot && c.RootID != nil {
|
||||||
|
rid = *c.RootID
|
||||||
|
}
|
||||||
|
floor := floorByRoot[rid]
|
||||||
|
if floor == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[c.ID] = CommentAnchor{Floor: floor, IsRoot: isRoot}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnchorsByCommentIDs 见包级 CommentAnchors
|
||||||
|
func (s *CommentService) AnchorsByCommentIDs(ids []uint) map[uint]CommentAnchor {
|
||||||
|
return CommentAnchors(s.db, ids)
|
||||||
|
}
|
||||||
|
|
||||||
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
|
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
|
||||||
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
|
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
|
||||||
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
|
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
|
||||||
@@ -442,6 +516,8 @@ type UserCommentItem struct {
|
|||||||
PostID uint `json:"post_id"`
|
PostID uint `json:"post_id"`
|
||||||
PostTitle string `json:"post_title"`
|
PostTitle string `json:"post_title"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
Floor uint `json:"floor"` // 所属楼层相对序号(主楼为自身楼层)
|
||||||
|
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前端拼 #comment-{floor}[-r{id}]
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +546,17 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI
|
|||||||
if items == nil {
|
if items == nil {
|
||||||
items = []UserCommentItem{}
|
items = []UserCommentItem{}
|
||||||
}
|
}
|
||||||
|
ids := make([]uint, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
ids = append(ids, it.ID)
|
||||||
|
}
|
||||||
|
anchors := s.AnchorsByCommentIDs(ids)
|
||||||
|
for i := range items {
|
||||||
|
if a, ok := anchors[items[i].ID]; ok {
|
||||||
|
items[i].Floor = a.Floor
|
||||||
|
items[i].IsRoot = a.IsRoot
|
||||||
|
}
|
||||||
|
}
|
||||||
return items, total, nil
|
return items, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
// 空数组 = 全部隐藏
|
// 空数组 = 全部隐藏
|
||||||
|
|||||||
276
backend/service/image_variants.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
webpenc "github.com/gen2brain/webp"
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
xwebp "golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 公开图片变体:GET /api/img?u=<源图URL>&w=<目标宽>
|
||||||
|
// 按宽度白名单实时缩放为 WebP 并持久缓存到 .thumbs/v/,供全站 <img srcset> 消费。
|
||||||
|
// 源内容不可变(上传文件名为随机哈希),变体 URL 可被浏览器/CDN 长期 immutable 缓存。
|
||||||
|
// GIF/SVG/ICO 与「源宽 ≤ 请求宽」一律原样透传:保动效、保矢量、不放大。
|
||||||
|
// 本地源按 mtime 失效;远程对象(/api/media/)ID 即内容,缓存永久有效。
|
||||||
|
|
||||||
|
// VariantWebPQuality 变体 WebP 有损质量(与 MediaThumb 一致)
|
||||||
|
const VariantWebPQuality = 78
|
||||||
|
|
||||||
|
// variantWidths 宽度白名单(与前端 lib/responsiveImage.ts 同值,约 1.5x 步进)
|
||||||
|
var variantWidths = []int{48, 96, 160, 256, 384, 512, 768, 1080, 1536, 1920}
|
||||||
|
|
||||||
|
// IsVariantWidth 是否白名单精确值(非法尺寸 400,防止被刷任意尺寸)
|
||||||
|
func IsVariantWidth(w int) bool {
|
||||||
|
for _, v := range variantWidths {
|
||||||
|
if v == w {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantGenLocks 并发生成去重:key=sha1(url).w → 每变体一把锁
|
||||||
|
var variantGenLocks sync.Map
|
||||||
|
|
||||||
|
// extMIME 扩展名 → MIME(含 SVG/ICO 透传类型)
|
||||||
|
func extMIME(ext string) string {
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml"
|
||||||
|
case ".ico":
|
||||||
|
return "image/x-icon"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantCacheFile 变体缓存路径:.thumbs/v/<sha1(url)>.w<w>.webp
|
||||||
|
func (s *UploadService) variantCacheFile(url string, w int) string {
|
||||||
|
sum := sha1.Sum([]byte(url))
|
||||||
|
name := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(w) + ".webp"
|
||||||
|
return filepath.Join(s.dir, ".thumbs", "v", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantCacheFresh 缓存是否新鲜:本地源按 mtime;远程对象(modTime 零值)只看存在
|
||||||
|
func variantCacheFresh(cache string, modTime time.Time) bool {
|
||||||
|
ti, err := os.Stat(cache)
|
||||||
|
if err != nil || ti.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return modTime.IsZero() || !ti.ModTime().Before(modTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variant 返回源图指定宽度的变体字节与 MIME。
|
||||||
|
func (s *UploadService) Variant(rawURL string, width int) ([]byte, string, error) {
|
||||||
|
rawURL = strings.TrimSpace(rawURL)
|
||||||
|
if len(rawURL) == 0 || len(rawURL) > 512 {
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
if !IsVariantWidth(width) {
|
||||||
|
return nil, "", errors.New("不支持的尺寸")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
srcData []byte // 远程源预读字节(本地源在锁内读取)
|
||||||
|
srcMIME string
|
||||||
|
srcPath string // 本地源绝对路径
|
||||||
|
modTime time.Time
|
||||||
|
remote bool
|
||||||
|
cache = s.variantCacheFile(rawURL, width)
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(rawURL, "/uploads/"):
|
||||||
|
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(rawURL, "/uploads/")))
|
||||||
|
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") {
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(rel)
|
||||||
|
catOK := false
|
||||||
|
for _, cat := range mediaLibraryCategories {
|
||||||
|
if cat.Dir == dir {
|
||||||
|
catOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !catOK {
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(rel))
|
||||||
|
mime := extMIME(ext)
|
||||||
|
if mime == "" {
|
||||||
|
return nil, "", errors.New("非图片")
|
||||||
|
}
|
||||||
|
srcPath = filepath.Join(s.dir, rel)
|
||||||
|
info, err := os.Stat(srcPath)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return nil, "", errors.New("文件不存在")
|
||||||
|
}
|
||||||
|
// 动图/矢量/图标:无法或无需光栅缩放,原样透传(不占变体缓存)
|
||||||
|
if ext == ".gif" || ext == ".svg" || ext == ".ico" {
|
||||||
|
data, err := os.ReadFile(srcPath)
|
||||||
|
return data, mime, err
|
||||||
|
}
|
||||||
|
srcMIME = mime
|
||||||
|
modTime = info.ModTime()
|
||||||
|
|
||||||
|
case strings.HasPrefix(rawURL, "/api/media/"):
|
||||||
|
if s.ops == nil {
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
id := strings.TrimPrefix(rawURL, "/api/media/")
|
||||||
|
if id == "" || strings.ContainsAny(id, "/?#") || len(id) > 128 {
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
remote = true
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
r, mime, e := s.ops.OpenObject(ctx, id, true)
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", errors.New("文件暂不可用")
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
switch mime {
|
||||||
|
case "image/jpeg", "image/png", "image/webp":
|
||||||
|
case "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon":
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
return data, mime, err
|
||||||
|
default:
|
||||||
|
return nil, "", errors.New("非图片")
|
||||||
|
}
|
||||||
|
srcData, e = io.ReadAll(r)
|
||||||
|
if e != nil {
|
||||||
|
return nil, "", e
|
||||||
|
}
|
||||||
|
srcMIME = mime
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, "", errors.New("无效地址")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快速路径:缓存已新鲜直接返回(无锁)
|
||||||
|
if variantCacheFresh(cache, modTime) {
|
||||||
|
if data, err := os.ReadFile(cache); err == nil {
|
||||||
|
return data, "image/webp", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并发生成去重:同 url+w 只生成一次
|
||||||
|
sum := sha1.Sum([]byte(rawURL))
|
||||||
|
key := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(width)
|
||||||
|
mu, _ := variantGenLocks.LoadOrStore(key, &sync.Mutex{})
|
||||||
|
lk := mu.(*sync.Mutex)
|
||||||
|
lk.Lock()
|
||||||
|
defer lk.Unlock()
|
||||||
|
|
||||||
|
// double-check:等锁期间可能已由他人生成
|
||||||
|
if variantCacheFresh(cache, modTime) {
|
||||||
|
if data, err := os.ReadFile(cache); err == nil {
|
||||||
|
return data, "image/webp", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !remote {
|
||||||
|
data, err := os.ReadFile(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
srcData = data
|
||||||
|
}
|
||||||
|
|
||||||
|
out, m, err := variantFromBytes(srcData, srcMIME, width)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err // 如动图 WebP,调用方回退原图
|
||||||
|
}
|
||||||
|
// 仅 WebP 变体积缓存(小图透传不落盘);失败仅影响下次重复生成,不阻断响应
|
||||||
|
if m == "image/webp" {
|
||||||
|
_ = atomicWriteFile(cache, out)
|
||||||
|
}
|
||||||
|
return out, m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// variantFromBytes 源字节 → 目标变体:解码失败报错(调用方回退原图);
|
||||||
|
// 源宽 ≤ 目标宽 → 原字节透传(不放大);否则按目标宽等比缩放为 WebP。
|
||||||
|
func variantFromBytes(srcData []byte, srcMIME string, width int) ([]byte, string, error) {
|
||||||
|
img, err := decodeImageBytes(srcData, srcMIME == "image/webp")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
b := img.Bounds()
|
||||||
|
if b.Dx() <= width {
|
||||||
|
return srcData, srcMIME, nil
|
||||||
|
}
|
||||||
|
nh := max(1, b.Dy()*width/b.Dx())
|
||||||
|
dst := scaleImage(img, width, nh)
|
||||||
|
out, err := encodeLossyWebP(dst, VariantWebPQuality)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return out, "image/webp", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 与 MediaThumb 共用的图片处理核心 ----------
|
||||||
|
|
||||||
|
// decodeImageBytes 解码图片字节;WebP 走 x/image/webp 解码器
|
||||||
|
func decodeImageBytes(data []byte, isWebP bool) (image.Image, error) {
|
||||||
|
if isWebP {
|
||||||
|
return xwebp.Decode(bytes.NewReader(data))
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
return img, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleImage CatmullRom 高质量缩放到指定宽高
|
||||||
|
func scaleImage(img image.Image, nw, nh int) *image.RGBA {
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||||
|
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeLossyWebP 有损 WebP 编码
|
||||||
|
func encodeLossyWebP(img image.Image, quality int) ([]byte, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := webpenc.Encode(&buf, img, webpenc.Options{Quality: quality}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// atomicWriteFile 临时文件 + rename 原子落盘
|
||||||
|
func atomicWriteFile(path string, data []byte) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".partial"
|
||||||
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
282
backend/service/image_variants_test.go
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/config"
|
||||||
|
webpenc "github.com/gen2brain/webp"
|
||||||
|
xwebp "golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// solidImg 生成渐变纯色测试图
|
||||||
|
func solidImg(w, h int) *image.RGBA {
|
||||||
|
src := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
src.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeVariantSource 把按编码器生成的文件写到 uploads 目录
|
||||||
|
func writeVariantSource(t *testing.T, dir, url string, encode func(io.Writer) error) {
|
||||||
|
t.Helper()
|
||||||
|
abs := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
f, err := os.Create(abs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := encode(f); err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeJPEG(img image.Image) func(io.Writer) error {
|
||||||
|
return func(w io.Writer) error {
|
||||||
|
return jpeg.Encode(w, img, &jpeg.Options{Quality: 85})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodePNG(img image.Image) func(io.Writer) error {
|
||||||
|
return func(w io.Writer) error {
|
||||||
|
return png.Encode(w, img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeWebP(img image.Image) func(io.Writer) error {
|
||||||
|
return func(w io.Writer) error {
|
||||||
|
return webpenc.Encode(w, img, webpenc.Options{Quality: 85})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeGIF(img image.Image) func(io.Writer) error {
|
||||||
|
return func(w io.Writer) error {
|
||||||
|
return gif.Encode(w, img, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeVariant 解码返回的 WebP 变体
|
||||||
|
func decodeVariant(t *testing.T, data []byte) image.Image {
|
||||||
|
t.Helper()
|
||||||
|
img, err := xwebp.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode variant webp: %v", err)
|
||||||
|
}
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsVariantWidth(t *testing.T) {
|
||||||
|
if !IsVariantWidth(48) || !IsVariantWidth(1920) {
|
||||||
|
t.Fatal("whitelist endpoints should be valid")
|
||||||
|
}
|
||||||
|
if IsVariantWidth(100) || IsVariantWidth(0) || IsVariantWidth(-48) {
|
||||||
|
t.Fatal("non-whitelist width should be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantRejectsInvalidInput(t *testing.T) {
|
||||||
|
s, _ := newUploadTestService(t)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
w int
|
||||||
|
}{
|
||||||
|
{"非法尺寸", "/uploads/images/a.webp", 100},
|
||||||
|
{"路径穿越", "/uploads/images/../../etc/passwd", 96},
|
||||||
|
{"非白名单目录", "/uploads/secret/a.webp", 96},
|
||||||
|
{"非图片扩展名", "/uploads/images/a.txt", 96},
|
||||||
|
{"外部地址", "https://evil.com/a.webp", 96},
|
||||||
|
{"空地址", "", 96},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if _, _, err := s.Variant(tc.url, tc.w); err == nil {
|
||||||
|
t.Fatal("want error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantMissingFile(t *testing.T) {
|
||||||
|
s, _ := newUploadTestService(t)
|
||||||
|
if _, _, err := s.Variant("/uploads/images/missing.webp", 96); err == nil {
|
||||||
|
t.Fatal("missing source should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantPassthroughSmallImage(t *testing.T) {
|
||||||
|
s, dir := newUploadTestService(t)
|
||||||
|
url := "/uploads/images/small.webp"
|
||||||
|
writeVariantSource(t, dir, url, encodeWebP(solidImg(48, 48)))
|
||||||
|
|
||||||
|
data, m, err := s.Variant(url, 96)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("variant: %v", err)
|
||||||
|
}
|
||||||
|
if m != "image/webp" {
|
||||||
|
t.Fatalf("want webp mime, got %s", m)
|
||||||
|
}
|
||||||
|
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash("images/small.webp")))
|
||||||
|
if !bytes.Equal(data, src) {
|
||||||
|
t.Fatal("small image should be returned byte-for-byte")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantResizesByWidthLandscape(t *testing.T) {
|
||||||
|
s, dir := newUploadTestService(t)
|
||||||
|
url := "/uploads/images/wide.jpg"
|
||||||
|
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
|
||||||
|
|
||||||
|
data, m, err := s.Variant(url, 384)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("variant: %v", err)
|
||||||
|
}
|
||||||
|
if m != "image/webp" {
|
||||||
|
t.Fatalf("want webp, got %s", m)
|
||||||
|
}
|
||||||
|
b := decodeVariant(t, data).Bounds()
|
||||||
|
if b.Dx() != 384 || b.Dy() != 192 {
|
||||||
|
t.Fatalf("want 384x192, got %dx%d", b.Dx(), b.Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantResizesByWidthPortrait(t *testing.T) {
|
||||||
|
s, dir := newUploadTestService(t)
|
||||||
|
url := "/uploads/images/tall.png"
|
||||||
|
writeVariantSource(t, dir, url, encodePNG(solidImg(500, 1000)))
|
||||||
|
|
||||||
|
data, _, err := s.Variant(url, 256)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("variant: %v", err)
|
||||||
|
}
|
||||||
|
b := decodeVariant(t, data).Bounds()
|
||||||
|
if b.Dx() != 256 || b.Dy() != 512 {
|
||||||
|
t.Fatalf("want 256x512, got %dx%d", b.Dx(), b.Dy())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantPassthroughGIFSVGICO(t *testing.T) {
|
||||||
|
s, dir := newUploadTestService(t)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
mime string
|
||||||
|
body []byte
|
||||||
|
}{
|
||||||
|
{"gif", "/uploads/images/a.gif", "image/gif", nil},
|
||||||
|
{"svg", "/uploads/images/a.svg", "image/svg+xml", []byte("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")},
|
||||||
|
{"ico", "/uploads/images/a.ico", "image/x-icon", []byte("\x00\x00\x01\x00")},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if tc.body != nil {
|
||||||
|
writeVariantSource(t, dir, tc.url, func(w io.Writer) error {
|
||||||
|
_, err := w.Write(tc.body)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
writeVariantSource(t, dir, tc.url, encodeGIF(solidImg(16, 16)))
|
||||||
|
}
|
||||||
|
data, m, err := s.Variant(tc.url, 96)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("variant: %v", err)
|
||||||
|
}
|
||||||
|
if m != tc.mime {
|
||||||
|
t.Fatalf("want %s, got %s", tc.mime, m)
|
||||||
|
}
|
||||||
|
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(tc.url, "/uploads/"))))
|
||||||
|
if !bytes.Equal(data, src) {
|
||||||
|
t.Fatalf("%s should pass through unchanged", tc.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantCachedAndReused(t *testing.T) {
|
||||||
|
s, dir := newUploadTestService(t)
|
||||||
|
url := "/uploads/images/cache.jpg"
|
||||||
|
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
|
||||||
|
|
||||||
|
first, m, err := s.Variant(url, 384)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first variant: %v", err)
|
||||||
|
}
|
||||||
|
if m != "image/webp" {
|
||||||
|
t.Fatalf("want webp, got %s", m)
|
||||||
|
}
|
||||||
|
cache := s.variantCacheFile(url, 384)
|
||||||
|
if info, err := os.Stat(cache); err != nil || info.IsDir() {
|
||||||
|
t.Fatalf("cache file should exist: %v", err)
|
||||||
|
}
|
||||||
|
second, _, err := s.Variant(url, 384)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second variant: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(first, second) {
|
||||||
|
t.Fatal("cached variant should be byte-identical")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantCacheFreshness(t *testing.T) {
|
||||||
|
_, dir := newUploadTestService(t)
|
||||||
|
cache := filepath.Join(dir, ".thumbs", "v", "x.webp")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(cache, []byte("data"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 远程对象:modTime 零值,存在即新鲜
|
||||||
|
if !variantCacheFresh(cache, time.Time{}) {
|
||||||
|
t.Fatal("remote cache should be fresh when file exists")
|
||||||
|
}
|
||||||
|
// 本地源:mtime 新于缓存 → 不新鲜
|
||||||
|
future := time.Now().Add(1 * time.Hour)
|
||||||
|
if variantCacheFresh(cache, future) {
|
||||||
|
t.Fatal("cache should be stale vs newer source mtime")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantRemoteRejectsBadID(t *testing.T) {
|
||||||
|
s, _ := newUploadTestService(t)
|
||||||
|
s.ops = NewOperations(s.db, &config.Config{})
|
||||||
|
|
||||||
|
if _, _, err := s.Variant("/api/media/bad/id", 96); err == nil {
|
||||||
|
t.Fatal("id with slash should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantRemoteUnavailable(t *testing.T) {
|
||||||
|
s, _ := newUploadTestService(t)
|
||||||
|
s.ops = NewOperations(s.db, &config.Config{})
|
||||||
|
|
||||||
|
// 表不存在/对象不存在:统一对外“暂不可用”,不泄漏细节
|
||||||
|
if _, _, err := s.Variant("/api/media/deadbeef", 96); err == nil {
|
||||||
|
t.Fatal("missing remote object should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVariantRemoteWithoutOps(t *testing.T) {
|
||||||
|
s, _ := newUploadTestService(t)
|
||||||
|
if _, _, err := s.Variant("/api/media/abc", 96); err == nil {
|
||||||
|
t.Fatal("remote url without ops wired should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -44,7 +46,16 @@ func LibraryExtAllowed(ext string) bool {
|
|||||||
|
|
||||||
// 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields)
|
// 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields)
|
||||||
var libraryDocWriteFields = []string{
|
var libraryDocWriteFields = []string{
|
||||||
"Slug", "Title", "Description", "CoverURL", "Published", "SortOrder", "EntriesAuto",
|
"Slug", "Title", "Description", "CoverURL", "CoverWidth", "CoverHeight",
|
||||||
|
"Published", "SortOrder", "EntriesAuto", "Author", "CreatorID",
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryCreator 条目创建者摘要(仅列表/详情展示所需字段;用户已注销时为 nil)
|
||||||
|
type LibraryCreator struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用)
|
// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用)
|
||||||
@@ -53,17 +64,21 @@ type LibraryDocDetail struct {
|
|||||||
Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型)
|
Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型)
|
||||||
Files []model.LibraryFile `json:"files"`
|
Files []model.LibraryFile `json:"files"`
|
||||||
Sections []model.LibrarySection `json:"sections"`
|
Sections []model.LibrarySection `json:"sections"`
|
||||||
|
Creator *LibraryCreator `json:"creator,omitempty"` // 创建者摘要(0/已注销=nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LibraryDocListItem 公开目录条目(不含 description 全文,含文件概要)
|
// LibraryDocListItem 公开目录条目(description 纯文本截断,含文件概要)
|
||||||
type LibraryDocListItem struct {
|
type LibraryDocListItem struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
FileCount int `json:"file_count"`
|
FileCount int `json:"file_count"`
|
||||||
Files []model.LibraryFile `json:"files"`
|
Files []model.LibraryFile `json:"files"`
|
||||||
|
Creator *LibraryCreator `json:"creator,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -72,8 +87,11 @@ type LibraryDocListItem struct {
|
|||||||
type LibraryInput struct {
|
type LibraryInput struct {
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"` // 书籍作者(自由文本,可空)
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
|
CoverWidth int `json:"cover_width"` // 封面自然宽(0=未知)
|
||||||
|
CoverHeight int `json:"cover_height"` // 封面自然高(0=未知)
|
||||||
Published *bool `json:"published"`
|
Published *bool `json:"published"`
|
||||||
SortOrder *int `json:"sort_order"`
|
SortOrder *int `json:"sort_order"`
|
||||||
EntriesAuto *bool `json:"entries_auto"` // 全章节条目卡模式(nil=不修改)
|
EntriesAuto *bool `json:"entries_auto"` // 全章节条目卡模式(nil=不修改)
|
||||||
@@ -99,6 +117,11 @@ func (in *LibraryInput) normalize() error {
|
|||||||
return errors.New("标题不能超过 200 字")
|
return errors.New("标题不能超过 200 字")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
in.Author = strings.TrimSpace(in.Author)
|
||||||
|
if utf8.RuneCountInString(in.Author) > 100 {
|
||||||
|
return errors.New("作者不能超过 100 字")
|
||||||
|
}
|
||||||
|
|
||||||
in.Description = strings.TrimSpace(in.Description)
|
in.Description = strings.TrimSpace(in.Description)
|
||||||
if utf8.RuneCountInString(in.Description) > libraryDescriptionMax {
|
if utf8.RuneCountInString(in.Description) > libraryDescriptionMax {
|
||||||
return errors.New("介绍不能超过 20000 字")
|
return errors.New("介绍不能超过 20000 字")
|
||||||
@@ -115,8 +138,17 @@ func (in *LibraryInput) normalize() error {
|
|||||||
func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
|
func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
|
||||||
d.Slug = in.Slug
|
d.Slug = in.Slug
|
||||||
d.Title = in.Title
|
d.Title = in.Title
|
||||||
|
d.Author = in.Author
|
||||||
d.Description = in.Description
|
d.Description = in.Description
|
||||||
d.CoverURL = in.CoverURL
|
d.CoverURL = in.CoverURL
|
||||||
|
// 封面清空时一并清除尺寸;有封面但未传尺寸时保留 0(前端回退运行时探测)
|
||||||
|
if in.CoverURL == "" {
|
||||||
|
d.CoverWidth = 0
|
||||||
|
d.CoverHeight = 0
|
||||||
|
} else {
|
||||||
|
d.CoverWidth = in.CoverWidth
|
||||||
|
d.CoverHeight = in.CoverHeight
|
||||||
|
}
|
||||||
if in.Published != nil {
|
if in.Published != nil {
|
||||||
d.Published = *in.Published
|
d.Published = *in.Published
|
||||||
}
|
}
|
||||||
@@ -132,6 +164,7 @@ func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
|
|||||||
type LibraryService struct {
|
type LibraryService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
|
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
|
||||||
|
uploadsDir string // data/uploads:封面回填时解析本地封面图
|
||||||
setting *SettingService
|
setting *SettingService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +178,12 @@ func (s *LibraryService) WithSetting(setting *SettingService) *LibraryService {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithUploadsDir 注入上传根目录(封面回填用:把 /uploads/... 封面 URL 解析为本地文件)
|
||||||
|
func (s *LibraryService) WithUploadsDir(uploadsDir string) *LibraryService {
|
||||||
|
s.uploadsDir = uploadsDir
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LibraryService) EnsureDir() error {
|
func (s *LibraryService) EnsureDir() error {
|
||||||
return os.MkdirAll(s.dir, 0o755)
|
return os.MkdirAll(s.dir, 0o755)
|
||||||
}
|
}
|
||||||
@@ -224,6 +263,7 @@ func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail
|
|||||||
for _, sec := range sections {
|
for _, sec := range sections {
|
||||||
secByDoc[sec.DocID] = append(secByDoc[sec.DocID], sec)
|
secByDoc[sec.DocID] = append(secByDoc[sec.DocID], sec)
|
||||||
}
|
}
|
||||||
|
creatorByID := s.creatorMap(docs)
|
||||||
for _, d := range docs {
|
for _, d := range docs {
|
||||||
fs := byDoc[d.ID]
|
fs := byDoc[d.ID]
|
||||||
if fs == nil {
|
if fs == nil {
|
||||||
@@ -233,12 +273,40 @@ func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail
|
|||||||
if ss == nil {
|
if ss == nil {
|
||||||
ss = []model.LibrarySection{}
|
ss = []model.LibrarySection{}
|
||||||
}
|
}
|
||||||
out = append(out, LibraryDocDetail{LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss})
|
out = append(out, LibraryDocDetail{
|
||||||
|
LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss,
|
||||||
|
Creator: creatorByID[d.CreatorID],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) {
|
// creatorMap 批量取条目创建者摘要(一条 SQL,避免列表 N+1;已注销用户查不到即缺省)
|
||||||
|
func (s *LibraryService) creatorMap(docs []model.LibraryDoc) map[uint]*LibraryCreator {
|
||||||
|
ids := make([]uint, 0, len(docs))
|
||||||
|
seen := map[uint]bool{}
|
||||||
|
for _, d := range docs {
|
||||||
|
if d.CreatorID > 0 && !seen[d.CreatorID] {
|
||||||
|
seen[d.CreatorID] = true
|
||||||
|
ids = append(ids, d.CreatorID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return map[uint]*LibraryCreator{}
|
||||||
|
}
|
||||||
|
var users []model.User
|
||||||
|
if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||||
|
return map[uint]*LibraryCreator{}
|
||||||
|
}
|
||||||
|
m := make(map[uint]*LibraryCreator, len(users))
|
||||||
|
for i := range users {
|
||||||
|
u := &users[i]
|
||||||
|
m[u.ID] = &LibraryCreator{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LibraryService) Create(in *LibraryInput, creatorID ...uint) (*model.LibraryDoc, error) {
|
||||||
if err := in.normalize(); err != nil {
|
if err := in.normalize(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -253,6 +321,9 @@ func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) {
|
|||||||
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
|
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
|
||||||
}
|
}
|
||||||
d := &model.LibraryDoc{Published: false, SortOrder: 0}
|
d := &model.LibraryDoc{Published: false, SortOrder: 0}
|
||||||
|
if len(creatorID) > 0 {
|
||||||
|
d.CreatorID = creatorID[0]
|
||||||
|
}
|
||||||
in.applyTo(d)
|
in.applyTo(d)
|
||||||
if err := s.db.Select(libraryDocWriteFields).Create(d).Error; err != nil {
|
if err := s.db.Select(libraryDocWriteFields).Create(d).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -279,12 +350,15 @@ func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, e
|
|||||||
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
|
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
|
||||||
}
|
}
|
||||||
in.applyTo(&d)
|
in.applyTo(&d)
|
||||||
// map 更新确保 false/空串写入(同 SitePage.Update)
|
// map 更新确保 false/空串写入(同 SitePage.Update);creator_id 创建后不变,不在此列
|
||||||
if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{
|
if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{
|
||||||
"slug": d.Slug,
|
"slug": d.Slug,
|
||||||
"title": d.Title,
|
"title": d.Title,
|
||||||
|
"author": d.Author,
|
||||||
"description": d.Description,
|
"description": d.Description,
|
||||||
"cover_url": d.CoverURL,
|
"cover_url": d.CoverURL,
|
||||||
|
"cover_width": d.CoverWidth,
|
||||||
|
"cover_height": d.CoverHeight,
|
||||||
"published": d.Published,
|
"published": d.Published,
|
||||||
"sort_order": d.SortOrder,
|
"sort_order": d.SortOrder,
|
||||||
"entries_auto": d.EntriesAuto,
|
"entries_auto": d.EntriesAuto,
|
||||||
@@ -294,6 +368,77 @@ func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, e
|
|||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveCoverPath 把 /uploads/... 封面 URL 解析为本地绝对路径;外链返回空串
|
||||||
|
func (s *LibraryService) resolveCoverPath(coverURL string) (string, bool) {
|
||||||
|
rel := strings.TrimPrefix(strings.TrimSpace(coverURL), "/uploads/")
|
||||||
|
if rel == "" || rel == coverURL || strings.Contains(rel, "..") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
abs := filepath.Join(s.uploadsDir, filepath.FromSlash(rel))
|
||||||
|
root := filepath.Clean(s.uploadsDir)
|
||||||
|
clean := filepath.Clean(abs)
|
||||||
|
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return clean, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureCoverReader 从图片流读取自然宽高(复用上传层的格式探测与尺寸解码)
|
||||||
|
func measureCoverReader(r io.Reader) (w, h int, err error) {
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
format, err := detectImageFormat(data)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return decodeImageSizeReader(bytes.NewReader(data), format.mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureCoverDimensions 若条目有封面但未存自然宽高,则测量并写回库(幂等:已有尺寸直接跳过)。
|
||||||
|
// 用于读路径懒回填,避免独立运维接口:首次访问后尺寸即落库,后续 SSR 首帧直接判定横竖版。
|
||||||
|
func (s *LibraryService) ensureCoverDimensions(d *model.LibraryDoc) {
|
||||||
|
if d.CoverURL == "" || (d.CoverWidth > 0 && d.CoverHeight > 0) || s.uploadsDir == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url := strings.TrimSpace(d.CoverURL)
|
||||||
|
var reader io.ReadCloser
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(url, "/uploads/"):
|
||||||
|
abs, ok := s.resolveCoverPath(url)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.Open(abs)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reader = f
|
||||||
|
case strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://"):
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reader = resp.Body
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w, h, err := measureCoverReader(reader)
|
||||||
|
reader.Close()
|
||||||
|
if err != nil || w <= 0 || h <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.db.Model(d).Updates(map[string]interface{}{
|
||||||
|
"cover_width": w,
|
||||||
|
"cover_height": h,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.CoverWidth, d.CoverHeight = w, h
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LibraryService) Delete(id uint) error {
|
func (s *LibraryService) Delete(id uint) error {
|
||||||
result := s.db.Delete(&model.LibraryDoc{}, id)
|
result := s.db.Delete(&model.LibraryDoc{}, id)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -349,8 +494,9 @@ func (s *LibraryService) ListPublished() ([]LibraryDocListItem, error) {
|
|||||||
out := make([]LibraryDocListItem, 0, len(details))
|
out := make([]LibraryDocListItem, 0, len(details))
|
||||||
for _, d := range details {
|
for _, d := range details {
|
||||||
out = append(out, LibraryDocListItem{
|
out = append(out, LibraryDocListItem{
|
||||||
ID: d.ID, Slug: d.Slug, Title: d.Title, CoverURL: d.CoverURL,
|
ID: d.ID, Slug: d.Slug, Title: d.Title, Author: d.Author, Description: d.Description,
|
||||||
SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
|
CoverURL: d.CoverURL, SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
|
||||||
|
Creator: d.Creator,
|
||||||
CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
|
CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -364,6 +510,8 @@ func (s *LibraryService) GetPublishedBySlug(slug string) (*LibraryDocDetail, err
|
|||||||
if err := s.db.Where("slug = ? AND published = ?", slug, true).First(&d).Error; err != nil {
|
if err := s.db.Where("slug = ? AND published = ?", slug, true).First(&d).Error; err != nil {
|
||||||
return nil, ErrLibraryNotFound
|
return nil, ErrLibraryNotFound
|
||||||
}
|
}
|
||||||
|
// 懒回填:有封面但未存尺寸时当场测量并写回(首次访问后即稳定,消除横竖版探测闪烁)
|
||||||
|
s.ensureCoverDimensions(&d)
|
||||||
out := s.attachFiles([]model.LibraryDoc{d})
|
out := s.attachFiles([]model.LibraryDoc{d})
|
||||||
return &out[0], nil
|
return &out[0], nil
|
||||||
}
|
}
|
||||||
|
|||||||
369
backend/service/library_export.go
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
// 书库导出:单本 / 全部条目打包为 ZIP(JSON 清单 + 章节正文 + 附件原文件 + 本地封面),
|
||||||
|
// 用于迁移与备份。清单自描述(format + format_version),为将来的导入功能预留。
|
||||||
|
//
|
||||||
|
// 单本 ZIP 布局:
|
||||||
|
//
|
||||||
|
// book.json 书籍清单(元信息 / 章节树 / 附件清单,路径均相对本目录)
|
||||||
|
// cover.<ext> 本地封面(外链封面不内嵌,仅保留 cover_url)
|
||||||
|
// files/<stored-name> 附件原文件
|
||||||
|
//
|
||||||
|
// 全库 ZIP 布局:
|
||||||
|
//
|
||||||
|
// library.json 索引(每本书的目录位置)
|
||||||
|
// docs/<slug>/book.json 各书清单
|
||||||
|
// docs/<slug>/cover.<ext>
|
||||||
|
// docs/<slug>/files/<stored-name>
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
libraryExportFormat = "jiang13-library" // 全库包
|
||||||
|
libraryExportBookFormat = "jiang13-library-book" // 单本书包
|
||||||
|
libraryExportVersion = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// 可内嵌进导出包的本地封面扩展名(与上传层支持的图片格式保持一致)
|
||||||
|
var libraryExportCoverExts = map[string]struct{}{
|
||||||
|
"jpg": {}, "jpeg": {}, "png": {}, "gif": {}, "webp": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryExportFile 附件清单条目
|
||||||
|
type libraryExportFile struct {
|
||||||
|
Name string `json:"name"` // 原始文件名
|
||||||
|
Stored string `json:"stored,omitempty"` // ZIP 内相对路径(磁盘缺失时为空)
|
||||||
|
Ext string `json:"ext"`
|
||||||
|
MIME string `json:"mime"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
DownloadCount int `json:"download_count"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
Missing bool `json:"missing,omitempty"` // 数据库有记录但磁盘文件已丢失
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryExportSection 章节清单条目(两级树:ParentKey 指向章的 Key)
|
||||||
|
type libraryExportSection struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
ParentKey string `json:"parent_key,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryExportBook 单本书的完整清单
|
||||||
|
type libraryExportBook struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CoverURL string `json:"cover_url"`
|
||||||
|
CoverFile string `json:"cover_file,omitempty"` // 相对本书目录的内嵌封面路径
|
||||||
|
CoverWidth int `json:"cover_width"`
|
||||||
|
CoverHeight int `json:"cover_height"`
|
||||||
|
Published bool `json:"published"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
EntriesAuto bool `json:"entries_auto"`
|
||||||
|
|
||||||
|
Files []libraryExportFile `json:"files"`
|
||||||
|
Sections []libraryExportSection `json:"sections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryExportIndexEntry 全库索引中的单本概要
|
||||||
|
type libraryExportIndexEntry struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Dir string `json:"dir"` // 相对 ZIP 根的目录
|
||||||
|
FileCount int `json:"file_count"`
|
||||||
|
SectionCount int `json:"section_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// libraryExportManifest ZIP 根清单:单本包用 Doc,全库包用 Docs 索引
|
||||||
|
type libraryExportManifest struct {
|
||||||
|
Format string `json:"format"`
|
||||||
|
FormatVersion int `json:"format_version"`
|
||||||
|
ExportedAt string `json:"exported_at"`
|
||||||
|
Doc *libraryExportBook `json:"doc,omitempty"`
|
||||||
|
Docs []libraryExportIndexEntry `json:"docs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// preparedFile 已打开、待流式拷入 ZIP 的附件
|
||||||
|
type preparedFile struct {
|
||||||
|
zipPath string
|
||||||
|
reader *os.File
|
||||||
|
}
|
||||||
|
|
||||||
|
// preparedBook 一本书的导出准备结果(清单 + 已打开的文件句柄)
|
||||||
|
type preparedBook struct {
|
||||||
|
meta libraryExportBook
|
||||||
|
dir string // ZIP 内目录前缀(单本包为空串 = 根目录)
|
||||||
|
coverPath string // ZIP 内封面路径(空 = 不内嵌)
|
||||||
|
coverReader *os.File
|
||||||
|
files []preparedFile // 仅磁盘存在的附件
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *preparedBook) closeReaders() {
|
||||||
|
if b.coverReader != nil {
|
||||||
|
_ = b.coverReader.Close()
|
||||||
|
}
|
||||||
|
for i := range b.files {
|
||||||
|
_ = b.files[i].reader.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryExport 已完成装载、可直接流式写出的导出包
|
||||||
|
type LibraryExport struct {
|
||||||
|
Filename string
|
||||||
|
full bool
|
||||||
|
exportedAt time.Time
|
||||||
|
books []preparedBook
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 释放导出过程中打开的全部文件句柄
|
||||||
|
func (e *LibraryExport) Close() {
|
||||||
|
for i := range e.books {
|
||||||
|
e.books[i].closeReaders()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildBookExport 装载单本书导出(不存在返回 ErrLibraryNotFound)。
|
||||||
|
// 所有失败均发生在写出之前,handler 可安全返回 JSON 错误。
|
||||||
|
func (s *LibraryService) BuildBookExport(id uint) (*LibraryExport, error) {
|
||||||
|
d, err := s.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := s.prepareBook(d, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name := d.Slug
|
||||||
|
if name == "" {
|
||||||
|
name = "book"
|
||||||
|
}
|
||||||
|
return &LibraryExport{
|
||||||
|
Filename: name + ".zip",
|
||||||
|
full: false,
|
||||||
|
exportedAt: time.Now(),
|
||||||
|
books: []preparedBook{*b},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAllExport 装载全库导出(仅在用条目,按 sort_order / id 排序)
|
||||||
|
func (s *LibraryService) BuildAllExport() (*LibraryExport, error) {
|
||||||
|
var docs []model.LibraryDoc
|
||||||
|
if err := s.db.Order("sort_order ASC, id ASC").Find(&docs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
details := s.attachFiles(docs)
|
||||||
|
exp := &LibraryExport{
|
||||||
|
Filename: "jiang13-library-" + time.Now().Format("20060102-150405") + ".zip",
|
||||||
|
full: true,
|
||||||
|
exportedAt: time.Now(),
|
||||||
|
books: make([]preparedBook, 0, len(details)),
|
||||||
|
}
|
||||||
|
for i := range details {
|
||||||
|
// slug 受 [a-z0-9-] 约束,拼接 ZIP 路径无穿越风险;防御性兜底
|
||||||
|
dir := "docs/" + details[i].Slug + "/"
|
||||||
|
if strings.Contains(details[i].Slug, "..") || strings.ContainsAny(details[i].Slug, `/\`) {
|
||||||
|
exp.Close()
|
||||||
|
return nil, errors.New("条目 slug 含非法字符,无法导出")
|
||||||
|
}
|
||||||
|
b, err := s.prepareBook(&details[i], dir)
|
||||||
|
if err != nil {
|
||||||
|
exp.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
exp.books = append(exp.books, *b)
|
||||||
|
}
|
||||||
|
return exp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareBook 组装单本清单并打开全部待打包文件(dir 为 ZIP 内目录前缀)
|
||||||
|
func (s *LibraryService) prepareBook(d *LibraryDocDetail, dir string) (*preparedBook, error) {
|
||||||
|
b := &preparedBook{dir: dir}
|
||||||
|
b.meta = libraryExportBook{
|
||||||
|
ID: d.ID,
|
||||||
|
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
|
||||||
|
Slug: d.Slug,
|
||||||
|
Title: d.Title,
|
||||||
|
Author: d.Author,
|
||||||
|
Description: d.Description,
|
||||||
|
CoverURL: d.CoverURL,
|
||||||
|
CoverWidth: d.CoverWidth,
|
||||||
|
CoverHeight: d.CoverHeight,
|
||||||
|
Published: d.Published,
|
||||||
|
SortOrder: d.SortOrder,
|
||||||
|
EntriesAuto: d.EntriesAuto,
|
||||||
|
Files: make([]libraryExportFile, 0, len(d.Files)),
|
||||||
|
Sections: make([]libraryExportSection, 0, len(d.Sections)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 章节:按数组序号发 Key,ParentKey 引用父章 Key(attachFiles 已按 sort_order, id 排序)
|
||||||
|
keyByID := make(map[uint]string, len(d.Sections))
|
||||||
|
for i, sec := range d.Sections {
|
||||||
|
keyByID[sec.ID] = "s" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
for _, sec := range d.Sections {
|
||||||
|
m := libraryExportSection{
|
||||||
|
Key: keyByID[sec.ID],
|
||||||
|
Title: sec.Title,
|
||||||
|
Content: sec.Content,
|
||||||
|
SortOrder: sec.SortOrder,
|
||||||
|
}
|
||||||
|
if sec.ParentID != nil {
|
||||||
|
m.ParentKey = keyByID[*sec.ParentID]
|
||||||
|
}
|
||||||
|
b.meta.Sections = append(b.meta.Sections, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附件:数据库清单始终保留;磁盘文件存在才打开并记录 ZIP 路径
|
||||||
|
for _, f := range d.Files {
|
||||||
|
m := libraryExportFile{
|
||||||
|
Name: f.Name,
|
||||||
|
Ext: f.Ext,
|
||||||
|
MIME: f.MIME,
|
||||||
|
Size: f.Size,
|
||||||
|
DownloadCount: f.DownloadCount,
|
||||||
|
SortOrder: f.SortOrder,
|
||||||
|
}
|
||||||
|
full := filepath.Join(s.dir, f.StoredName)
|
||||||
|
if r, err := os.Open(full); err == nil {
|
||||||
|
m.Stored = "files/" + f.StoredName
|
||||||
|
b.files = append(b.files, preparedFile{zipPath: dir + m.Stored, reader: r})
|
||||||
|
} else {
|
||||||
|
m.Missing = true
|
||||||
|
}
|
||||||
|
b.meta.Files = append(b.meta.Files, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地封面(/uploads/...):能解析到磁盘文件则内嵌;外链或缺目录时仅保留 URL
|
||||||
|
if rel, ok := s.resolveCoverPath(d.CoverURL); ok {
|
||||||
|
if ext := coverExtOf(d.CoverURL); ext != "" {
|
||||||
|
if r, err := os.Open(rel); err == nil {
|
||||||
|
b.coverReader = r
|
||||||
|
b.coverPath = dir + "cover." + ext
|
||||||
|
b.meta.CoverFile = "cover." + ext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteZip 流式写出 ZIP。调用后文件句柄随之释放;中途的 IO 错误已无法改变 HTTP 状态。
|
||||||
|
func (e *LibraryExport) WriteZip(w io.Writer) error {
|
||||||
|
defer e.Close()
|
||||||
|
zw := zip.NewWriter(w)
|
||||||
|
|
||||||
|
index := make([]libraryExportIndexEntry, 0, len(e.books))
|
||||||
|
for i := range e.books {
|
||||||
|
b := &e.books[i]
|
||||||
|
// 全库包每本书在自己的目录内写 book.json;单本包的根清单即 book.json,在循环外写
|
||||||
|
if e.full {
|
||||||
|
if err := writeZipJSON(zw, b.dir+"book.json", b.meta); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.coverReader != nil {
|
||||||
|
if err := writeZipFile(zw, b.coverPath, b.coverReader); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for j := range b.files {
|
||||||
|
if err := writeZipFile(zw, b.files[j].zipPath, b.files[j].reader); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index = append(index, libraryExportIndexEntry{
|
||||||
|
Slug: b.meta.Slug,
|
||||||
|
Title: b.meta.Title,
|
||||||
|
Dir: b.dir,
|
||||||
|
FileCount: len(b.meta.Files),
|
||||||
|
SectionCount: len(b.meta.Sections),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单本包根清单为 book.json;全库包根清单为 library.json(各书清单在其目录内)
|
||||||
|
if e.full {
|
||||||
|
root := libraryExportManifest{
|
||||||
|
Format: libraryExportFormat,
|
||||||
|
FormatVersion: libraryExportVersion,
|
||||||
|
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
|
||||||
|
Docs: index,
|
||||||
|
}
|
||||||
|
if err := writeZipJSON(zw, "library.json", root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
root := libraryExportManifest{
|
||||||
|
Format: libraryExportBookFormat,
|
||||||
|
FormatVersion: libraryExportVersion,
|
||||||
|
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
|
||||||
|
Doc: &e.books[0].meta,
|
||||||
|
}
|
||||||
|
if err := writeZipJSON(zw, "book.json", root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zw.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverExtOf 从 /uploads/xx.jpg 形式的封面 URL 提取受支持的图片扩展名
|
||||||
|
func coverExtOf(coverURL string) string {
|
||||||
|
u := strings.TrimSpace(coverURL)
|
||||||
|
if i := strings.IndexByte(u, '?'); i >= 0 {
|
||||||
|
u = u[:i]
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(u), "."))
|
||||||
|
if _, ok := libraryExportCoverExts[ext]; !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeZipJSON 写入一个 DEFLATE 压缩的 UTF-8 JSON 条目(关闭 HTML 转义、缩进可读)
|
||||||
|
func writeZipJSON(zw *zip.Writer, name string, v any) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
|
||||||
|
hdr.SetMode(0o644)
|
||||||
|
f, err := zw.CreateHeader(hdr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = f.Write(buf.Bytes())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeZipFile 以 STORE 方式原样写入二进制附件(多为已压缩格式,重复 deflate 仅耗 CPU)
|
||||||
|
func writeZipFile(zw *zip.Writer, name string, r io.Reader) error {
|
||||||
|
hdr := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||||
|
hdr.SetMode(0o644)
|
||||||
|
f, err := zw.CreateHeader(hdr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(f, r)
|
||||||
|
return err
|
||||||
|
}
|
||||||
199
backend/service/library_export_test.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readZipEntry 读取 ZIP 内指定名称的文件内容(不存在返回 nil, false)
|
||||||
|
func readZipEntry(t *testing.T, zr *zip.Reader, name string) ([]byte, bool) {
|
||||||
|
t.Helper()
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if f.Name == name {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open zip entry %s: %v", name, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
data, err := io.ReadAll(rc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read zip entry %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return data, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExportZip(t *testing.T, exp *LibraryExport) *zip.Reader {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := exp.WriteZip(&buf); err != nil {
|
||||||
|
t.Fatalf("write zip: %v", err)
|
||||||
|
}
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read exported zip: %v", err)
|
||||||
|
}
|
||||||
|
return zr
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedBookForExport 造一本含章节(章 + 小节)、附件与本地封面的书籍
|
||||||
|
func seedBookForExport(t *testing.T, s *LibraryService, uploads string) (docID uint, storedName string) {
|
||||||
|
t.Helper()
|
||||||
|
pub := true
|
||||||
|
doc, err := s.Create(&LibraryInput{Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create doc: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
coverDir := filepath.Join(uploads, "images")
|
||||||
|
if err := os.MkdirAll(coverDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir uploads: %v", err)
|
||||||
|
}
|
||||||
|
coverBytes := encodeJPEGBytes(t, 120, 160)
|
||||||
|
if err := os.WriteFile(filepath.Join(coverDir, "cover.jpg"), coverBytes, 0o644); err != nil {
|
||||||
|
t.Fatalf("write cover: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.Update(doc.ID, &LibraryInput{
|
||||||
|
Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍",
|
||||||
|
CoverURL: "/uploads/images/cover.jpg", CoverWidth: 120, CoverHeight: 160,
|
||||||
|
Published: &pub,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update cover: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chapter, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章", Content: "章正文"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create chapter: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateSection(doc.ID, &SectionInput{
|
||||||
|
ParentID: &chapter.ID, Title: "小节", Content: "小节正文",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create section: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := s.AddFile(doc.ID, 1, "manual.epub", bytes.NewReader([]byte("EPUB-FILE-BYTES")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add file: %v", err)
|
||||||
|
}
|
||||||
|
return doc.ID, f.StoredName
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryExportBook(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
uploads := t.TempDir()
|
||||||
|
s.WithUploadsDir(uploads)
|
||||||
|
docID, storedName := seedBookForExport(t, s, uploads)
|
||||||
|
|
||||||
|
exp, err := s.BuildBookExport(docID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build export: %v", err)
|
||||||
|
}
|
||||||
|
defer exp.Close()
|
||||||
|
if exp.Filename != "dao-jia.zip" {
|
||||||
|
t.Fatalf("filename = %q, want dao-jia.zip", exp.Filename)
|
||||||
|
}
|
||||||
|
zr := buildExportZip(t, exp)
|
||||||
|
|
||||||
|
raw, ok := readZipEntry(t, zr, "book.json")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("缺少 book.json")
|
||||||
|
}
|
||||||
|
var manifest libraryExportManifest
|
||||||
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
|
t.Fatalf("parse book.json: %v", err)
|
||||||
|
}
|
||||||
|
if manifest.Format != libraryExportBookFormat || manifest.FormatVersion != libraryExportVersion {
|
||||||
|
t.Fatalf("清单格式异常: %s v%d", manifest.Format, manifest.FormatVersion)
|
||||||
|
}
|
||||||
|
b := manifest.Doc
|
||||||
|
if b == nil {
|
||||||
|
t.Fatal("book.json 缺少 doc")
|
||||||
|
}
|
||||||
|
if b.Title != "道家研究" || b.Slug != "dao-jia" || b.Author != "老子" || !b.Published {
|
||||||
|
t.Fatalf("元信息导出不正确: %+v", b)
|
||||||
|
}
|
||||||
|
if b.CoverFile != "cover.jpg" || b.CoverWidth != 120 || b.CoverHeight != 160 {
|
||||||
|
t.Fatalf("封面信息导出不正确: %+v", b)
|
||||||
|
}
|
||||||
|
if len(b.Sections) != 2 || b.Sections[0].Key != "s0" || b.Sections[1].ParentKey != "s0" {
|
||||||
|
t.Fatalf("章节树导出不正确: %+v", b.Sections)
|
||||||
|
}
|
||||||
|
if len(b.Files) != 1 || b.Files[0].Stored != "files/"+storedName || b.Files[0].Missing {
|
||||||
|
t.Fatalf("附件清单导出不正确: %+v", b.Files)
|
||||||
|
}
|
||||||
|
|
||||||
|
if data, ok := readZipEntry(t, zr, "files/"+storedName); !ok || string(data) != "EPUB-FILE-BYTES" {
|
||||||
|
t.Fatalf("附件内容导出不正确(ok=%v)", ok)
|
||||||
|
}
|
||||||
|
if data, ok := readZipEntry(t, zr, "cover.jpg"); !ok || !bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}) {
|
||||||
|
t.Fatalf("封面内容导出不正确(ok=%v)", ok)
|
||||||
|
}
|
||||||
|
// 单本包不应出现全库索引
|
||||||
|
if _, ok := readZipEntry(t, zr, "library.json"); ok {
|
||||||
|
t.Fatal("单本包不应包含 library.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryExportAll(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
uploads := t.TempDir()
|
||||||
|
s.WithUploadsDir(uploads)
|
||||||
|
docID, storedName := seedBookForExport(t, s, uploads)
|
||||||
|
_ = docID
|
||||||
|
|
||||||
|
exp, err := s.BuildAllExport()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build all export: %v", err)
|
||||||
|
}
|
||||||
|
defer exp.Close()
|
||||||
|
zr := buildExportZip(t, exp)
|
||||||
|
|
||||||
|
raw, ok := readZipEntry(t, zr, "library.json")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("缺少 library.json")
|
||||||
|
}
|
||||||
|
var manifest libraryExportManifest
|
||||||
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
|
t.Fatalf("parse library.json: %v", err)
|
||||||
|
}
|
||||||
|
if manifest.Format != libraryExportFormat || len(manifest.Docs) != 1 {
|
||||||
|
t.Fatalf("全库索引异常: %+v", manifest)
|
||||||
|
}
|
||||||
|
idx := manifest.Docs[0]
|
||||||
|
if idx.Dir != "docs/dao-jia/" || idx.FileCount != 1 || idx.SectionCount != 2 {
|
||||||
|
t.Fatalf("索引条目异常: %+v", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
bookRaw, ok := readZipEntry(t, zr, "docs/dao-jia/book.json")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("缺少 docs/dao-jia/book.json")
|
||||||
|
}
|
||||||
|
var book libraryExportBook
|
||||||
|
if err := json.Unmarshal(bookRaw, &book); err != nil {
|
||||||
|
t.Fatalf("parse nested book.json: %v", err)
|
||||||
|
}
|
||||||
|
if book.Slug != "dao-jia" || book.CoverFile != "cover.jpg" {
|
||||||
|
t.Fatalf("嵌套书籍清单异常: %+v", book)
|
||||||
|
}
|
||||||
|
if _, ok := readZipEntry(t, zr, "docs/dao-jia/files/"+storedName); !ok {
|
||||||
|
t.Fatal("缺少嵌套附件")
|
||||||
|
}
|
||||||
|
if _, ok := readZipEntry(t, zr, "docs/dao-jia/cover.jpg"); !ok {
|
||||||
|
t.Fatal("缺少嵌套封面")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryExportMissingDoc(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
if _, err := s.BuildBookExport(999); !errors.Is(err, ErrLibraryNotFound) {
|
||||||
|
t.Fatalf("不存在的条目应返回 ErrLibraryNotFound,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
631
backend/service/library_import.go
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
// 书库导入恢复:解析导出 ZIP(见 library_export.go),在本站重建书籍。
|
||||||
|
//
|
||||||
|
// - 单本包(根 book.json,format=jiang13-library-book)与全库包(library.json 索引)
|
||||||
|
// - mode=create(默认):同 slug 在用条目冲突时跳过该书;软删占用同样拒绝(释放后再来)
|
||||||
|
// - mode=overwrite:覆盖同 slug 在用条目(章节/附件整体替换,元信息按包恢复)
|
||||||
|
// - 附件复用 AddFile(扩展名白名单/大小/20 个上限/随机落盘),磁盘名重新生成但保留下载计数
|
||||||
|
// - 内嵌封面按魔数校验后随机名落盘 uploads/images;外链封面仅保留 URL
|
||||||
|
// - 逐本独立处理:单本书失败不影响包内其他书,结果以报告返回(包本身非法才整体报错)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LibraryImportMaxBytes 导入 ZIP 上传上限(超出由 handler 提前拦截)
|
||||||
|
const LibraryImportMaxBytes = 512 << 20
|
||||||
|
|
||||||
|
const (
|
||||||
|
LibraryImportModeCreate = "create" // 冲突跳过
|
||||||
|
LibraryImportModeOverwrite = "overwrite" // 冲突覆盖
|
||||||
|
)
|
||||||
|
|
||||||
|
// 失败原因(前端据此区分冲突,可提示覆盖重导)
|
||||||
|
const (
|
||||||
|
libraryImportReasonConflict = "conflict" // slug 被在用条目占用(create 模式)
|
||||||
|
libraryImportReasonDeleted = "deleted" // slug 被已软删条目占用
|
||||||
|
libraryImportReasonInvalid = "invalid" // 清单数据非法
|
||||||
|
libraryImportReasonFailed = "failed" // 落盘/写库失败
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrLibraryImportBadZip = errors.New("无法读取导入文件,请上传书库导出的 ZIP 备份包")
|
||||||
|
ErrLibraryImportFormat = errors.New("不是有效的书库导出包:缺少清单文件或清单已损坏")
|
||||||
|
ErrLibraryImportVer = errors.New("导出版本不受支持")
|
||||||
|
ErrLibraryImportEmpty = errors.New("备份包内没有可导入的书籍")
|
||||||
|
ErrLibraryImportMode = errors.New("无效的导入模式")
|
||||||
|
)
|
||||||
|
|
||||||
|
// LibraryImportItem 成功导入的单本结果
|
||||||
|
type LibraryImportItem struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Action string `json:"action"` // created / overwritten
|
||||||
|
Sections int `json:"sections"`
|
||||||
|
Files int `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryImportFailure 单本书失败明细
|
||||||
|
type LibraryImportFailure struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryImportReport 导入报告
|
||||||
|
type LibraryImportReport struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Imported []LibraryImportItem `json:"imported"`
|
||||||
|
Failed []LibraryImportFailure `json:"failed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// bookImportPlan 单本书的导入计划(清单 + ZIP 内目录前缀)
|
||||||
|
type bookImportPlan struct {
|
||||||
|
book libraryExportBook
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// plannedSection 重建后的章节(保留原始 key 用于两级映射;sort 为同层级序号)
|
||||||
|
type plannedSection struct {
|
||||||
|
key string
|
||||||
|
parentKey string
|
||||||
|
title string
|
||||||
|
content string
|
||||||
|
sort int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportLibraryZip 从导出 ZIP 恢复书籍。zipPath 为已落盘的临时文件路径。
|
||||||
|
func (s *LibraryService) ImportLibraryZip(zipPath, mode string, userID uint) (*LibraryImportReport, error) {
|
||||||
|
if mode != LibraryImportModeCreate && mode != LibraryImportModeOverwrite {
|
||||||
|
return nil, ErrLibraryImportMode
|
||||||
|
}
|
||||||
|
zr, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrLibraryImportBadZip
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
|
||||||
|
entries := make(map[string]*zip.File, len(zr.File))
|
||||||
|
for _, f := range zr.File {
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries[strings.ReplaceAll(f.Name, "\\", "/")] = f // 正常包无重名
|
||||||
|
}
|
||||||
|
|
||||||
|
plans, err := parseImportPlans(entries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(plans) == 0 {
|
||||||
|
return nil, ErrLibraryImportEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
rep := &LibraryImportReport{
|
||||||
|
Mode: mode,
|
||||||
|
Total: len(plans),
|
||||||
|
Imported: []LibraryImportItem{},
|
||||||
|
Failed: []LibraryImportFailure{},
|
||||||
|
}
|
||||||
|
imp := &bookImporter{s: s, entries: entries, userID: userID}
|
||||||
|
for _, p := range plans {
|
||||||
|
item, fail := imp.run(p, mode)
|
||||||
|
if fail != nil {
|
||||||
|
rep.Failed = append(rep.Failed, *fail)
|
||||||
|
} else {
|
||||||
|
rep.Imported = append(rep.Imported, *item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseImportPlans 识别单本/全库包并解析书籍清单(不读正文/附件)
|
||||||
|
func parseImportPlans(entries map[string]*zip.File) ([]bookImportPlan, error) {
|
||||||
|
if rootRaw, ok := entries["library.json"]; ok {
|
||||||
|
var root libraryExportManifest
|
||||||
|
if err := readZipJSON(rootRaw, &root); err != nil {
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
if root.Format != libraryExportFormat || root.FormatVersion != libraryExportVersion {
|
||||||
|
return nil, ErrLibraryImportVer
|
||||||
|
}
|
||||||
|
plans := make([]bookImportPlan, 0, len(root.Docs))
|
||||||
|
for _, d := range root.Docs {
|
||||||
|
dir := strings.ReplaceAll(d.Dir, "\\", "/")
|
||||||
|
if !safeImportDir(dir) {
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
raw, ok := entries[path.Clean(dir)+"/book.json"]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
var b libraryExportBook
|
||||||
|
if err := readZipJSON(raw, &b); err != nil {
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
plans = append(plans, bookImportPlan{book: b, dir: dir})
|
||||||
|
}
|
||||||
|
return plans, nil
|
||||||
|
}
|
||||||
|
if rootRaw, ok := entries["book.json"]; ok {
|
||||||
|
var root libraryExportManifest
|
||||||
|
if err := readZipJSON(rootRaw, &root); err != nil {
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
if root.Format != libraryExportBookFormat || root.FormatVersion != libraryExportVersion || root.Doc == nil {
|
||||||
|
return nil, ErrLibraryImportVer
|
||||||
|
}
|
||||||
|
return []bookImportPlan{{book: *root.Doc, dir: ""}}, nil
|
||||||
|
}
|
||||||
|
return nil, ErrLibraryImportFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeImportDir 全库包内目录必须是 docs/<slug>/ 形态(slug 与条目同规则)
|
||||||
|
func safeImportDir(dir string) bool {
|
||||||
|
if dir == "" || !strings.HasPrefix(dir, "docs/") || !strings.HasSuffix(dir, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
slug := strings.TrimSuffix(strings.TrimPrefix(dir, "docs/"), "/")
|
||||||
|
if strings.Contains(slug, "/") || strings.Contains(slug, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return sitePageSlugRe.MatchString(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bookImporter 携带一次批量导入的共享上下文
|
||||||
|
type bookImporter struct {
|
||||||
|
s *LibraryService
|
||||||
|
entries map[string]*zip.File
|
||||||
|
userID uint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (imp *bookImporter) fail(p bookImportPlan, reason, msg string) *LibraryImportFailure {
|
||||||
|
return &LibraryImportFailure{Slug: p.book.Slug, Title: p.book.Title, Reason: reason, Error: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run 导入单本书;返回 item 或 failure(二者互斥)
|
||||||
|
func (imp *bookImporter) run(p bookImportPlan, mode string) (*LibraryImportItem, *LibraryImportFailure) {
|
||||||
|
b := p.book
|
||||||
|
|
||||||
|
in, err := buildImportInput(&b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||||
|
}
|
||||||
|
sections, err := buildImportSections(&b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||||
|
}
|
||||||
|
if err := imp.checkPlannedFiles(p); err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||||
|
}
|
||||||
|
coverEntry, err := imp.planCover(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
active, deleted, err := imp.s.slugTaken(b.Slug, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "查询条目失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var docID uint
|
||||||
|
action := "created"
|
||||||
|
var oldFiles []model.LibraryFile // overwrite 时待删磁盘附件
|
||||||
|
var oldCoverDisk string // overwrite 时待删旧封面(仅本地 /uploads)
|
||||||
|
backfillCreator := false // overwrite 且原条目无创建者时补记导入操作人
|
||||||
|
switch {
|
||||||
|
case active && mode != LibraryImportModeOverwrite:
|
||||||
|
return nil, imp.fail(p, libraryImportReasonConflict, "slug 已被在用条目占用:"+b.Slug)
|
||||||
|
case active:
|
||||||
|
var existing model.LibraryDoc
|
||||||
|
if err := imp.s.db.Where("slug = ?", b.Slug).First(&existing).Error; err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "读取原条目失败")
|
||||||
|
}
|
||||||
|
docID = existing.ID
|
||||||
|
// 跨站恢复不导出创建者:原条目无创建者时把本次导入操作人补为创建者
|
||||||
|
backfillCreator = existing.CreatorID == 0
|
||||||
|
_ = imp.s.db.Where("doc_id = ?", docID).Find(&oldFiles).Error
|
||||||
|
if disk, ok := imp.s.resolveCoverPath(existing.CoverURL); ok {
|
||||||
|
oldCoverDisk = disk
|
||||||
|
}
|
||||||
|
action = "overwritten"
|
||||||
|
case deleted:
|
||||||
|
return nil, imp.fail(p, libraryImportReasonDeleted,
|
||||||
|
"该地址被已删除条目占用,请先在书库管理中彻底删除后再导入:"+b.Slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内嵌封面先落盘(URL 在事务前确定;create 失败时随条目回滚删除)
|
||||||
|
coverDisk := ""
|
||||||
|
if coverEntry != nil {
|
||||||
|
url, w, h, disk, ferr := imp.materializeCover(coverEntry)
|
||||||
|
if ferr != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "封面恢复失败:"+ferr.Error())
|
||||||
|
}
|
||||||
|
in.CoverURL, in.CoverWidth, in.CoverHeight, coverDisk = url, w, h, disk
|
||||||
|
}
|
||||||
|
// 无内嵌封面时保留清单原值(外链或原站 /uploads URL)与尺寸
|
||||||
|
|
||||||
|
txErr := imp.s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if action == "created" {
|
||||||
|
d := &model.LibraryDoc{Published: false, SortOrder: 0, CreatorID: imp.userID}
|
||||||
|
in.applyTo(d)
|
||||||
|
if err := tx.Select(libraryDocWriteFields).Create(d).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
docID = d.ID
|
||||||
|
} else {
|
||||||
|
var d model.LibraryDoc
|
||||||
|
if err := tx.First(&d, docID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.applyTo(&d)
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"slug": d.Slug,
|
||||||
|
"title": d.Title,
|
||||||
|
"author": d.Author,
|
||||||
|
"description": d.Description,
|
||||||
|
"cover_url": d.CoverURL,
|
||||||
|
"cover_width": d.CoverWidth,
|
||||||
|
"cover_height": d.CoverHeight,
|
||||||
|
"published": d.Published,
|
||||||
|
"sort_order": d.SortOrder,
|
||||||
|
"entries_auto": d.EntriesAuto,
|
||||||
|
}
|
||||||
|
if backfillCreator {
|
||||||
|
updates["creator_id"] = imp.userID
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(updates).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return imp.createSections(tx, docID, sections)
|
||||||
|
})
|
||||||
|
if txErr != nil {
|
||||||
|
if action == "created" && coverDisk != "" {
|
||||||
|
_ = os.Remove(coverDisk)
|
||||||
|
}
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "写入数据库失败:"+txErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// overwrite 事务成功后清理旧附件与旧封面磁盘文件(失败不影响结果)
|
||||||
|
for i := range oldFiles {
|
||||||
|
_ = os.Remove(imp.s.FilePath(&oldFiles[i]))
|
||||||
|
}
|
||||||
|
if oldCoverDisk != "" && oldCoverDisk != coverDisk {
|
||||||
|
_ = os.Remove(oldCoverDisk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附件落盘。create 阶段失败需硬删新建条目以释放 slug;overwrite 仅报告失败
|
||||||
|
added := 0
|
||||||
|
var newFiles []model.LibraryFile
|
||||||
|
for _, mf := range b.Files {
|
||||||
|
if mf.Missing || mf.Stored == "" {
|
||||||
|
continue // 导出时磁盘已丢失的附件:清单保留记录但无文件可恢复
|
||||||
|
}
|
||||||
|
f, fail := imp.addPlannedFile(p, docID, mf)
|
||||||
|
if fail != nil {
|
||||||
|
if action == "created" {
|
||||||
|
imp.rollbackCreated(docID, newFiles, coverDisk)
|
||||||
|
}
|
||||||
|
return nil, fail
|
||||||
|
}
|
||||||
|
newFiles = append(newFiles, *f)
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LibraryImportItem{
|
||||||
|
Slug: b.Slug,
|
||||||
|
Title: b.Title,
|
||||||
|
Action: action,
|
||||||
|
Sections: len(sections),
|
||||||
|
Files: added,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 清单解析与预检 ----------
|
||||||
|
|
||||||
|
func buildImportInput(b *libraryExportBook) (*LibraryInput, error) {
|
||||||
|
pub, sortOrder, entriesAuto := b.Published, b.SortOrder, b.EntriesAuto
|
||||||
|
in := &LibraryInput{
|
||||||
|
Slug: b.Slug,
|
||||||
|
Title: b.Title,
|
||||||
|
Author: b.Author,
|
||||||
|
Description: b.Description,
|
||||||
|
CoverURL: b.CoverURL,
|
||||||
|
CoverWidth: b.CoverWidth,
|
||||||
|
CoverHeight: b.CoverHeight,
|
||||||
|
Published: &pub,
|
||||||
|
SortOrder: &sortOrder,
|
||||||
|
EntriesAuto: &entriesAuto,
|
||||||
|
}
|
||||||
|
if err := in.normalize(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return in, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildImportSections 两轮解析:先章(编号)后节(按父分组编号),校验标题/正文/父引用
|
||||||
|
func buildImportSections(b *libraryExportBook) ([]plannedSection, error) {
|
||||||
|
if len(b.Sections) > MaxSectionsPerDoc {
|
||||||
|
return nil, errors.New("章节数量超过上限(最多 200)")
|
||||||
|
}
|
||||||
|
keySeen := map[string]bool{}
|
||||||
|
out := make([]plannedSection, 0, len(b.Sections))
|
||||||
|
|
||||||
|
chapterSort := map[string]int{}
|
||||||
|
order := 0
|
||||||
|
for _, sec := range b.Sections {
|
||||||
|
if sec.ParentKey != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(sec.Title)
|
||||||
|
if title == "" {
|
||||||
|
return nil, errors.New("存在标题为空的章节")
|
||||||
|
}
|
||||||
|
if err := validateSectionText(title, sec.Content); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if keySeen[sec.Key] {
|
||||||
|
return nil, errors.New("章节标识重复:" + sec.Key)
|
||||||
|
}
|
||||||
|
keySeen[sec.Key] = true
|
||||||
|
chapterSort[sec.Key] = order
|
||||||
|
out = append(out, plannedSection{
|
||||||
|
key: sec.Key, title: title, content: sec.Content, sort: order,
|
||||||
|
})
|
||||||
|
order++
|
||||||
|
}
|
||||||
|
|
||||||
|
childCount := map[string]int{}
|
||||||
|
for _, sec := range b.Sections {
|
||||||
|
if sec.ParentKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := chapterSort[sec.ParentKey]; !ok {
|
||||||
|
return nil, errors.New("小节「" + strings.TrimSpace(sec.Title) + "」找不到所属章节")
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(sec.Title)
|
||||||
|
if title == "" {
|
||||||
|
return nil, errors.New("存在标题为空的小节")
|
||||||
|
}
|
||||||
|
if err := validateSectionText(title, sec.Content); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, plannedSection{
|
||||||
|
key: sec.Key,
|
||||||
|
parentKey: sec.ParentKey,
|
||||||
|
title: title,
|
||||||
|
content: sec.Content,
|
||||||
|
sort: childCount[sec.ParentKey],
|
||||||
|
})
|
||||||
|
childCount[sec.ParentKey]++
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSectionText(title, content string) error {
|
||||||
|
if len([]rune(title)) > 200 {
|
||||||
|
return errors.New("章节标题不能超过 200 字:" + title)
|
||||||
|
}
|
||||||
|
if len([]rune(content)) > MaxSectionContent {
|
||||||
|
return errors.New("章节正文不能超过 100000 字:" + title)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPlannedFiles 预检附件:数量、扩展名、ZIP 路径、声明大小、包内是否存在
|
||||||
|
func (imp *bookImporter) checkPlannedFiles(p bookImportPlan) error {
|
||||||
|
if len(p.book.Files) > MaxLibraryFilesPerDoc {
|
||||||
|
return errors.New("附件数量超过上限(最多 20 个)")
|
||||||
|
}
|
||||||
|
maxB := imp.s.maxBytes()
|
||||||
|
for _, f := range p.book.Files {
|
||||||
|
if f.Missing || f.Stored == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel, ok := cleanZipRel(f.Stored)
|
||||||
|
if !ok || !strings.HasPrefix(rel, "files/") {
|
||||||
|
return errors.New("附件路径非法:" + f.Stored)
|
||||||
|
}
|
||||||
|
name := sanitizeFilename(f.Name)
|
||||||
|
if name == "" || !LibraryExtAllowed(ExtOfFilename(name)) {
|
||||||
|
return errors.New("附件格式不受支持:" + f.Name)
|
||||||
|
}
|
||||||
|
zf, ok := imp.entries[p.dir+rel]
|
||||||
|
if !ok {
|
||||||
|
return errors.New("备份包缺少附件文件:" + f.Name)
|
||||||
|
}
|
||||||
|
if zf.UncompressedSize64 > uint64(maxB) {
|
||||||
|
return errors.New("附件超过大小上限:" + f.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// planCover 返回内嵌封面 ZIP 条目(无则 nil)
|
||||||
|
func (imp *bookImporter) planCover(p bookImportPlan) (*zip.File, error) {
|
||||||
|
cf := strings.TrimSpace(p.book.CoverFile)
|
||||||
|
if cf == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rel, ok := cleanZipRel(cf)
|
||||||
|
if !ok || strings.Contains(rel, "/") {
|
||||||
|
return nil, errors.New("封面路径非法:" + cf)
|
||||||
|
}
|
||||||
|
zf, ok := imp.entries[p.dir+rel]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("备份包缺少封面文件")
|
||||||
|
}
|
||||||
|
if zf.UncompressedSize64 > uint64(ImageMaxBytes) {
|
||||||
|
return nil, errors.New("封面不能超过 5MB")
|
||||||
|
}
|
||||||
|
return zf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 落盘 ----------
|
||||||
|
|
||||||
|
// createSections 两趟写入:先建章(key→新ID),再建节(父引用映射后的章 ID)
|
||||||
|
func (imp *bookImporter) createSections(tx *gorm.DB, docID uint, sections []plannedSection) error {
|
||||||
|
keyToID := make(map[string]uint, len(sections))
|
||||||
|
for i := range sections {
|
||||||
|
ps := §ions[i]
|
||||||
|
if ps.parentKey != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sec := &model.LibrarySection{DocID: docID, Title: ps.title, Content: ps.content, SortOrder: ps.sort}
|
||||||
|
if err := tx.Create(sec).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyToID[ps.key] = sec.ID
|
||||||
|
}
|
||||||
|
for i := range sections {
|
||||||
|
ps := §ions[i]
|
||||||
|
if ps.parentKey == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parentID, ok := keyToID[ps.parentKey]
|
||||||
|
if !ok {
|
||||||
|
return errors.New("小节找不到所属章节")
|
||||||
|
}
|
||||||
|
sec := &model.LibrarySection{
|
||||||
|
DocID: docID, ParentID: &parentID,
|
||||||
|
Title: ps.title, Content: ps.content, SortOrder: ps.sort,
|
||||||
|
}
|
||||||
|
if err := tx.Create(sec).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addPlannedFile 从 ZIP 读取附件并复用 AddFile 落盘,随后恢复下载计数与排序
|
||||||
|
func (imp *bookImporter) addPlannedFile(
|
||||||
|
p bookImportPlan, docID uint, mf libraryExportFile,
|
||||||
|
) (*model.LibraryFile, *LibraryImportFailure) {
|
||||||
|
rel, _ := cleanZipRel(mf.Stored)
|
||||||
|
zf, ok := imp.entries[p.dir+rel]
|
||||||
|
if !ok {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "备份包缺少附件文件:"+mf.Name)
|
||||||
|
}
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "读取附件失败:"+mf.Name)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
f, err := imp.s.AddFile(docID, imp.userID, mf.Name, io.LimitReader(rc, imp.s.maxBytes()+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "附件恢复失败「"+mf.Name+"」:"+err.Error())
|
||||||
|
}
|
||||||
|
if err := imp.s.db.Model(&model.LibraryFile{}).Where("id = ?", f.ID).
|
||||||
|
UpdateColumns(map[string]interface{}{
|
||||||
|
"download_count": mf.DownloadCount,
|
||||||
|
"sort_order": mf.SortOrder,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return nil, imp.fail(p, libraryImportReasonFailed, "附件信息写入失败:"+mf.Name)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// materializeCover 校验图片魔数后以随机名落盘 uploads/images,返回 URL/尺寸/磁盘路径
|
||||||
|
func (imp *bookImporter) materializeCover(zf *zip.File) (string, int, int, string, error) {
|
||||||
|
if imp.s.uploadsDir == "" {
|
||||||
|
return "", 0, 0, "", errors.New("未配置上传目录")
|
||||||
|
}
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(rc, ImageMaxBytes+1))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
if int64(len(data)) > ImageMaxBytes {
|
||||||
|
return "", 0, 0, "", errors.New("封面不能超过 5MB")
|
||||||
|
}
|
||||||
|
format, err := detectImageFormat(data)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
width, height, err := decodeImageSizeReader(bytes.NewReader(data), format.mime)
|
||||||
|
if err != nil || width < 1 || height < 1 {
|
||||||
|
return "", 0, 0, "", errors.New("无法解析封面图片")
|
||||||
|
}
|
||||||
|
if width > ImageMaxDim || height > ImageMaxDim {
|
||||||
|
return "", 0, 0, "", errors.New("封面边长不能超过 4096px")
|
||||||
|
}
|
||||||
|
nameBytes := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(nameBytes); err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||||
|
imagesDir := filepath.Join(imp.s.uploadsDir, "images")
|
||||||
|
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
full := filepath.Join(imagesDir, filename)
|
||||||
|
if err := os.WriteFile(full, data, 0o644); err != nil {
|
||||||
|
return "", 0, 0, "", err
|
||||||
|
}
|
||||||
|
return "/uploads/images/" + filename, width, height, full, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollbackCreated create 模式落盘阶段失败:硬删条目/章节/文件行与已落盘文件,释放 slug
|
||||||
|
func (imp *bookImporter) rollbackCreated(docID uint, newFiles []model.LibraryFile, coverDisk string) {
|
||||||
|
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error
|
||||||
|
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error
|
||||||
|
_ = imp.s.db.Unscoped().Delete(&model.LibraryDoc{}, docID).Error
|
||||||
|
for i := range newFiles {
|
||||||
|
_ = os.Remove(imp.s.FilePath(&newFiles[i]))
|
||||||
|
}
|
||||||
|
if coverDisk != "" {
|
||||||
|
_ = os.Remove(coverDisk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ZIP 工具 ----------
|
||||||
|
|
||||||
|
// cleanZipRel 校验 ZIP 内相对路径:拒绝绝对路径与任何 ../ 穿越段
|
||||||
|
func cleanZipRel(rel string) (string, bool) {
|
||||||
|
p := path.Clean(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"))
|
||||||
|
if p == "." || p == "" || path.IsAbs(p) || p == ".." {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, seg := range strings.Split(p, "/") {
|
||||||
|
if seg == ".." {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readZipJSON(zf *zip.File, v any) error {
|
||||||
|
rc, err := zf.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
return json.NewDecoder(rc).Decode(v)
|
||||||
|
}
|
||||||
354
backend/service/library_import_test.go
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func encodeJPEGBytes(t *testing.T, w, h int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h)), &jpeg.Options{Quality: 80}); err != nil {
|
||||||
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeExportZipFile 把内存导出包落盘为临时 zip(模拟 handler 上传后的临时文件)
|
||||||
|
func writeExportZipFile(t *testing.T, exp *LibraryExport) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "export.zip")
|
||||||
|
f, err := os.Create(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create temp zip: %v", err)
|
||||||
|
}
|
||||||
|
if err := exp.WriteZip(f); err != nil {
|
||||||
|
t.Fatalf("write export zip: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Fatalf("close temp zip: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeZipFileMap 手工构造 ZIP(用于非法包测试)
|
||||||
|
func writeZipFileMap(t *testing.T, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
zw := zip.NewWriter(buf)
|
||||||
|
for name, content := range entries {
|
||||||
|
w, err := zw.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip create %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(w, content); err != nil {
|
||||||
|
t.Fatalf("zip write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := zw.Close(); err != nil {
|
||||||
|
t.Fatalf("zip close: %v", err)
|
||||||
|
}
|
||||||
|
p := filepath.Join(t.TempDir(), "in.zip")
|
||||||
|
if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatalf("write temp zip: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertReportOK(t *testing.T, rep *LibraryImportReport, total int) {
|
||||||
|
t.Helper()
|
||||||
|
if rep == nil {
|
||||||
|
t.Fatal("报告为空")
|
||||||
|
}
|
||||||
|
if rep.Total != total {
|
||||||
|
t.Fatalf("total = %d, want %d", rep.Total, total)
|
||||||
|
}
|
||||||
|
if len(rep.Failed) != 0 {
|
||||||
|
t.Fatalf("存在失败记录: %+v", rep.Failed)
|
||||||
|
}
|
||||||
|
if len(rep.Imported) != total {
|
||||||
|
t.Fatalf("imported = %d, want %d", len(rep.Imported), total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibraryImportBookRoundTrip 单本包:导出 → 导入 → 冲突跳过 → 覆盖导入
|
||||||
|
func TestLibraryImportBookRoundTrip(t *testing.T) {
|
||||||
|
// ---- 源库 ----
|
||||||
|
src, _ := newLibraryTestService(t)
|
||||||
|
srcUploads := t.TempDir()
|
||||||
|
src.WithUploadsDir(srcUploads)
|
||||||
|
docID, storedName := seedBookForExport(t, src, srcUploads)
|
||||||
|
|
||||||
|
// 制造两次下载,验证计数随包保留
|
||||||
|
var srcFile model.LibraryFile
|
||||||
|
if err := src.db.Where("stored_name = ?", storedName).First(&srcFile).Error; err != nil {
|
||||||
|
t.Fatalf("find source file: %v", err)
|
||||||
|
}
|
||||||
|
src.IncrDownload(srcFile.ID)
|
||||||
|
src.IncrDownload(srcFile.ID)
|
||||||
|
|
||||||
|
exp, err := src.BuildBookExport(docID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build export: %v", err)
|
||||||
|
}
|
||||||
|
zipPath := writeExportZipFile(t, exp)
|
||||||
|
exp.Close()
|
||||||
|
|
||||||
|
// ---- 目标库(空库)----
|
||||||
|
dst, _ := newLibraryTestService(t)
|
||||||
|
dstUploads := t.TempDir()
|
||||||
|
dst.WithUploadsDir(dstUploads)
|
||||||
|
|
||||||
|
// 1) create 导入
|
||||||
|
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import: %v", err)
|
||||||
|
}
|
||||||
|
assertReportOK(t, rep, 1)
|
||||||
|
item := rep.Imported[0]
|
||||||
|
if item.Slug != "dao-jia" || item.Action != "created" || item.Sections != 2 || item.Files != 1 {
|
||||||
|
t.Fatalf("导入条目异常: %+v", item)
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc model.LibraryDoc
|
||||||
|
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc).Error; err != nil {
|
||||||
|
t.Fatalf("find imported doc: %v", err)
|
||||||
|
}
|
||||||
|
if doc.Title != "道家研究" || doc.Description != "# 介绍" || !doc.Published {
|
||||||
|
t.Fatalf("元信息恢复不正确: %+v", doc)
|
||||||
|
}
|
||||||
|
if doc.Author != "老子" {
|
||||||
|
t.Fatalf("作者恢复不正确: %q", doc.Author)
|
||||||
|
}
|
||||||
|
if doc.CreatorID != 7 {
|
||||||
|
t.Fatalf("新建导入创建者应为导入操作人,got %d", doc.CreatorID)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(doc.CoverURL, "/uploads/images/") || doc.CoverURL == "/uploads/images/cover.jpg" {
|
||||||
|
t.Fatalf("封面 URL 应为重新落盘的随机名,got %q", doc.CoverURL)
|
||||||
|
}
|
||||||
|
if doc.CoverWidth != 120 || doc.CoverHeight != 160 {
|
||||||
|
t.Fatalf("封面尺寸恢复不正确: %dx%d", doc.CoverWidth, doc.CoverHeight)
|
||||||
|
}
|
||||||
|
coverDisk, ok := dst.resolveCoverPath(doc.CoverURL)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("封面路径无法解析: %s", doc.CoverURL)
|
||||||
|
}
|
||||||
|
coverData, err := os.ReadFile(coverDisk)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("封面未落盘: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.HasPrefix(coverData, []byte{0xff, 0xd8, 0xff}) {
|
||||||
|
t.Fatal("封面内容不是 JPEG")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 章节树:章 + 小节父子关系
|
||||||
|
secs, err := dst.ListSections(doc.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list sections: %v", err)
|
||||||
|
}
|
||||||
|
if len(secs) != 2 {
|
||||||
|
t.Fatalf("章节数 = %d, want 2", len(secs))
|
||||||
|
}
|
||||||
|
var chapter, child *model.LibrarySection
|
||||||
|
for i := range secs {
|
||||||
|
if secs[i].ParentID == nil {
|
||||||
|
chapter = &secs[i]
|
||||||
|
} else {
|
||||||
|
child = &secs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chapter == nil || child == nil {
|
||||||
|
t.Fatalf("章节层级不正确: %+v", secs)
|
||||||
|
}
|
||||||
|
if chapter.Title != "第一章" || chapter.SortOrder != 0 {
|
||||||
|
t.Fatalf("章信息不正确: %+v", chapter)
|
||||||
|
}
|
||||||
|
if child.Title != "小节" || *child.ParentID != chapter.ID || child.SortOrder != 0 {
|
||||||
|
t.Fatalf("小节信息不正确: %+v", child)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附件:重新随机落盘、内容与下载计数保留
|
||||||
|
var files []model.LibraryFile
|
||||||
|
if err := dst.db.Where("doc_id = ?", doc.ID).Find(&files).Error; err != nil {
|
||||||
|
t.Fatalf("list files: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("附件数 = %d, want 1", len(files))
|
||||||
|
}
|
||||||
|
nf := files[0]
|
||||||
|
if nf.Name != "manual.epub" || nf.DownloadCount != 2 || nf.StoredName == srcFile.StoredName {
|
||||||
|
t.Fatalf("附件恢复不正确: %+v", nf)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(dst.FilePath(&nf))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("附件未落盘: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "EPUB-FILE-BYTES" {
|
||||||
|
t.Fatalf("附件内容不正确: %q", data)
|
||||||
|
}
|
||||||
|
firstCoverURL := doc.CoverURL
|
||||||
|
|
||||||
|
// 2) 再次 create:冲突跳过
|
||||||
|
rep2, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("re-import: %v", err)
|
||||||
|
}
|
||||||
|
if rep2.Total != 1 || len(rep2.Imported) != 0 || len(rep2.Failed) != 1 {
|
||||||
|
t.Fatalf("冲突报告异常: %+v", rep2)
|
||||||
|
}
|
||||||
|
if rep2.Failed[0].Reason != libraryImportReasonConflict {
|
||||||
|
t.Fatalf("失败原因应为 conflict,got %q", rep2.Failed[0].Reason)
|
||||||
|
}
|
||||||
|
var docCount int64
|
||||||
|
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
|
||||||
|
if docCount != 1 {
|
||||||
|
t.Fatalf("冲突跳过后书库数量异常: %d", docCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) overwrite:先加一个计划外附件与旧封面,导入后应被整体替换
|
||||||
|
extra, err := dst.AddFile(doc.ID, 7, "extra.txt", bytes.NewReader([]byte("EXTRA-BYTES")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add extra: %v", err)
|
||||||
|
}
|
||||||
|
extraPath := dst.FilePath(extra)
|
||||||
|
if _, err := os.Stat(extraPath); err != nil {
|
||||||
|
t.Fatalf("extra 未先落盘: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rep3, err := dst.ImportLibraryZip(zipPath, LibraryImportModeOverwrite, 7)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("overwrite import: %v", err)
|
||||||
|
}
|
||||||
|
assertReportOK(t, rep3, 1)
|
||||||
|
if rep3.Imported[0].Action != "overwritten" {
|
||||||
|
t.Fatalf("动作应为 overwritten,got %q", rep3.Imported[0].Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
|
||||||
|
if docCount != 1 {
|
||||||
|
t.Fatalf("覆盖后书库数量异常: %d", docCount)
|
||||||
|
}
|
||||||
|
var doc2 model.LibraryDoc
|
||||||
|
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc2).Error; err != nil {
|
||||||
|
t.Fatalf("find overwritten doc: %v", err)
|
||||||
|
}
|
||||||
|
if secs2, err := dst.ListSections(doc2.ID); err != nil || len(secs2) != 2 {
|
||||||
|
t.Fatalf("覆盖后章节异常: %d %v", len(secs2), err)
|
||||||
|
}
|
||||||
|
if doc2.Author != "老子" || doc2.CreatorID != 7 {
|
||||||
|
t.Fatalf("覆盖后作者/创建者异常: author=%q creator=%d", doc2.Author, doc2.CreatorID)
|
||||||
|
}
|
||||||
|
var files2 []model.LibraryFile
|
||||||
|
if err := dst.db.Where("doc_id = ?", doc2.ID).Find(&files2).Error; err != nil || len(files2) != 1 {
|
||||||
|
t.Fatalf("覆盖后附件数量异常: %d %v", len(files2), err)
|
||||||
|
}
|
||||||
|
if files2[0].Name != "manual.epub" || files2[0].DownloadCount != 2 {
|
||||||
|
t.Fatalf("覆盖后附件内容异常: %+v", files2[0])
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(extraPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("旧附件磁盘文件应被删除,err=%v", err)
|
||||||
|
}
|
||||||
|
oldCoverDisk, _ := dst.resolveCoverPath(firstCoverURL)
|
||||||
|
if _, err := os.Stat(oldCoverDisk); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("旧封面磁盘文件应被删除,err=%v", err)
|
||||||
|
}
|
||||||
|
if doc2.CoverURL == firstCoverURL {
|
||||||
|
t.Fatal("覆盖后封面应重新落盘为新文件")
|
||||||
|
}
|
||||||
|
newCoverDisk, _ := dst.resolveCoverPath(doc2.CoverURL)
|
||||||
|
if _, err := os.ReadFile(newCoverDisk); err != nil {
|
||||||
|
t.Fatalf("新封面未落盘: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibraryImportAll 全库包往返
|
||||||
|
func TestLibraryImportAll(t *testing.T) {
|
||||||
|
src, _ := newLibraryTestService(t)
|
||||||
|
srcUploads := t.TempDir()
|
||||||
|
src.WithUploadsDir(srcUploads)
|
||||||
|
if _, err := src.Create(&LibraryInput{Slug: "second-book", Title: "第二本"}); err != nil {
|
||||||
|
t.Fatalf("create second doc: %v", err)
|
||||||
|
}
|
||||||
|
seedBookForExport(t, src, srcUploads)
|
||||||
|
|
||||||
|
exp, err := src.BuildAllExport()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build all export: %v", err)
|
||||||
|
}
|
||||||
|
zipPath := writeExportZipFile(t, exp)
|
||||||
|
exp.Close()
|
||||||
|
|
||||||
|
dst, _ := newLibraryTestService(t)
|
||||||
|
dst.WithUploadsDir(t.TempDir())
|
||||||
|
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("import all: %v", err)
|
||||||
|
}
|
||||||
|
assertReportOK(t, rep, 2)
|
||||||
|
slugs := map[string]bool{}
|
||||||
|
for _, it := range rep.Imported {
|
||||||
|
slugs[it.Slug] = true
|
||||||
|
}
|
||||||
|
if !slugs["dao-jia"] || !slugs["second-book"] {
|
||||||
|
t.Fatalf("导入书集不正确: %+v", slugs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibraryImportRejectBadPackage 非法包整体拒绝
|
||||||
|
func TestLibraryImportRejectBadPackage(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
s.WithUploadsDir(t.TempDir())
|
||||||
|
|
||||||
|
badPath := filepath.Join(t.TempDir(), "bad.zip")
|
||||||
|
if err := os.WriteFile(badPath, []byte("this is not a zip"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.ImportLibraryZip(badPath, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportBadZip) {
|
||||||
|
t.Fatalf("非 zip 应返回 ErrLibraryImportBadZip,got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
noManifest := writeZipFileMap(t, map[string]string{"files/a.epub": "x"})
|
||||||
|
if _, err := s.ImportLibraryZip(noManifest, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportFormat) {
|
||||||
|
t.Fatalf("缺清单应返回 ErrLibraryImportFormat,got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.ImportLibraryZip(badPath, "bogus", 1); !errors.Is(err, ErrLibraryImportMode) {
|
||||||
|
t.Fatalf("非法模式应返回 ErrLibraryImportMode,got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCleanZipRel 路径穿越校验
|
||||||
|
func TestCleanZipRel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"", false},
|
||||||
|
{".", false},
|
||||||
|
{"..", false},
|
||||||
|
{"../evil", false},
|
||||||
|
{"a/../../b", false},
|
||||||
|
{"/etc/passwd", false},
|
||||||
|
{`docs\..\..\x`, false},
|
||||||
|
{"files/a.epub", true},
|
||||||
|
{"docs/x/book.json", true},
|
||||||
|
{`docs\x/cover.jpg`, true},
|
||||||
|
{"a//b", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
_, ok := cleanZipRel(tc.in)
|
||||||
|
if ok != tc.want {
|
||||||
|
t.Errorf("cleanZipRel(%q) = %v, want %v", tc.in, ok, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ func newLibraryTestService(t *testing.T) (*LibraryService, string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -45,6 +45,7 @@ func TestLibraryNormalize(t *testing.T) {
|
|||||||
{"标题为空", func(i *LibraryInput) { i.Title = " " }, "标题不能为空"},
|
{"标题为空", func(i *LibraryInput) { i.Title = " " }, "标题不能为空"},
|
||||||
{"标题超限", func(i *LibraryInput) { i.Title = strings.Repeat("书", 201) }, "标题不能超过"},
|
{"标题超限", func(i *LibraryInput) { i.Title = strings.Repeat("书", 201) }, "标题不能超过"},
|
||||||
{"介绍超限", func(i *LibraryInput) { i.Description = strings.Repeat("介", 20001) }, "介绍不能超过"},
|
{"介绍超限", func(i *LibraryInput) { i.Description = strings.Repeat("介", 20001) }, "介绍不能超过"},
|
||||||
|
{"作者超限", func(i *LibraryInput) { i.Author = strings.Repeat("作", 101) }, "作者不能超过"},
|
||||||
{"合法", func(i *LibraryInput) {}, ""},
|
{"合法", func(i *LibraryInput) {}, ""},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -63,6 +64,58 @@ func TestLibraryNormalize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLibraryAuthorAndCreator(t *testing.T) {
|
||||||
|
s, _ := newLibraryTestService(t)
|
||||||
|
user := &model.User{Username: "curator", Nickname: "馆长", Avatar: "/a.png"}
|
||||||
|
if err := s.db.Create(user).Error; err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub := true
|
||||||
|
doc, err := s.Create(&LibraryInput{
|
||||||
|
Slug: "authored", Title: "署名本", Author: " 鲁迅 ", Published: &pub,
|
||||||
|
}, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if doc.Author != "鲁迅" || doc.CreatorID != user.ID {
|
||||||
|
t.Fatalf("作者应裁剪、创建者应落库,got %+v", doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.ListPublished()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 || list[0].Author != "鲁迅" {
|
||||||
|
t.Fatalf("列表作者异常: %+v", list)
|
||||||
|
}
|
||||||
|
if c := list[0].Creator; c == nil || c.ID != user.ID || c.Nickname != "馆长" {
|
||||||
|
t.Fatalf("列表创建者摘要异常: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := s.GetPublishedBySlug("authored")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("detail: %v", err)
|
||||||
|
}
|
||||||
|
if detail.Creator == nil || detail.Creator.Username != "curator" {
|
||||||
|
t.Fatalf("详情创建者摘要异常: %+v", detail.Creator)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 作者可清空;更新不改变创建者归属
|
||||||
|
if _, err := s.Update(doc.ID, &LibraryInput{
|
||||||
|
Slug: "authored", Title: "署名本", Author: "", Published: &pub,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
var again model.LibraryDoc
|
||||||
|
if err := s.db.First(&again, doc.ID).Error; err != nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
if again.Author != "" || again.CreatorID != user.ID {
|
||||||
|
t.Fatalf("更新后作者/创建者异常: %+v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLibrarySlugUnique(t *testing.T) {
|
func TestLibrarySlugUnique(t *testing.T) {
|
||||||
s, _ := newLibraryTestService(t)
|
s, _ := newLibraryTestService(t)
|
||||||
if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "A"}); err != nil {
|
if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "A"}); err != nil {
|
||||||
|
|||||||
@@ -30,6 +30,27 @@ var mediaLibraryCategories = []struct {
|
|||||||
{"brand", "brand", "品牌资源"},
|
{"brand", "brand", "品牌资源"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 衍生分类:不对应 uploads 子目录,按附件 source 归类
|
||||||
|
const (
|
||||||
|
mediaCategoryBook = "book"
|
||||||
|
mediaCategoryBookName = "书籍"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mediaCategoryForAttachment 附件在媒体库中的展示分类:
|
||||||
|
// 书库封面 / 正文插图(source=library_cover|library_content)统一归“书籍”,
|
||||||
|
// 其余按 kind 对应磁盘目录分类。
|
||||||
|
func mediaCategoryForAttachment(kind, source string) (key, name string) {
|
||||||
|
if source == model.AttachmentSourceLibraryCover || source == model.AttachmentSourceLibraryContent {
|
||||||
|
return mediaCategoryBook, mediaCategoryBookName
|
||||||
|
}
|
||||||
|
for _, cat := range mediaLibraryCategories {
|
||||||
|
if cat.Key == kind {
|
||||||
|
return cat.Key, cat.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kind, kind
|
||||||
|
}
|
||||||
|
|
||||||
// MediaLibraryItem 媒体库条目
|
// MediaLibraryItem 媒体库条目
|
||||||
type MediaLibraryItem struct {
|
type MediaLibraryItem struct {
|
||||||
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
|
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
|
||||||
@@ -43,6 +64,7 @@ type MediaLibraryItem struct {
|
|||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
|
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
|
||||||
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||||||
|
RefCount int `json:"ref_count,omitempty"` // 引用计数(>1 表示内容相同的重复上传共享此文件)
|
||||||
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
|
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
|
||||||
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
|
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
|
||||||
}
|
}
|
||||||
@@ -53,6 +75,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
// 附件元数据索引:URL → 记录(联出上传者昵称)
|
// 附件元数据索引:URL → 记录(联出上传者昵称)
|
||||||
type attMeta struct {
|
type attMeta struct {
|
||||||
Kind string
|
Kind string
|
||||||
|
Source string
|
||||||
URL string
|
URL string
|
||||||
MIME string
|
MIME string
|
||||||
Size int
|
Size int
|
||||||
@@ -64,41 +87,49 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
}
|
}
|
||||||
var rows []attMeta
|
var rows []attMeta
|
||||||
if err := s.db.Table("attachments").
|
if err := s.db.Table("attachments").
|
||||||
Select("attachments.kind, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
|
Select("attachments.kind, attachments.source, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
|
||||||
Joins("LEFT JOIN users ON users.id = attachments.user_id").
|
Joins("LEFT JOIN users ON users.id = attachments.user_id").
|
||||||
Scan(&rows).Error; err != nil {
|
Scan(&rows).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
metaByURL := make(map[string]attMeta, len(rows))
|
// 同 URL 多条记录(内容寻址去重后,重复上传共享同一文件):
|
||||||
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
|
// 上传者/时间取最早一条,计数即该文件的引用次数
|
||||||
items := make([]MediaLibraryItem, 0, len(rows))
|
type urlMeta struct {
|
||||||
categoryName := func(key string) string {
|
meta attMeta
|
||||||
for _, cat := range mediaLibraryCategories {
|
count int
|
||||||
if cat.Key == key {
|
|
||||||
return cat.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
}
|
}
|
||||||
|
agg := make(map[string]*urlMeta, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
|
if a, ok := agg[r.URL]; ok {
|
||||||
if !strings.HasPrefix(r.URL, "/api/media/") {
|
a.count++
|
||||||
continue
|
if r.CreatedAt.Before(a.meta.CreatedAt) {
|
||||||
|
a.meta = r
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
agg[r.URL] = &urlMeta{meta: r, count: 1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 远程存储对象(/api/media/)不在磁盘上,按 URL 去重后直接作为条目加入
|
||||||
|
items := make([]MediaLibraryItem, 0, len(agg))
|
||||||
|
for url, a := range agg {
|
||||||
|
r := a.meta
|
||||||
|
if !strings.HasPrefix(url, "/api/media/") {
|
||||||
|
continue // 本地文件在磁盘扫描时按 URL 合并元数据
|
||||||
|
}
|
||||||
|
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
|
||||||
|
continue // 未知 kind 不进媒体库
|
||||||
}
|
}
|
||||||
uploader := r.Nickname
|
uploader := r.Nickname
|
||||||
if uploader == "" {
|
if uploader == "" {
|
||||||
uploader = r.Username
|
uploader = r.Username
|
||||||
}
|
}
|
||||||
cat := r.Kind
|
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
|
||||||
if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar {
|
|
||||||
continue // 未知 kind 不进媒体库
|
|
||||||
}
|
|
||||||
ca := r.CreatedAt
|
ca := r.CreatedAt
|
||||||
items = append(items, MediaLibraryItem{
|
items = append(items, MediaLibraryItem{
|
||||||
URL: r.URL, Category: cat, CategoryName: categoryName(cat),
|
URL: url, Category: catKey, CategoryName: catName,
|
||||||
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
|
Name: strings.TrimPrefix(url, "/api/media/"), MIME: r.MIME,
|
||||||
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
||||||
Uploader: uploader, UploadedAt: &ca,
|
Uploader: uploader, UploadedAt: &ca, RefCount: a.count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +169,9 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
mt := info.ModTime()
|
mt := info.ModTime()
|
||||||
item.UploadedAt = &mt
|
item.UploadedAt = &mt
|
||||||
}
|
}
|
||||||
if m, ok := metaByURL[url]; ok {
|
if a, ok := agg[url]; ok {
|
||||||
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
|
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
|
||||||
|
m := a.meta
|
||||||
uploader := m.Nickname
|
uploader := m.Nickname
|
||||||
if uploader == "" {
|
if uploader == "" {
|
||||||
uploader = m.Username
|
uploader = m.Username
|
||||||
@@ -147,12 +179,18 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
ca := m.CreatedAt
|
ca := m.CreatedAt
|
||||||
item.Uploader = uploader
|
item.Uploader = uploader
|
||||||
item.UploadedAt = &ca
|
item.UploadedAt = &ca
|
||||||
|
item.RefCount = a.count
|
||||||
if m.Size > 0 {
|
if m.Size > 0 {
|
||||||
item.Size = int64(m.Size)
|
item.Size = int64(m.Size)
|
||||||
}
|
}
|
||||||
if m.Width > 0 {
|
if m.Width > 0 {
|
||||||
item.Width, item.Height = m.Width, m.Height
|
item.Width, item.Height = m.Width, m.Height
|
||||||
}
|
}
|
||||||
|
// 书库封面 / 正文插图改归“书籍”分类
|
||||||
|
if m.Source == model.AttachmentSourceLibraryCover || m.Source == model.AttachmentSourceLibraryContent {
|
||||||
|
item.Category = mediaCategoryBook
|
||||||
|
item.CategoryName = mediaCategoryBookName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
|
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
|
||||||
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
|
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
|
||||||
@@ -243,6 +281,8 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
|
|||||||
postByURL := map[string]postRef{}
|
postByURL := map[string]postRef{}
|
||||||
type commentRef struct {
|
type commentRef struct {
|
||||||
id, postID uint
|
id, postID uint
|
||||||
|
floor uint
|
||||||
|
isRoot bool
|
||||||
}
|
}
|
||||||
commentByURL := map[string]commentRef{}
|
commentByURL := map[string]commentRef{}
|
||||||
|
|
||||||
@@ -299,6 +339,19 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 评论锚点改用所属帖内相对楼层号(#comment-{floor},楼中楼追加 -r{id})
|
||||||
|
commentIDs := make([]uint, 0, len(commentByURL))
|
||||||
|
for _, cm := range commentByURL {
|
||||||
|
commentIDs = append(commentIDs, cm.id)
|
||||||
|
}
|
||||||
|
commentAnchors := CommentAnchors(s.db, commentIDs)
|
||||||
|
for u, cm := range commentByURL {
|
||||||
|
if a, ok := commentAnchors[cm.id]; ok {
|
||||||
|
cm.floor, cm.isRoot = a.Floor, a.IsRoot
|
||||||
|
commentByURL[u] = cm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 评论来源要带宿主帖子标题,一次性补齐
|
// 评论来源要带宿主帖子标题,一次性补齐
|
||||||
postTitle := func(id uint) string {
|
postTitle := func(id uint) string {
|
||||||
var p model.Post
|
var p model.Post
|
||||||
@@ -374,7 +427,15 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if cm, ok := commentByURL[u]; ok {
|
if cm, ok := commentByURL[u]; ok {
|
||||||
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id)
|
if cm.floor > 0 {
|
||||||
|
if cm.isRoot {
|
||||||
|
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.floor)
|
||||||
|
} else {
|
||||||
|
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d-r%d", cm.postID, cm.floor, cm.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
it.SourceURL = fmt.Sprintf("/post/%d", cm.postID)
|
||||||
|
}
|
||||||
title := commentPostTitle[cm.postID]
|
title := commentPostTitle[cm.postID]
|
||||||
if title != "" {
|
if title != "" {
|
||||||
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
|
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func newMediaLibraryService(t *testing.T) (*UploadService, string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.Attachment{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
|
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -250,8 +250,7 @@ func TestAdminMediaLibraryAttachSources(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("comment image missing")
|
t.Fatal("comment image missing")
|
||||||
}
|
}
|
||||||
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-", post.ID) &&
|
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-1", post.ID) {
|
||||||
!strings.HasPrefix(cimg.SourceURL, fmt.Sprintf("/post/%d#comment-", post.ID)) {
|
|
||||||
t.Fatalf("comment source url wrong: %+v", cimg)
|
t.Fatalf("comment source url wrong: %+v", cimg)
|
||||||
}
|
}
|
||||||
if !strings.Contains(cimg.SourceLabel, "下的评论") {
|
if !strings.Contains(cimg.SourceLabel, "下的评论") {
|
||||||
@@ -259,6 +258,139 @@ func TestAdminMediaLibraryAttachSources(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminMediaLibraryBookCategory(t *testing.T) {
|
||||||
|
s, dir := newMediaLibraryService(t)
|
||||||
|
|
||||||
|
// 书籍封面(本地磁盘)与正文插图(远程对象),另有一张普通帖子插图
|
||||||
|
writeMediaFile(t, dir, "images/cover.webp", []byte("fake-webp-bytes"))
|
||||||
|
|
||||||
|
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
var alice model.User
|
||||||
|
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
|
||||||
|
t.Fatalf("load user: %v", err)
|
||||||
|
}
|
||||||
|
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
mkAtt := func(source, url string) {
|
||||||
|
if err := s.db.Create(&model.Attachment{
|
||||||
|
UserID: alice.ID, Kind: model.AttachmentKindImage, Source: source,
|
||||||
|
URL: url, MIME: "image/webp", Size: 100, Width: 10, Height: 10,
|
||||||
|
CreatedAt: created,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create attachment %s: %v", source, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mkAtt(model.AttachmentSourceLibraryCover, "/uploads/images/cover.webp")
|
||||||
|
mkAtt(model.AttachmentSourceLibraryContent, "/api/media/book-content")
|
||||||
|
mkAtt(model.AttachmentSourcePost, "/api/media/post-image")
|
||||||
|
|
||||||
|
items, counts, err := s.AdminMediaLibrary()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||||
|
}
|
||||||
|
byURL := map[string]MediaLibraryItem{}
|
||||||
|
for _, it := range items {
|
||||||
|
byURL[it.URL] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
// 封面(磁盘)与正文插图(远程)都归“书籍”
|
||||||
|
for _, u := range []string{"/uploads/images/cover.webp", "/api/media/book-content"} {
|
||||||
|
it, ok := byURL[u]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s missing", u)
|
||||||
|
}
|
||||||
|
if it.Category != "book" || it.CategoryName != "书籍" {
|
||||||
|
t.Fatalf("%s should be book category, got %q/%q", u, it.Category, it.CategoryName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通帖子插图仍归 image
|
||||||
|
if post := byURL["/api/media/post-image"]; post.Category != "image" {
|
||||||
|
t.Fatalf("post image should stay image category, got %q", post.Category)
|
||||||
|
}
|
||||||
|
|
||||||
|
if counts["book"] != 2 || counts["image"] != 1 {
|
||||||
|
t.Fatalf("counts wrong: %v", counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminMediaLibraryDedupAggregation(t *testing.T) {
|
||||||
|
s, dir := newMediaLibraryService(t)
|
||||||
|
|
||||||
|
writeMediaFile(t, dir, "images/dup.webp", []byte("fake-webp-bytes"))
|
||||||
|
|
||||||
|
mkUser := func(username, nickname string) model.User {
|
||||||
|
if err := s.db.Create(&model.User{Username: username, Nickname: nickname}).Error; err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
var u model.User
|
||||||
|
if err := s.db.Where("username = ?", username).First(&u).Error; err != nil {
|
||||||
|
t.Fatalf("load user: %v", err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
alice := mkUser("alice", "爱丽丝")
|
||||||
|
bob := mkUser("bob", "阿Bob")
|
||||||
|
|
||||||
|
early := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
late := early.Add(2 * time.Hour)
|
||||||
|
|
||||||
|
// 同一张图被两人先后上传(内容寻址去重后共享同一文件/对象)
|
||||||
|
mkAtt := func(u model.User, url string, at time.Time) {
|
||||||
|
if err := s.db.Create(&model.Attachment{
|
||||||
|
UserID: u.ID, Kind: model.AttachmentKindImage,
|
||||||
|
URL: url, MIME: "image/webp",
|
||||||
|
Size: 100, Width: 10, Height: 10, CreatedAt: at,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create attachment %s: %v", url, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mkAtt(alice, "/uploads/images/dup.webp", early)
|
||||||
|
mkAtt(bob, "/uploads/images/dup.webp", late)
|
||||||
|
mkAtt(bob, "/api/media/rdup", early)
|
||||||
|
mkAtt(alice, "/api/media/rdup", late)
|
||||||
|
|
||||||
|
items, _, err := s.AdminMediaLibrary()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
countByURL := map[string]int{}
|
||||||
|
byURL := map[string]MediaLibraryItem{}
|
||||||
|
for _, it := range items {
|
||||||
|
countByURL[it.URL]++
|
||||||
|
byURL[it.URL] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地去重文件:单个条目,上传者/时间取最早上传的爱丽丝,引用计数 2
|
||||||
|
local, ok := byURL["/uploads/images/dup.webp"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("dedup local file missing")
|
||||||
|
}
|
||||||
|
if countByURL["/uploads/images/dup.webp"] != 1 {
|
||||||
|
t.Fatal("dedup local file should appear exactly once")
|
||||||
|
}
|
||||||
|
if local.Uploader != "爱丽丝" || local.RefCount != 2 {
|
||||||
|
t.Fatalf("local dedup meta wrong: %+v", local)
|
||||||
|
}
|
||||||
|
if local.UploadedAt == nil || !local.UploadedAt.Equal(early) {
|
||||||
|
t.Fatalf("local dedup time should be earliest upload, got %v", local.UploadedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程对象同 URL 两条记录:也只出一个条目,取最早的 bob
|
||||||
|
remote, ok := byURL["/api/media/rdup"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("dedup remote object missing")
|
||||||
|
}
|
||||||
|
if countByURL["/api/media/rdup"] != 1 {
|
||||||
|
t.Fatal("dedup remote object should appear exactly once")
|
||||||
|
}
|
||||||
|
if remote.Uploader != "阿Bob" || remote.RefCount != 2 {
|
||||||
|
t.Fatalf("remote dedup meta wrong: %+v", remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMediaThumb(t *testing.T) {
|
func TestMediaThumb(t *testing.T) {
|
||||||
s, dir := newMediaLibraryService(t)
|
s, dir := newMediaLibraryService(t)
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,19 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"image"
|
|
||||||
_ "image/gif"
|
|
||||||
_ "image/jpeg"
|
|
||||||
_ "image/png"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
webpenc "github.com/gen2brain/webp"
|
|
||||||
"golang.org/x/image/draw"
|
|
||||||
xwebp "golang.org/x/image/webp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
|
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
|
||||||
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
|
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
|
||||||
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
|
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
|
||||||
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
|
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
|
||||||
|
// 解码/缩放/编码/原子落盘共用 image_variants.go 的图片处理核心。
|
||||||
|
|
||||||
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
|
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
|
||||||
const MediaThumbMaxSide = 480
|
const MediaThumbMaxSide = 480
|
||||||
@@ -70,12 +62,7 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var img image.Image
|
img, err := decodeImageBytes(data, ext == ".webp")
|
||||||
if ext == ".webp" {
|
|
||||||
img, err = xwebp.Decode(bytes.NewReader(data))
|
|
||||||
} else {
|
|
||||||
img, _, err = image.Decode(bytes.NewReader(data))
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err // 如动图 WebP,调用方回退原图
|
return nil, err // 如动图 WebP,调用方回退原图
|
||||||
}
|
}
|
||||||
@@ -94,21 +81,13 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) {
|
|||||||
nh = MediaThumbMaxSide
|
nh = MediaThumbMaxSide
|
||||||
nw = max(1, w*MediaThumbMaxSide/h)
|
nw = max(1, w*MediaThumbMaxSide/h)
|
||||||
}
|
}
|
||||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
dst := scaleImage(img, nw, nh)
|
||||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
out, err := encodeLossyWebP(dst, VariantWebPQuality)
|
||||||
if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
|
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
|
||||||
if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err == nil {
|
_ = atomicWriteFile(thumbPath, out)
|
||||||
tmp := thumbPath + ".partial"
|
return out, nil
|
||||||
if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err == nil {
|
|
||||||
if err := os.Rename(tmp, thumbPath); err != nil {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import (
|
|||||||
|
|
||||||
type mailPayload struct{ To, Subject, Body string }
|
type mailPayload struct{ To, Subject, Body string }
|
||||||
|
|
||||||
|
// ErrEmailRegistered 注册发码时邮箱已被占用,handler 据此返回 400 业务提示
|
||||||
|
var ErrEmailRegistered = errors.New("该邮箱已被注册,请直接登录")
|
||||||
|
|
||||||
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
|
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
|
||||||
password, e := o.open(c.Password, "mail:password")
|
password, e := o.open(c.Password, "mail:password")
|
||||||
if e != nil {
|
if e != nil {
|
||||||
@@ -392,6 +395,16 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if purpose == "register" {
|
||||||
|
// 入口拦截:邮箱已被占用则不发码,省一次邮件资源,也给用户明确提示
|
||||||
|
var n int64
|
||||||
|
if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return 0, ErrEmailRegistered
|
||||||
|
}
|
||||||
|
}
|
||||||
code := randomCode()
|
code := randomCode()
|
||||||
hash := counterKey(email + ":" + purpose + ":" + code)
|
hash := counterKey(email + ":" + purpose + ":" + code)
|
||||||
e = o.db.Transaction(func(tx *gorm.DB) error {
|
e = o.db.Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -402,6 +415,7 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
|||||||
})
|
})
|
||||||
return 0, e
|
return 0, e
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
codeVerifyMaxAttempts = 5
|
codeVerifyMaxAttempts = 5
|
||||||
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致
|
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
|
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
|
||||||
@@ -137,6 +139,65 @@ func (o *Operations) StoreFile(path, mimeType string, public bool) (string, erro
|
|||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// blobObjectID 内容寻址对象的稳定 ID:公开/私有加不同前缀,同字节也不共享可见性。
|
||||||
|
func blobObjectID(hash string, public bool) string {
|
||||||
|
if public {
|
||||||
|
return "pub_" + hash
|
||||||
|
}
|
||||||
|
return "prv_" + hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreFileHashed 按内容 hash 幂等上传:同一 (hash, public) 只存一份,重复上传跳过传输。
|
||||||
|
// 本地后端返回空 ID(文件留在本地 blob 目录,由调用方做引用计数)。
|
||||||
|
func (o *Operations) StoreFileHashed(path, mimeType string, public bool, hash string) (string, error) {
|
||||||
|
v, version, e := o.read(o.db, "storage")
|
||||||
|
if e != nil {
|
||||||
|
return "", errors.New("读取存储配置失败")
|
||||||
|
}
|
||||||
|
c := *v.(*StorageConfig)
|
||||||
|
if c.Backend == "local" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
id := blobObjectID(hash, public)
|
||||||
|
// hash 相同字节必然相同:对象已存在时直接复用,省掉整次上传
|
||||||
|
var existing model.StoredObject
|
||||||
|
if e := o.db.First(&existing, "id = ?", id).Error; e == nil {
|
||||||
|
return id, nil
|
||||||
|
} else if !errors.Is(e, gorm.ErrRecordNotFound) {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
client, e := o.s3(c)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
f, e := os.Open(path)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
stat, e := f.Stat()
|
||||||
|
if e != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
key := c.Prefix + "objects/" + id
|
||||||
|
if _, e = client.PutObject(ctx, c.Bucket, key, f, stat.Size(), minio.PutObjectOptions{ContentType: mimeType, DisableMultipart: true}); e != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return "", errors.New("上传到 S3 失败,当前目标未自动切换")
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
obj := model.StoredObject{ID: id, ConfigName: fmt.Sprintf("storage-%d", version), Key: key, MIME: mimeType, Public: public}
|
||||||
|
if e = o.db.Create(&obj).Error; e != nil {
|
||||||
|
if isUniqueConflict(e) { // 并发:另一请求已写入同内容对象,直接复用
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
_ = client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
|
||||||
|
return "", errors.New("保存文件记录失败")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
|
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
|
||||||
var obj model.StoredObject
|
var obj model.StoredObject
|
||||||
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
|
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -145,8 +147,9 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」
|
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」;落盘同时计算内容 hash
|
||||||
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
|
hasher := sha256.New()
|
||||||
|
written, copyErr := io.Copy(io.MultiWriter(f, hasher), io.LimitReader(src, maxB+1))
|
||||||
_ = f.Close()
|
_ = f.Close()
|
||||||
if copyErr != nil {
|
if copyErr != nil {
|
||||||
_ = os.Remove(tmp)
|
_ = os.Remove(tmp)
|
||||||
@@ -167,28 +170,45 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
att := &model.PostAttachment{
|
// 内容寻址:私有 blob 以 hash 命名(不带扩展名,下载时以 att.MIME + 原文件名下发)
|
||||||
PostID: 0,
|
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
UserID: userID,
|
final := absPath(s.dir, hash)
|
||||||
Name: name,
|
var objectID string
|
||||||
StoredName: stored,
|
|
||||||
MIME: mimeType,
|
|
||||||
Size: int(written),
|
|
||||||
PricePoints: pricePoints,
|
|
||||||
}
|
|
||||||
if s.ops != nil {
|
if s.ops != nil {
|
||||||
id, e := s.ops.StoreFile(full, att.MIME, false)
|
id, e := s.ops.StoreFileHashed(full, mimeType, false, hash)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
_ = os.Remove(full)
|
_ = os.Remove(full)
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
att.ObjectID = id
|
objectID = id
|
||||||
if id != "" {
|
if id != "" {
|
||||||
_ = os.Remove(full)
|
_ = os.Remove(full)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.db.Create(att).Error; err != nil {
|
if objectID == "" {
|
||||||
_ = os.Remove(full)
|
if e := placeLocalBlob(full, final); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
att := &model.PostAttachment{
|
||||||
|
PostID: 0,
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
StoredName: hash,
|
||||||
|
BlobHash: hash,
|
||||||
|
ObjectID: objectID,
|
||||||
|
MIME: mimeType,
|
||||||
|
Size: int(written),
|
||||||
|
PricePoints: pricePoints,
|
||||||
|
}
|
||||||
|
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := acquireBlobRef(tx, hash, false, int(written), mimeType, objectID); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return tx.Create(att).Error
|
||||||
|
}); err != nil {
|
||||||
|
s.releasePostBlobBestEffort(hash)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return att, nil
|
return att, nil
|
||||||
@@ -283,14 +303,51 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
|
|||||||
if att.UserID != userID {
|
if att.UserID != userID {
|
||||||
return ErrAttachmentForbidden
|
return ErrAttachmentForbidden
|
||||||
}
|
}
|
||||||
path := absPath(s.dir, att.StoredName)
|
|
||||||
if err := s.db.Delete(&att).Error; err != nil {
|
if err := s.db.Delete(&att).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if att.BlobHash != "" {
|
||||||
|
return s.releasePostBlob(&att)
|
||||||
|
}
|
||||||
if s.ops != nil && att.ObjectID != "" {
|
if s.ops != nil && att.ObjectID != "" {
|
||||||
return s.ops.RemoveObject(att.ObjectID)
|
return s.ops.RemoveObject(att.ObjectID)
|
||||||
}
|
}
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(absPath(s.dir, att.StoredName))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// releasePostBlob 私有附件 blob 引用计数-1;最后一个引用删除时回收物理文件
|
||||||
|
func (s *PostFileService) releasePostBlob(att *model.PostAttachment) error {
|
||||||
|
b, last, err := releaseBlobRef(s.db, att.BlobHash, false)
|
||||||
|
if err != nil || !last {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.removePrivateBlobObject(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// releasePostBlobBestEffort 附件记录落库失败时回滚刚加的引用,错误只记日志
|
||||||
|
func (s *PostFileService) releasePostBlobBestEffort(hash string) {
|
||||||
|
b, last, err := releaseBlobRef(s.db, hash, false)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[post-file] 回滚 blob 引用失败 hash=%s: %v", hash, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !last {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.removePrivateBlobObject(b); err != nil {
|
||||||
|
log.Printf("[post-file] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removePrivateBlobObject 引用归零后的物理回收(私有 blob 文件名即 hash,无扩展名)
|
||||||
|
func (s *PostFileService) removePrivateBlobObject(b model.Blob) error {
|
||||||
|
if b.ObjectID != "" && s.ops != nil {
|
||||||
|
return s.ops.RemoveObject(b.ObjectID)
|
||||||
|
}
|
||||||
|
if err := os.Remove(absPath(s.dir, b.Hash)); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Printf("[post-file] 删除 blob 文件失败 hash=%s: %v", b.Hash, err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
|
|||||||
type AcceptedAnswer struct {
|
type AcceptedAnswer struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Floor int `json:"floor"`
|
Floor int `json:"floor"`
|
||||||
|
IsRoot bool `json:"is_root"` // false = 楼中楼回复,锚点需 #comment-{floor}-r{id}
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Deleted bool `json:"deleted,omitempty"`
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
@@ -736,6 +737,7 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, view
|
|||||||
ans := &AcceptedAnswer{
|
ans := &AcceptedAnswer{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
Floor: floor,
|
Floor: floor,
|
||||||
|
IsRoot: c.ParentID == nil,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
}
|
}
|
||||||
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ type PublicSiteSettings struct {
|
|||||||
BrandMark string `json:"brand_mark"`
|
BrandMark string `json:"brand_mark"`
|
||||||
BrandLogoSize string `json:"brand_logo_size"`
|
BrandLogoSize string `json:"brand_logo_size"`
|
||||||
BrandLogoFit string `json:"brand_logo_fit"`
|
BrandLogoFit string `json:"brand_logo_fit"`
|
||||||
|
BrandSloganVisible bool `json:"brand_slogan_visible"`
|
||||||
FooterLinks []FooterLink `json:"footer_links"`
|
FooterLinks []FooterLink `json:"footer_links"`
|
||||||
|
|
||||||
// 积分增长规则(经济规则公开透明,签到卡等可直出)
|
// 积分增长规则(经济规则公开透明,签到卡等可直出)
|
||||||
|
|||||||
1022
backend/service/snippet.go
Normal file
@@ -113,46 +113,57 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
|
|||||||
return nil, errors.New("头像尺寸需在 64~512px 之间")
|
return nil, errors.New("头像尺寸需在 64~512px 之间")
|
||||||
}
|
}
|
||||||
|
|
||||||
nameBytes := make([]byte, 16)
|
hash := sha256Hex(data)
|
||||||
if _, err := rand.Read(nameBytes); err != nil {
|
filename := hash + ".webp"
|
||||||
|
fullPath := filepath.Join(s.dir, "avatars", filename)
|
||||||
|
url := "/uploads/avatars/" + filename
|
||||||
|
var objectID string
|
||||||
|
writeLocal := func() error {
|
||||||
|
if _, err := os.Stat(fullPath); err == nil {
|
||||||
|
return nil // 同内容头像已落盘,字节必然一致,无需重写
|
||||||
|
}
|
||||||
|
return os.WriteFile(fullPath, data, 0o644)
|
||||||
|
}
|
||||||
|
if s.ops != nil {
|
||||||
|
if err := writeLocal(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
filename := hex.EncodeToString(nameBytes) + ".webp"
|
id, e := s.ops.StoreFileHashed(fullPath, "image/webp", true, hash)
|
||||||
fullPath := filepath.Join(s.dir, "avatars", filename)
|
if e != nil {
|
||||||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
_ = os.Remove(fullPath)
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
objectID = id
|
||||||
|
if id != "" {
|
||||||
|
url = "/api/media/" + id
|
||||||
|
_ = os.Remove(fullPath)
|
||||||
|
}
|
||||||
|
} else if err := writeLocal(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
att := &model.Attachment{
|
att := &model.Attachment{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Kind: model.AttachmentKindAvatar,
|
Kind: model.AttachmentKindAvatar,
|
||||||
URL: "/uploads/avatars/" + filename,
|
URL: url,
|
||||||
|
BlobHash: hash,
|
||||||
MIME: "image/webp",
|
MIME: "image/webp",
|
||||||
Size: len(data),
|
Size: len(data),
|
||||||
Width: cfg.Width,
|
Width: cfg.Width,
|
||||||
Height: cfg.Height,
|
Height: cfg.Height,
|
||||||
}
|
}
|
||||||
|
// 附件记录、blob 引用与用户头像更新在同一事务内完成
|
||||||
if s.ops != nil {
|
|
||||||
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
|
|
||||||
if e != nil {
|
|
||||||
_ = os.Remove(fullPath)
|
|
||||||
return nil, e
|
|
||||||
}
|
|
||||||
if id != "" {
|
|
||||||
att.URL = "/api/media/" + id
|
|
||||||
_ = os.Remove(fullPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 附件记录与用户头像更新在同一事务内完成
|
|
||||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := acquireBlobRef(tx, hash, true, len(data), "image/webp", objectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := tx.Create(att).Error; err != nil {
|
if err := tx.Create(att).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
|
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(fullPath) // DB 失败时清理已落盘文件
|
s.releaseBlobBestEffort(hash, true, "/uploads/avatars/"+filename)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return att, nil
|
return att, nil
|
||||||
@@ -238,7 +249,7 @@ func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
|
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
|
||||||
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
|
func (s *UploadService) SaveImage(userID uint, src io.Reader, source string) (*model.Attachment, error) {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return nil, errors.New("文件为空")
|
return nil, errors.New("文件为空")
|
||||||
}
|
}
|
||||||
@@ -351,28 +362,51 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对最终存储字节做内容寻址(JPEG/PNG 已在上面转成 WebP,同图重复上传才能命中)
|
||||||
|
hash, err := hashFile(storePath)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(storePath)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
finalName := hash + strings.ToLower(filepath.Ext(storeName))
|
||||||
|
url := "/uploads/images/" + finalName
|
||||||
|
var objectID string
|
||||||
|
if s.ops != nil {
|
||||||
|
id, e := s.ops.StoreFileHashed(storePath, storeMime, true, hash)
|
||||||
|
if e != nil {
|
||||||
|
_ = os.Remove(storePath)
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
objectID = id
|
||||||
|
if id != "" {
|
||||||
|
// 远程后端:以对象 ID 为访问地址,本地临时副本回收
|
||||||
|
url = "/api/media/" + id
|
||||||
|
_ = os.Remove(storePath)
|
||||||
|
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
|
||||||
att := &model.Attachment{
|
att := &model.Attachment{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Kind: model.AttachmentKindImage,
|
Kind: model.AttachmentKindImage,
|
||||||
URL: "/uploads/images/" + storeName,
|
Source: normalizeImageSource(source),
|
||||||
|
URL: url,
|
||||||
|
BlobHash: hash,
|
||||||
MIME: storeMime,
|
MIME: storeMime,
|
||||||
Size: int(storeSize),
|
Size: int(storeSize),
|
||||||
Width: w,
|
Width: w,
|
||||||
Height: h,
|
Height: h,
|
||||||
}
|
}
|
||||||
if s.ops != nil {
|
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
id, e := s.ops.StoreFile(storePath, att.MIME, true)
|
if err := acquireBlobRef(tx, hash, true, int(storeSize), storeMime, objectID); err != nil {
|
||||||
if e != nil {
|
return err
|
||||||
_ = os.Remove(storePath)
|
|
||||||
return nil, e
|
|
||||||
}
|
}
|
||||||
if id != "" {
|
return tx.Create(att).Error
|
||||||
att.URL = "/api/media/" + id
|
}); err != nil {
|
||||||
_ = os.Remove(storePath)
|
s.releaseBlobBestEffort(hash, true, url)
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.db.Create(att).Error; err != nil {
|
|
||||||
_ = os.Remove(storePath)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return att, nil
|
return att, nil
|
||||||
@@ -585,9 +619,20 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
|
|||||||
return s.SaveBackground(f)
|
return s.SaveBackground(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeImageSource 把外部传入的图片来源收敛到白名单,未知值回退为 post
|
||||||
|
func normalizeImageSource(s string) string {
|
||||||
|
switch s {
|
||||||
|
case model.AttachmentSourceLibraryCover, model.AttachmentSourceLibraryContent:
|
||||||
|
return s
|
||||||
|
default:
|
||||||
|
return model.AttachmentSourcePost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件)
|
// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件)
|
||||||
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
|
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
|
||||||
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.Attachment, error) {
|
// source 决定副本的来源标记(如 library_cover)
|
||||||
|
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint, source string) (*model.Attachment, error) {
|
||||||
var att model.Attachment
|
var att model.Attachment
|
||||||
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
||||||
return nil, errors.New("图片不存在或不属于你")
|
return nil, errors.New("图片不存在或不属于你")
|
||||||
@@ -600,7 +645,7 @@ func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.At
|
|||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
defer r.Close()
|
defer r.Close()
|
||||||
return s.SaveImage(userID, r)
|
return s.SaveImage(userID, r, source)
|
||||||
}
|
}
|
||||||
abs, ok := s.safeUploadPath(att.URL)
|
abs, ok := s.safeUploadPath(att.URL)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -611,7 +656,7 @@ func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.At
|
|||||||
return nil, errors.New("读取图片失败")
|
return nil, errors.New("读取图片失败")
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
return s.SaveImage(userID, f)
|
return s.SaveImage(userID, f, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
|
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
|
||||||
@@ -667,6 +712,15 @@ func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) {
|
|||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindMediaBySource 按来源查本人附件(存在返回记录,不存在返回 error)
|
||||||
|
func (s *UploadService) FindMediaBySource(userID, attachmentID uint, source string) (*model.Attachment, error) {
|
||||||
|
var att model.Attachment
|
||||||
|
if err := s.db.Where("id = ? AND user_id = ? AND source = ?", attachmentID, userID, source).First(&att).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &att, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
|
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
|
||||||
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
|
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
|
||||||
|
|
||||||
@@ -701,6 +755,10 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if att.BlobHash != "" {
|
||||||
|
return s.releaseAttachmentBlob(&att)
|
||||||
|
}
|
||||||
|
|
||||||
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
|
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
|
||||||
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
|
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
|
||||||
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||||||
@@ -715,4 +773,47 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// releaseAttachmentBlob 公开图片 blob 引用计数-1;最后一个引用删除时回收物理文件
|
||||||
|
func (s *UploadService) releaseAttachmentBlob(att *model.Attachment) error {
|
||||||
|
b, last, err := releaseBlobRef(s.db, att.BlobHash, true)
|
||||||
|
if err != nil || !last {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.removePublicBlobObject(b, att.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseBlobBestEffort 业务记录落库失败时回滚刚加的引用,错误只记日志
|
||||||
|
func (s *UploadService) releaseBlobBestEffort(hash string, public bool, localURL string) {
|
||||||
|
b, last, err := releaseBlobRef(s.db, hash, public)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[upload] 回滚 blob 引用失败 hash=%s: %v", hash, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !last {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.removePublicBlobObject(b, localURL); err != nil {
|
||||||
|
log.Printf("[upload] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removePublicBlobObject 引用归零后的物理回收:远程对象失败需上抛(与历史行为一致),
|
||||||
|
// 本地文件失败只记日志(下次同内容上传会自愈覆盖)。
|
||||||
|
func (s *UploadService) removePublicBlobObject(b model.Blob, localURL string) error {
|
||||||
|
if b.ObjectID != "" && s.ops != nil {
|
||||||
|
if e := s.ops.RemoveObject(b.ObjectID); e != nil {
|
||||||
|
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if localURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(localURL, "/uploads/")))
|
||||||
|
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Printf("[upload] 删除 blob 文件失败 hash=%s path=%s: %v", b.Hash, abs, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }
|
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func newUploadTestService(t *testing.T) (*UploadService, string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.Attachment{}); err != nil {
|
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}); err != nil {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -60,7 +60,7 @@ func TestSaveImageTranscodesJPEGToWebP(t *testing.T) {
|
|||||||
t.Fatalf("encode jpeg: %v", err)
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ func TestSaveImageTranscodesPNGLosslessKeepsAlpha(t *testing.T) {
|
|||||||
t.Fatalf("encode png: %v", err)
|
t.Fatalf("encode png: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func TestSaveImageKeepsGIFAsIs(t *testing.T) {
|
|||||||
t.Fatalf("encode gif: %v", err)
|
t.Fatalf("encode gif: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ func TestSaveImageKeepsWebPAsIs(t *testing.T) {
|
|||||||
t.Fatalf("encode webp: %v", err)
|
t.Fatalf("encode webp: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveImage: %v", err)
|
t.Fatalf("SaveImage: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
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 |
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import { pageGlyph } from "@/lib/pageGlyph";
|
import { pageGlyph } from "@/lib/pageGlyph";
|
||||||
import {
|
import {
|
||||||
@@ -94,7 +94,7 @@ export default async function AboutIndexPage() {
|
|||||||
const announcements = overview?.announcements ?? [];
|
const announcements = overview?.announcements ?? [];
|
||||||
const siteLead = settings.site_description?.trim() || undefined;
|
const siteLead = settings.site_description?.trim() || undefined;
|
||||||
const urlStyle = settings.url_style;
|
const urlStyle = settings.url_style;
|
||||||
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
|
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
|
||||||
|
|
||||||
const left = <SiteDocIdentity kind="about" lead={siteLead} />;
|
const left = <SiteDocIdentity kind="about" lead={siteLead} />;
|
||||||
const right = <SiteDocAnnoNav items={announcements} />;
|
const right = <SiteDocAnnoNav items={announcements} />;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import Modal from "@/components/Modal";
|
|||||||
import { AdText } from "@/components/AdText";
|
import { AdText } from "@/components/AdText";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
type StatusFilter = "" | "pending" | "active" | "rejected" | "expired";
|
type StatusFilter = "" | "pending" | "active" | "rejected" | "expired";
|
||||||
type SectionId = "overview" | "queue" | "promote" | "sponsors";
|
type SectionId = "overview" | "queue" | "promote" | "sponsors";
|
||||||
@@ -882,8 +883,14 @@ function AdCard({
|
|||||||
{/* 预览缩略 */}
|
{/* 预览缩略 */}
|
||||||
<div className="j13-ads-thumb shrink-0" aria-hidden>
|
<div className="j13-ads-thumb shrink-0" aria-hidden>
|
||||||
{ad.kind === "image" && ad.image_url ? (
|
{ad.kind === "image" && ad.image_url ? (
|
||||||
|
// 88×44 缩略位:本站图走 96/160 密度变体;外链图保持原地址
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={ad.image_url} alt="" className="h-full w-full object-contain" />
|
<img
|
||||||
|
src={isOwnImage(ad.image_url) ? variantURL(ad.image_url, pickVariantWidth(88)) : ad.image_url}
|
||||||
|
srcSet={isOwnImage(ad.image_url) ? densitySrcSet(ad.image_url, 88) : undefined}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
) : ad.kind === "text" && ad.title ? (
|
) : ad.kind === "text" && ad.title ? (
|
||||||
<div className="flex h-full w-full items-center justify-center p-1.5">
|
<div className="flex h-full w-full items-center justify-center p-1.5">
|
||||||
<AdText title={ad.title} textColor={ad.text_color} bgColor={ad.bg_color} />
|
<AdText title={ad.title} textColor={ad.text_color} bgColor={ad.bg_color} />
|
||||||
@@ -990,8 +997,14 @@ function PaymentEditor({
|
|||||||
{/* 二维码:点击即上传 */}
|
{/* 二维码:点击即上传 */}
|
||||||
<label className="j13-ads-qrbox shrink-0 cursor-pointer" title="点击上传/更换收款码">
|
<label className="j13-ads-qrbox shrink-0 cursor-pointer" title="点击上传/更换收款码">
|
||||||
{payment.qr_url ? (
|
{payment.qr_url ? (
|
||||||
|
// 96×96 收款码位:本站图走 96/160 变体
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={payment.qr_url} alt="" className="h-full w-full object-contain" />
|
<img
|
||||||
|
src={isOwnImage(payment.qr_url) ? variantURL(payment.qr_url, pickVariantWidth(96)) : payment.qr_url}
|
||||||
|
srcSet={isOwnImage(payment.qr_url) ? densitySrcSet(payment.qr_url, 96) : undefined}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
@@ -1084,8 +1097,14 @@ function SponsorEditor({
|
|||||||
{/* Logo:点击即上传 */}
|
{/* Logo:点击即上传 */}
|
||||||
<label className="j13-ads-logo shrink-0 cursor-pointer" title="点击上传/更换 Logo">
|
<label className="j13-ads-logo shrink-0 cursor-pointer" title="点击上传/更换 Logo">
|
||||||
{item.logo_url ? (
|
{item.logo_url ? (
|
||||||
|
// 120×56 Logo 位:本站图走 160/256 变体
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={item.logo_url} alt="" className="h-full w-full rounded-lg object-contain" />
|
<img
|
||||||
|
src={isOwnImage(item.logo_url) ? variantURL(item.logo_url, pickVariantWidth(120)) : item.logo_url}
|
||||||
|
srcSet={isOwnImage(item.logo_url) ? densitySrcSet(item.logo_url, 120) : undefined}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full rounded-lg object-contain"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
<Upload size={15} />
|
<Upload size={15} />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type SponsorsConfig,
|
type SponsorsConfig,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminAdsPage() {
|
export default async function AdminAdsPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ADS)) {
|
||||||
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function DetailsInner() {
|
|||||||
<div className="w-full sm:w-56">
|
<div className="w-full sm:w-56">
|
||||||
<AdminSearchField
|
<AdminSearchField
|
||||||
value={path}
|
value={path}
|
||||||
onChange={(v) => { setPath(v); }}
|
onChange={(v) => { setPath(v); resetPage(); }}
|
||||||
onSubmit={resetPage}
|
onSubmit={resetPage}
|
||||||
onClear={() => { setPath(""); resetPage(); }}
|
onClear={() => { setPath(""); resetPage(); }}
|
||||||
placeholder="路径模糊匹配,回车应用"
|
placeholder="路径模糊匹配,回车应用"
|
||||||
@@ -148,7 +148,7 @@ function DetailsInner() {
|
|||||||
<div className="w-full sm:w-48">
|
<div className="w-full sm:w-48">
|
||||||
<AdminSearchField
|
<AdminSearchField
|
||||||
value={ip}
|
value={ip}
|
||||||
onChange={setIp}
|
onChange={(v) => { setIp(v); resetPage(); }}
|
||||||
onSubmit={resetPage}
|
onSubmit={resetPage}
|
||||||
onClear={() => { setIp(""); resetPage(); }}
|
onClear={() => { setIp(""); resetPage(); }}
|
||||||
placeholder="按 IP 精确筛选"
|
placeholder="按 IP 精确筛选"
|
||||||
@@ -158,7 +158,7 @@ function DetailsInner() {
|
|||||||
<div className="w-full sm:w-44">
|
<div className="w-full sm:w-44">
|
||||||
<AdminSearchField
|
<AdminSearchField
|
||||||
value={bot}
|
value={bot}
|
||||||
onChange={setBot}
|
onChange={(v) => { setBot(v); resetPage(); }}
|
||||||
onSubmit={resetPage}
|
onSubmit={resetPage}
|
||||||
onClear={() => { setBot(""); resetPage(); }}
|
onClear={() => { setBot(""); resetPage(); }}
|
||||||
placeholder="按爬虫名精确筛选"
|
placeholder="按爬虫名精确筛选"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../../Forbidden";
|
import Forbidden from "../../../Forbidden";
|
||||||
import AnnouncementEditLoader from "./AnnouncementEditLoader";
|
import AnnouncementEditLoader from "./AnnouncementEditLoader";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export default async function AdminAnnouncementEditPage({ params }: PageProps) {
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
|
||||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../Forbidden";
|
import Forbidden from "../../Forbidden";
|
||||||
import AnnouncementComposeClient from "../AnnouncementComposeClient";
|
import AnnouncementComposeClient from "../AnnouncementComposeClient";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminAnnouncementNewPage() {
|
export default async function AdminAnnouncementNewPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
|
||||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ import AnnouncementAdmin from "./AnnouncementAdmin";
|
|||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { fetchAdminAnnouncements, type Announcement } from "@/lib/api";
|
import { fetchAdminAnnouncements, type Announcement } from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "站点公告管理",
|
title: "站点公告管理",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 公告管理仅管理员及以上可用(板块管理员无公告权;后端路由另有守卫)
|
// 公告管理:管理员及以上角色自带;超管/站长亦可授予任意账号
|
||||||
export default async function AdminAnnouncementsPage() {
|
export default async function AdminAnnouncementsPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
|
||||||
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||||
import { mediaURLFitsBackground, type BgSurface } from "@/lib/bg";
|
import { mediaURLFitsBackground, type BgSurface } from "@/lib/bg";
|
||||||
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
const SURFACE_LABEL: Record<BgSurface, string> = {
|
const SURFACE_LABEL: Record<BgSurface, string> = {
|
||||||
site: "前台背景",
|
site: "前台背景",
|
||||||
@@ -101,7 +102,9 @@ export default function BgMediaPicker({
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={item.url}
|
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
|
||||||
|
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
|
||||||
|
sizes="(max-width:520px) 23vw, 96px"
|
||||||
alt={`${SURFACE_LABEL[surface]}候选图`}
|
alt={`${SURFACE_LABEL[surface]}候选图`}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { canModerateAuthor } from "@/lib/roles";
|
import { canModerateAuthor } from "@/lib/roles";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
import { postPath } from "@/lib/urlStyle";
|
import { postPath, commentAnchor } from "@/lib/urlStyle";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
@@ -692,7 +692,7 @@ export default function ContentAdmin({
|
|||||||
c.post_title
|
c.post_title
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
|
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type FriendLinkStatus,
|
type FriendLinkStatus,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { AdminPageHeader, AdminSettingsPage, AdminEmpty } from "@/components/admin";
|
import { AdminPageHeader, AdminSettingsPage, AdminEmpty } from "@/components/admin";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
initialLinks: FriendLink[];
|
initialLinks: FriendLink[];
|
||||||
@@ -237,9 +238,11 @@ export default function FriendLinksAdmin({ initialLinks, initError }: Props) {
|
|||||||
style={{ borderTop: "1px solid var(--line)" }}
|
style={{ borderTop: "1px solid var(--line)" }}
|
||||||
>
|
>
|
||||||
{l.logo_url ? (
|
{l.logo_url ? (
|
||||||
|
// 36px 位:本站图走 48/96 变体;外链保持原样
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={l.logo_url}
|
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(36)) : l.logo_url}
|
||||||
|
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 36) : undefined}
|
||||||
alt=""
|
alt=""
|
||||||
width={36}
|
width={36}
|
||||||
height={36}
|
height={36}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
import { authCookieHeader, REFRESH_COOKIE, CSRF_COOKIE, TOKEN_COOKIE } from "@/lib/cookies";
|
import { authCookieHeader, REFRESH_COOKIE, CSRF_COOKIE, TOKEN_COOKIE } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
|
import { canAccessAdminMessages, hasAnyChannelPerm, isStaff } from "@/lib/roles";
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
import AdminShell from "@/components/admin/AdminShell";
|
||||||
import AdminSessionRecover from "./AdminSessionRecover";
|
import AdminSessionRecover from "./AdminSessionRecover";
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
|
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
|
||||||
// 允许:管理角色,或仅有消息管理/群管权限的用户(仅消息菜单可见)。
|
// 允许:管理角色 / 消息管理/群管权限 / 任一渠道权限(公告/单页/书库/广告)。
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const cookie = authCookieHeader(cookieStore);
|
const cookie = authCookieHeader(cookieStore);
|
||||||
@@ -24,7 +24,8 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
||||||
|
|
||||||
const allowed =
|
const allowed =
|
||||||
!!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user));
|
!!me.user &&
|
||||||
|
(isStaff(me.user.role) || canAccessAdminMessages(me.user) || hasAnyChannelPerm(me.user));
|
||||||
|
|
||||||
if (!allowed || !me.user) {
|
if (!allowed || !me.user) {
|
||||||
// 已登录但无后台权限:保留提示卡片
|
// 已登录但无后台权限:保留提示卡片
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Download,
|
Download,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
apiAdminCreateLibraryDoc,
|
apiAdminCreateLibraryDoc,
|
||||||
apiAdminDeleteLibraryDoc,
|
apiAdminDeleteLibraryDoc,
|
||||||
apiAdminDeleteLibraryFile,
|
apiAdminDeleteLibraryFile,
|
||||||
|
apiAdminImportLibraryZip,
|
||||||
apiAdminListLibraryDocs,
|
apiAdminListLibraryDocs,
|
||||||
apiAdminPurgeLibraryDoc,
|
apiAdminPurgeLibraryDoc,
|
||||||
apiAdminUpdateLibraryDoc,
|
apiAdminUpdateLibraryDoc,
|
||||||
@@ -25,17 +26,20 @@ import {
|
|||||||
apiUploadPostImage,
|
apiUploadPostImage,
|
||||||
type LibraryDoc,
|
type LibraryDoc,
|
||||||
type LibraryFileItem,
|
type LibraryFileItem,
|
||||||
|
type LibraryImportReport,
|
||||||
type LibraryInput,
|
type LibraryInput,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||||
import { libraryDocPath } from "@/lib/urlStyle";
|
import { libraryDocPath, userPath } from "@/lib/urlStyle";
|
||||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import Avatar from "@/components/Avatar";
|
||||||
import MarkdownEditor from "@/components/MarkdownEditor";
|
import MarkdownEditor from "@/components/MarkdownEditor";
|
||||||
import { AdminEmpty, AdminField, AdminPageHeader, AdminStatusChip, AdminSwitch } from "@/components/admin";
|
import { AdminEmpty, AdminField, AdminPageHeader, AdminStatusChip, AdminSwitch } from "@/components/admin";
|
||||||
import LibrarySectionsPanel from "./LibrarySectionsPanel";
|
import LibrarySectionsPanel from "./LibrarySectionsPanel";
|
||||||
import LibraryCoverPicker from "./LibraryCoverPicker";
|
import LibraryCoverPicker from "./LibraryCoverPicker";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
// 与后端 libraryAllowedExts 保持一致;前端先拦一道,后端仍会校验
|
// 与后端 libraryAllowedExts 保持一致;前端先拦一道,后端仍会校验
|
||||||
const ALLOWED_EXTS = new Set([
|
const ALLOWED_EXTS = new Set([
|
||||||
@@ -59,9 +63,12 @@ function formatSize(n: number): string {
|
|||||||
|
|
||||||
interface DocForm {
|
interface DocForm {
|
||||||
title: string;
|
title: string;
|
||||||
|
author: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string;
|
description: string;
|
||||||
cover_url: string;
|
cover_url: string;
|
||||||
|
cover_width: number;
|
||||||
|
cover_height: number;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
entries_auto: boolean;
|
entries_auto: boolean;
|
||||||
@@ -69,9 +76,12 @@ interface DocForm {
|
|||||||
|
|
||||||
const EMPTY_FORM: DocForm = {
|
const EMPTY_FORM: DocForm = {
|
||||||
title: "",
|
title: "",
|
||||||
|
author: "",
|
||||||
slug: "",
|
slug: "",
|
||||||
description: "",
|
description: "",
|
||||||
cover_url: "",
|
cover_url: "",
|
||||||
|
cover_width: 0,
|
||||||
|
cover_height: 0,
|
||||||
published: false,
|
published: false,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
entries_auto: false,
|
entries_auto: false,
|
||||||
@@ -80,9 +90,12 @@ const EMPTY_FORM: DocForm = {
|
|||||||
function formOfDoc(d: LibraryDoc): DocForm {
|
function formOfDoc(d: LibraryDoc): DocForm {
|
||||||
return {
|
return {
|
||||||
title: d.title,
|
title: d.title,
|
||||||
|
author: d.author ?? "",
|
||||||
slug: d.slug,
|
slug: d.slug,
|
||||||
description: d.description ?? "",
|
description: d.description ?? "",
|
||||||
cover_url: d.cover_url ?? "",
|
cover_url: d.cover_url ?? "",
|
||||||
|
cover_width: d.cover_width ?? 0,
|
||||||
|
cover_height: d.cover_height ?? 0,
|
||||||
published: d.published,
|
published: d.published,
|
||||||
sort_order: d.sort_order,
|
sort_order: d.sort_order,
|
||||||
entries_auto: d.entries_auto ?? false,
|
entries_auto: d.entries_auto ?? false,
|
||||||
@@ -145,6 +158,13 @@ export default function LibraryAdmin({
|
|||||||
const [fileDeleteTarget, setFileDeleteTarget] = useState<LibraryFileItem | null>(null);
|
const [fileDeleteTarget, setFileDeleteTarget] = useState<LibraryFileItem | null>(null);
|
||||||
const [fileDeleteBusy, setFileDeleteBusy] = useState(false);
|
const [fileDeleteBusy, setFileDeleteBusy] = useState(false);
|
||||||
|
|
||||||
|
// 书库导入恢复(上传导出 ZIP)
|
||||||
|
const importInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importReport, setImportReport] = useState<LibraryImportReport | null>(null);
|
||||||
|
// create 模式遇到 conflict 时保留 File,供确认后 overwrite 重传
|
||||||
|
const [importOverwrite, setImportOverwrite] = useState<{ file: File; count: number } | null>(null);
|
||||||
|
|
||||||
// 条目删除(软删)与彻底删除
|
// 条目删除(软删)与彻底删除
|
||||||
const [docDeleteTarget, setDocDeleteTarget] = useState<LibraryDoc | null>(null);
|
const [docDeleteTarget, setDocDeleteTarget] = useState<LibraryDoc | null>(null);
|
||||||
const [docDeleteBusy, setDocDeleteBusy] = useState(false);
|
const [docDeleteBusy, setDocDeleteBusy] = useState(false);
|
||||||
@@ -170,6 +190,34 @@ export default function LibraryAdmin({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runImport(file: File, mode: "create" | "overwrite") {
|
||||||
|
setImporting(true);
|
||||||
|
setImportReport(null);
|
||||||
|
try {
|
||||||
|
const rep = await apiAdminImportLibraryZip(file, mode);
|
||||||
|
setImportReport(rep);
|
||||||
|
const okN = rep.imported.length;
|
||||||
|
const failN = rep.failed.length;
|
||||||
|
if (okN > 0) toast(`成功导入 ${okN} 本书${failN ? `,${failN} 本未导入` : ""}`, "ok");
|
||||||
|
else toast("没有书籍被导入,请查看失败明细", "error");
|
||||||
|
await reload();
|
||||||
|
if (mode === "create") {
|
||||||
|
const conflicts = rep.failed.filter((f) => f.reason === "conflict").length;
|
||||||
|
if (conflicts > 0) setImportOverwrite({ file, count: conflicts });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "导入失败");
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickImportFile(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = ""; // 允许重选同一文件
|
||||||
|
if (file) void runImport(file, "create");
|
||||||
|
}
|
||||||
|
|
||||||
function openNew() {
|
function openNew() {
|
||||||
setForm(EMPTY_FORM);
|
setForm(EMPTY_FORM);
|
||||||
setSnapshot(JSON.stringify(EMPTY_FORM));
|
setSnapshot(JSON.stringify(EMPTY_FORM));
|
||||||
@@ -221,8 +269,11 @@ export default function LibraryAdmin({
|
|||||||
const input: LibraryInput = {
|
const input: LibraryInput = {
|
||||||
slug,
|
slug,
|
||||||
title,
|
title,
|
||||||
|
author: form.author.trim(),
|
||||||
description: form.description,
|
description: form.description,
|
||||||
cover_url: form.cover_url.trim(),
|
cover_url: form.cover_url.trim(),
|
||||||
|
cover_width: form.cover_width,
|
||||||
|
cover_height: form.cover_height,
|
||||||
published: form.published,
|
published: form.published,
|
||||||
sort_order: form.sort_order,
|
sort_order: form.sort_order,
|
||||||
entries_auto: form.entries_auto,
|
entries_auto: form.entries_auto,
|
||||||
@@ -250,9 +301,13 @@ export default function LibraryAdmin({
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
setCoverBusy(true);
|
setCoverBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiUploadPostImage(file);
|
const res = await apiUploadPostImage(file, undefined, "library_cover");
|
||||||
if (res.url) {
|
if (res.url) {
|
||||||
patchForm({ cover_url: res.url });
|
patchForm({
|
||||||
|
cover_url: res.url,
|
||||||
|
cover_width: res.attachment?.width ?? 0,
|
||||||
|
cover_height: res.attachment?.height ?? 0,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setFormError(res.error || "封面上传失败");
|
setFormError(res.error || "封面上传失败");
|
||||||
}
|
}
|
||||||
@@ -267,8 +322,8 @@ export default function LibraryAdmin({
|
|||||||
async function pickCoverFromMedia(attachmentId: number) {
|
async function pickCoverFromMedia(attachmentId: number) {
|
||||||
setCoverBusy(true);
|
setCoverBusy(true);
|
||||||
try {
|
try {
|
||||||
const url = await apiLibraryCoverFromMedia(attachmentId);
|
const { url, width, height } = await apiLibraryCoverFromMedia(attachmentId);
|
||||||
patchForm({ cover_url: url });
|
patchForm({ cover_url: url, cover_width: width, cover_height: height });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setFormError(e instanceof Error ? e.message : "选用图库图片失败");
|
setFormError(e instanceof Error ? e.message : "选用图库图片失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -374,6 +429,36 @@ export default function LibraryAdmin({
|
|||||||
title="书库管理"
|
title="书库管理"
|
||||||
description="书籍、文档与页面作品。书籍由介绍、目录与章节正文组成,像书一样阅读;附件仅提供下载分发。"
|
description="书籍、文档与页面作品。书籍由介绍、目录与章节正文组成,像书一样阅读;附件仅提供下载分发。"
|
||||||
actions={
|
actions={
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href="/api/admin/library/export"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
title="把全部书籍打包为 ZIP(介绍、章节、附件与封面),用于迁移或备份"
|
||||||
|
>
|
||||||
|
<Download size={15} aria-hidden />
|
||||||
|
导出全部
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => importInputRef.current?.click()}
|
||||||
|
disabled={importing}
|
||||||
|
title="上传导出的 ZIP 备份包,恢复书籍(介绍、章节、附件与封面)"
|
||||||
|
>
|
||||||
|
{importing ? (
|
||||||
|
<Loader2 size={15} className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Upload size={15} aria-hidden />
|
||||||
|
)}
|
||||||
|
{importing ? "导入中…" : "导入恢复"}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={importInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
className="hidden"
|
||||||
|
onChange={onPickImportFile}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
@@ -382,9 +467,65 @@ export default function LibraryAdmin({
|
|||||||
>
|
>
|
||||||
<Plus size={15} /> 新建条目
|
<Plus size={15} /> 新建条目
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{importReport ? (
|
||||||
|
<div
|
||||||
|
className="panel mx-3.5 sm:mx-4 my-3 px-4 py-3 text-[13px]"
|
||||||
|
role="status"
|
||||||
|
style={{ borderColor: "var(--line)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<strong style={{ color: "var(--ink)" }}>
|
||||||
|
导入完成:成功 {importReport.imported.length} 本
|
||||||
|
(新建 {importReport.imported.filter((i) => i.action === "created").length} / 覆盖{" "}
|
||||||
|
{importReport.imported.filter((i) => i.action === "overwritten").length}),失败{" "}
|
||||||
|
{importReport.failed.length} 本
|
||||||
|
</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-auto btn btn-ghost"
|
||||||
|
style={{ padding: "2px 6px" }}
|
||||||
|
onClick={() => setImportReport(null)}
|
||||||
|
aria-label="关闭导入结果"
|
||||||
|
>
|
||||||
|
<X size={14} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{importReport.failed.length > 0 ? (
|
||||||
|
<ul className="mt-2 flex flex-col gap-1">
|
||||||
|
{importReport.failed.map((f, i) => (
|
||||||
|
<li key={`${f.slug}-${i}`} className="meta flex flex-wrap items-baseline gap-x-2">
|
||||||
|
<span style={{ color: "var(--ink-2)" }}>
|
||||||
|
{f.title || f.slug}
|
||||||
|
{f.slug ? `(${f.slug})` : ""}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
f.reason === "conflict" || f.reason === "deleted"
|
||||||
|
? "var(--warn, #b45309)"
|
||||||
|
: "var(--danger, #b91c1c)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.reason === "conflict"
|
||||||
|
? "同名书籍已存在,可覆盖导入"
|
||||||
|
: f.reason === "deleted"
|
||||||
|
? "地址被已删除条目占用,需先彻底删除"
|
||||||
|
: f.reason === "invalid"
|
||||||
|
? "备份数据不受支持"
|
||||||
|
: "导入失败"}
|
||||||
|
</span>
|
||||||
|
<span>{f.error}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="admin-library-split"
|
className="admin-library-split"
|
||||||
data-view={editorOpen ? "editor" : "list"}
|
data-view={editorOpen ? "editor" : "list"}
|
||||||
@@ -435,9 +576,11 @@ export default function LibraryAdmin({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{d.cover_url ? (
|
{d.cover_url ? (
|
||||||
|
// 36px 封面位:本站图走 48/96 变体
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={d.cover_url}
|
src={isOwnImage(d.cover_url) ? variantURL(d.cover_url, pickVariantWidth(36)) : d.cover_url}
|
||||||
|
srcSet={isOwnImage(d.cover_url) ? densitySrcSet(d.cover_url, 36) : undefined}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-9 h-9 rounded-lg object-cover shrink-0"
|
className="w-9 h-9 rounded-lg object-cover shrink-0"
|
||||||
style={{ border: "1px solid var(--line)" }}
|
style={{ border: "1px solid var(--line)" }}
|
||||||
@@ -560,6 +703,16 @@ export default function LibraryAdmin({
|
|||||||
? `${editingDoc.sections?.length ?? 0} 章节 · ${editingDoc.files?.length ?? 0} 附件`
|
? `${editingDoc.sections?.length ?? 0} 章节 · ${editingDoc.files?.length ?? 0} 附件`
|
||||||
: ""}
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
|
{typeof editingId === "number" ? (
|
||||||
|
<a
|
||||||
|
href={`/api/admin/library/docs/${editingId}/export`}
|
||||||
|
className="btn btn-line btn-sm shrink-0"
|
||||||
|
title="导出本书为 ZIP(介绍、章节、附件与本地封面),用于迁移或备份"
|
||||||
|
>
|
||||||
|
<Download size={13} aria-hidden />
|
||||||
|
导出备份
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 书籍信息 tab(两个 tab 均保持挂载,避免章节编辑状态丢失)。
|
{/* 书籍信息 tab(两个 tab 均保持挂载,避免章节编辑状态丢失)。
|
||||||
@@ -586,6 +739,15 @@ export default function LibraryAdmin({
|
|||||||
className="admin-doc-title"
|
className="admin-doc-title"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AdminField
|
||||||
|
id="lib-author"
|
||||||
|
label="作者"
|
||||||
|
maxLength={100}
|
||||||
|
value={form.author}
|
||||||
|
onChange={(author) => patchForm({ author })}
|
||||||
|
placeholder="作者或编著者,多位可用逗号分隔(可选)"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="admin-doc-field">
|
<div className="admin-doc-field">
|
||||||
<label className="admin-doc-label" htmlFor="lib-slug">
|
<label className="admin-doc-label" htmlFor="lib-slug">
|
||||||
访问地址
|
访问地址
|
||||||
@@ -628,6 +790,7 @@ export default function LibraryAdmin({
|
|||||||
placeholder="条目介绍,支持 Markdown…"
|
placeholder="条目介绍,支持 Markdown…"
|
||||||
rows={12}
|
rows={12}
|
||||||
ariaLabel="条目介绍"
|
ariaLabel="条目介绍"
|
||||||
|
uploadSource="library_content"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -636,31 +799,33 @@ export default function LibraryAdmin({
|
|||||||
<aside className="flex flex-col gap-4 lg:min-h-0">
|
<aside className="flex flex-col gap-4 lg:min-h-0">
|
||||||
<div className="admin-doc-field">
|
<div className="admin-doc-field">
|
||||||
<p className="admin-doc-label">封面(可选)</p>
|
<p className="admin-doc-label">封面(可选)</p>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex flex-col gap-2">
|
||||||
{form.cover_url ? (
|
{form.cover_url ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={form.cover_url}
|
src={isOwnImage(form.cover_url) ? variantURL(form.cover_url, 256) : form.cover_url}
|
||||||
alt="封面预览"
|
alt="封面预览"
|
||||||
className="w-12 h-12 rounded-lg object-cover shrink-0"
|
className="w-full h-32 rounded-xl object-cover"
|
||||||
style={{ border: "1px solid var(--line)" }}
|
style={{ border: "1px solid var(--line)" }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-12 h-12 rounded-lg flex items-center justify-center shrink-0"
|
className="w-full h-32 rounded-xl flex flex-col items-center justify-center gap-1.5"
|
||||||
style={{ border: "1px dashed var(--control-border)" }}
|
style={{ border: "1px dashed var(--control-border)" }}
|
||||||
aria-label="上传封面"
|
aria-label="上传封面"
|
||||||
onClick={() => coverInputRef.current?.click()}
|
onClick={() => coverInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<ImagePlus size={16} style={{ color: "var(--ink-3)" }} aria-hidden />
|
<ImagePlus size={20} style={{ color: "var(--ink-3)" }} aria-hidden />
|
||||||
|
<span className="text-[11.5px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
点击上传封面
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-line btn-sm flex-1"
|
className="btn btn-line btn-sm"
|
||||||
disabled={coverBusy}
|
disabled={coverBusy}
|
||||||
onClick={() => coverInputRef.current?.click()}
|
onClick={() => coverInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
@@ -673,19 +838,19 @@ export default function LibraryAdmin({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-line btn-sm flex-1"
|
className="btn btn-line btn-sm"
|
||||||
disabled={coverBusy}
|
disabled={coverBusy}
|
||||||
onClick={() => setCoverPickerOpen(true)}
|
onClick={() => setCoverPickerOpen(true)}
|
||||||
>
|
>
|
||||||
<Images size={13} aria-hidden />
|
<Images size={13} aria-hidden />
|
||||||
图库
|
图库
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
{form.cover_url ? (
|
{form.cover_url ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="admin-doc-textbtn"
|
className="admin-doc-textbtn ml-auto"
|
||||||
onClick={() => patchForm({ cover_url: "" })}
|
style={{ minHeight: 0, minWidth: 0, padding: "1px 6px", fontSize: 12 }}
|
||||||
|
onClick={() => patchForm({ cover_url: "", cover_width: 0, cover_height: 0 })}
|
||||||
>
|
>
|
||||||
移除
|
移除
|
||||||
</button>
|
</button>
|
||||||
@@ -749,6 +914,30 @@ export default function LibraryAdmin({
|
|||||||
<p className="admin-doc-hint">数字越小越靠前。</p>
|
<p className="admin-doc-hint">数字越小越靠前。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{typeof editingId === "number" && editingDoc?.creator ? (
|
||||||
|
<div className="admin-doc-field">
|
||||||
|
<p className="admin-doc-label">创建者</p>
|
||||||
|
<a
|
||||||
|
href={userPath(urlStyle, editingDoc.creator.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-[13px] font-medium max-w-full"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
title={`@${editingDoc.creator.username}`}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
size={22}
|
||||||
|
name={editingDoc.creator.nickname || editingDoc.creator.username}
|
||||||
|
src={editingDoc.creator.avatar}
|
||||||
|
/>
|
||||||
|
<span className="truncate">
|
||||||
|
{editingDoc.creator.nickname || editingDoc.creator.username}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<p className="admin-doc-hint">条目创建后归属不变。</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* 附件管理:长列表列内滚动 */}
|
{/* 附件管理:长列表列内滚动 */}
|
||||||
<div className="admin-doc-field flex flex-col lg:flex-1 lg:min-h-0">
|
<div className="admin-doc-field flex flex-col lg:flex-1 lg:min-h-0">
|
||||||
<p className="admin-doc-label shrink-0">
|
<p className="admin-doc-label shrink-0">
|
||||||
@@ -956,6 +1145,27 @@ export default function LibraryAdmin({
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 导入遇到同名书籍:确认后覆盖重传(保留同一 File) */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!importOverwrite}
|
||||||
|
title="覆盖导入同名书籍?"
|
||||||
|
message={
|
||||||
|
importOverwrite
|
||||||
|
? `备份包中有 ${importOverwrite.count} 本书的地址已被现有书籍占用。选择覆盖将用备份包整体替换这些书籍的介绍、章节、附件与封面,现有内容不可恢复;其余书籍正常新建。`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
danger
|
||||||
|
busy={importing}
|
||||||
|
confirmLabel="覆盖导入"
|
||||||
|
cancelLabel="保留现有"
|
||||||
|
onCancel={() => !importing && setImportOverwrite(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
const pending = importOverwrite;
|
||||||
|
setImportOverwrite(null);
|
||||||
|
if (pending) void runImport(pending.file, "overwrite");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 未保存关闭/切换确认 */}
|
{/* 未保存关闭/切换确认 */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!pendingNav}
|
open={!!pendingNav}
|
||||||
|
|||||||
@@ -4,8 +4,35 @@ import { Images, Loader2 } from "lucide-react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||||
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
/** 书籍封面图库选择弹窗:列出本人媒体库图片,选中后由父组件复制一份作封面素材 */
|
/** 来源标签映射 */
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
library_cover: "书籍封面",
|
||||||
|
library_content: "书籍内容",
|
||||||
|
post: "帖子插图",
|
||||||
|
};
|
||||||
|
|
||||||
|
function sourceLabel(source?: string): string {
|
||||||
|
return SOURCE_LABELS[source || "post"] || source || "帖子插图";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按来源分组(library_cover 优先,其次 library_content,最后 post/其他) */
|
||||||
|
function groupBySource(items: MediaAttachment[]): [string, MediaAttachment[]][] {
|
||||||
|
const groups = new Map<string, MediaAttachment[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.source || "post";
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(item);
|
||||||
|
}
|
||||||
|
// 按优先级排序
|
||||||
|
const order = ["library_cover", "library_content", "post"];
|
||||||
|
return Array.from(groups.entries()).sort(
|
||||||
|
([a], [b]) => order.indexOf(a) - order.indexOf(b)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 书籍封面图库选择弹窗:列出本人媒体库图片,按来源分组 */
|
||||||
export default function LibraryCoverPicker({
|
export default function LibraryCoverPicker({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -45,13 +72,15 @@ export default function LibraryCoverPicker({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
const groups = groupBySource(items);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title="从图库选择封面"
|
title="从图库选择封面"
|
||||||
description="选择后会复制一份作为封面素材,之后删除原图不影响封面。"
|
description="已选作封面的图片会单独分组,再次选择直接复用。"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
maxWidthClass="max-w-lg"
|
maxWidthClass="max-w-2xl"
|
||||||
dismissOnOverlay
|
dismissOnOverlay
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -67,8 +96,18 @@ export default function LibraryCoverPicker({
|
|||||||
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
|
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[46vh] overflow-y-auto">
|
<div className="flex flex-col gap-5 max-h-[60vh] overflow-y-auto">
|
||||||
{items.map((item) => (
|
{groups.map(([source, groupItems]) => (
|
||||||
|
<section key={source}>
|
||||||
|
<h4
|
||||||
|
className="text-[11.5px] font-bold tracking-wide uppercase mb-2 px-0.5"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
{sourceLabel(source)}
|
||||||
|
<span className="ml-1.5 font-normal opacity-70">({groupItems.length})</span>
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||||||
|
{groupItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -78,10 +117,21 @@ export default function LibraryCoverPicker({
|
|||||||
}}
|
}}
|
||||||
className="relative group aspect-square rounded-xl overflow-hidden"
|
className="relative group aspect-square rounded-xl overflow-hidden"
|
||||||
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
|
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
|
||||||
data-tip="复制一份用作封面"
|
data-tip={
|
||||||
|
item.source === "library_cover"
|
||||||
|
? "已作封面,点击直接复用"
|
||||||
|
: "复制一份用作封面"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={item.url} alt="图库候选封面" className="w-full h-full object-contain" loading="lazy" />
|
<img
|
||||||
|
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
|
||||||
|
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
|
||||||
|
sizes="(max-width:520px) 23vw, 96px"
|
||||||
|
alt="图库候选封面"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||||
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
||||||
@@ -91,6 +141,9 @@ export default function LibraryCoverPicker({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -410,6 +410,7 @@ export default function LibrarySectionsPanel({
|
|||||||
placeholder="本章正文,支持 Markdown…"
|
placeholder="本章正文,支持 Markdown…"
|
||||||
rows={16}
|
rows={16}
|
||||||
ariaLabel="章节正文"
|
ariaLabel="章节正文"
|
||||||
|
uploadSource="library_content"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import LibraryAdmin from "./LibraryAdmin";
|
|||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { fetchAdminLibraryDocs, type LibraryDoc } from "@/lib/api";
|
import { fetchAdminLibraryDocs, type LibraryDoc } from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminLibraryPage() {
|
export default async function AdminLibraryPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.LIBRARY)) {
|
||||||
return <Forbidden text="书库管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="书库管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Images, Copy, ExternalLink, Eye, Check } from "lucide-react";
|
|||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import AdminEmpty from "@/components/admin/AdminEmpty";
|
import AdminEmpty from "@/components/admin/AdminEmpty";
|
||||||
import type { MediaLibraryItem } from "@/lib/api";
|
import type { MediaLibraryItem } from "@/lib/api";
|
||||||
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
function formatBytes(n: number): string {
|
function formatBytes(n: number): string {
|
||||||
if (n < 1024) return `${n} B`;
|
if (n < 1024) return `${n} B`;
|
||||||
@@ -24,10 +25,11 @@ function formatDims(item: MediaLibraryItem): string {
|
|||||||
return "—";
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 网格缩略图:本地图走服务端缩略接口(磁盘缓存),远程对象直连原图(头像类本身小) */
|
/** 网格缩略图:本站图走统一变体端点(磁盘缓存,256 档);非本站图原样返回 */
|
||||||
function thumbSrc(item: MediaLibraryItem): string {
|
function thumbSrc(item: MediaLibraryItem): string {
|
||||||
if (item.url.startsWith("/uploads/")) {
|
// 网格缩略走统一变体端点(256 档);非本站图原样返回
|
||||||
return `/api/admin/media-library/thumb?u=${encodeURIComponent(item.url)}`;
|
if (isOwnImage(item.url)) {
|
||||||
|
return variantURL(item.url, 256);
|
||||||
}
|
}
|
||||||
return item.url;
|
return item.url;
|
||||||
}
|
}
|
||||||
@@ -143,6 +145,8 @@ export default function MediaLibrary({
|
|||||||
<span className="block relative aspect-square overflow-hidden">
|
<span className="block relative aspect-square overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={thumbSrc(item)}
|
src={thumbSrc(item)}
|
||||||
|
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [160, 256, 384, 512]) : undefined}
|
||||||
|
sizes="(min-width:1280px) 16vw, (min-width:1024px) 25vw, (min-width:640px) 33vw, 50vw"
|
||||||
alt={item.name}
|
alt={item.name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
@@ -175,7 +179,8 @@ export default function MediaLibrary({
|
|||||||
<Modal
|
<Modal
|
||||||
open={active !== null}
|
open={active !== null}
|
||||||
onClose={() => setActive(null)}
|
onClose={() => setActive(null)}
|
||||||
title={active?.name ?? ""}
|
title={<span className="break-all">{active?.name ?? ""}</span>}
|
||||||
|
ariaLabel={active?.name ?? ""}
|
||||||
description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined}
|
description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined}
|
||||||
maxWidthClass="max-w-2xl"
|
maxWidthClass="max-w-2xl"
|
||||||
dismissOnOverlay
|
dismissOnOverlay
|
||||||
@@ -214,6 +219,14 @@ export default function MediaLibrary({
|
|||||||
["格式", active.mime],
|
["格式", active.mime],
|
||||||
["上传者", active.uploader || "—"],
|
["上传者", active.uploader || "—"],
|
||||||
["时间", formatTime(active.uploaded_at)],
|
["时间", formatTime(active.uploaded_at)],
|
||||||
|
...(active.ref_count && active.ref_count > 1
|
||||||
|
? [
|
||||||
|
[
|
||||||
|
"引用",
|
||||||
|
`${active.ref_count} 次上传 · 去重节省 ${formatBytes(active.size * (active.ref_count - 1))}`,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
: []),
|
||||||
].map(([k, v]) => (
|
].map(([k, v]) => (
|
||||||
<div key={k} className="flex items-baseline gap-2 min-w-0">
|
<div key={k} className="flex items-baseline gap-2 min-w-0">
|
||||||
<dt className="meta shrink-0 w-11 text-right">{k}</dt>
|
<dt className="meta shrink-0 w-11 text-right">{k}</dt>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../../Forbidden";
|
import Forbidden from "../../../Forbidden";
|
||||||
import PageEditLoader from "./PageEditLoader";
|
import PageEditLoader from "./PageEditLoader";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export default async function AdminPageEditPage({ params }: PageProps) {
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
|
||||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Suspense } from "react";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../../Forbidden";
|
import Forbidden from "../../Forbidden";
|
||||||
import PageComposeClient from "../PageComposeClient";
|
import PageComposeClient from "../PageComposeClient";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminPageNewPage() {
|
export default async function AdminPageNewPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
|
||||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import PagesAdmin from "./PagesAdmin";
|
|||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { fetchAdminSitePages, type SitePage } from "@/lib/api";
|
import { fetchAdminSitePages, type SitePage } from "@/lib/api";
|
||||||
import { getMeCached } from "@/lib/serverData";
|
import { getMeCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import Forbidden from "../Forbidden";
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
|
|||||||
export default async function AdminPagesPage() {
|
export default async function AdminPagesPage() {
|
||||||
const cookie = authCookieHeader(await cookies());
|
const cookie = authCookieHeader(await cookies());
|
||||||
const me = await getMeCached(cookie || undefined);
|
const me = await getMeCached(cookie || undefined);
|
||||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
|
||||||
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AdminField, AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
|
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
|
||||||
import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api";
|
import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api";
|
||||||
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
|
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
@@ -25,6 +25,7 @@ export default function BasicIdentityPanel({
|
|||||||
}, []);
|
}, []);
|
||||||
const [wordmark, setWordmark] = useState(initial.site_wordmark);
|
const [wordmark, setWordmark] = useState(initial.site_wordmark);
|
||||||
const [slogan, setSlogan] = useState(initial.site_slogan);
|
const [slogan, setSlogan] = useState(initial.site_slogan);
|
||||||
|
const [sloganVisible, setSloganVisible] = useState(initial.brand_slogan_visible);
|
||||||
const [desc, setDesc] = useState(initial.site_description);
|
const [desc, setDesc] = useState(initial.site_description);
|
||||||
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
|
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
|
||||||
const [kwInput, setKwInput] = useState("");
|
const [kwInput, setKwInput] = useState("");
|
||||||
@@ -32,6 +33,7 @@ export default function BasicIdentityPanel({
|
|||||||
name: initial.site_name,
|
name: initial.site_name,
|
||||||
wordmark: initial.site_wordmark,
|
wordmark: initial.site_wordmark,
|
||||||
slogan: initial.site_slogan,
|
slogan: initial.site_slogan,
|
||||||
|
sloganVisible: initial.brand_slogan_visible,
|
||||||
desc: initial.site_description,
|
desc: initial.site_description,
|
||||||
keywords: initial.site_keywords,
|
keywords: initial.site_keywords,
|
||||||
});
|
});
|
||||||
@@ -44,6 +46,7 @@ export default function BasicIdentityPanel({
|
|||||||
name.trim() !== saved.name ||
|
name.trim() !== saved.name ||
|
||||||
wordmark.trim() !== saved.wordmark ||
|
wordmark.trim() !== saved.wordmark ||
|
||||||
slogan.trim() !== saved.slogan ||
|
slogan.trim() !== saved.slogan ||
|
||||||
|
sloganVisible !== saved.sloganVisible ||
|
||||||
desc.trim() !== saved.desc ||
|
desc.trim() !== saved.desc ||
|
||||||
JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
|
JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
|
||||||
|
|
||||||
@@ -85,6 +88,7 @@ export default function BasicIdentityPanel({
|
|||||||
setName(saved.name);
|
setName(saved.name);
|
||||||
setWordmark(saved.wordmark);
|
setWordmark(saved.wordmark);
|
||||||
setSlogan(saved.slogan);
|
setSlogan(saved.slogan);
|
||||||
|
setSloganVisible(saved.sloganVisible);
|
||||||
setDesc(saved.desc);
|
setDesc(saved.desc);
|
||||||
setKeywords([...saved.keywords]);
|
setKeywords([...saved.keywords]);
|
||||||
setKwInput("");
|
setKwInput("");
|
||||||
@@ -98,6 +102,7 @@ export default function BasicIdentityPanel({
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
wordmark: wordmark.trim(),
|
wordmark: wordmark.trim(),
|
||||||
slogan: slogan.trim(),
|
slogan: slogan.trim(),
|
||||||
|
sloganVisible,
|
||||||
desc: desc.trim(),
|
desc: desc.trim(),
|
||||||
keywords: [...keywords],
|
keywords: [...keywords],
|
||||||
};
|
};
|
||||||
@@ -115,6 +120,7 @@ export default function BasicIdentityPanel({
|
|||||||
site_name: snap.name,
|
site_name: snap.name,
|
||||||
site_wordmark: snap.wordmark,
|
site_wordmark: snap.wordmark,
|
||||||
site_slogan: snap.slogan,
|
site_slogan: snap.slogan,
|
||||||
|
brand_slogan_visible: snap.sloganVisible,
|
||||||
site_description: snap.desc,
|
site_description: snap.desc,
|
||||||
site_keywords: snap.keywords,
|
site_keywords: snap.keywords,
|
||||||
});
|
});
|
||||||
@@ -123,12 +129,14 @@ export default function BasicIdentityPanel({
|
|||||||
name: res.site_name,
|
name: res.site_name,
|
||||||
wordmark: res.site_wordmark,
|
wordmark: res.site_wordmark,
|
||||||
slogan: res.site_slogan,
|
slogan: res.site_slogan,
|
||||||
|
sloganVisible: res.brand_slogan_visible,
|
||||||
desc: res.site_description,
|
desc: res.site_description,
|
||||||
keywords: res.site_keywords,
|
keywords: res.site_keywords,
|
||||||
});
|
});
|
||||||
setName((v) => (v.trim() === snap.name ? res.site_name : v));
|
setName((v) => (v.trim() === snap.name ? res.site_name : v));
|
||||||
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
|
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
|
||||||
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
|
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
|
||||||
|
setSloganVisible((v) => (v === snap.sloganVisible ? res.brand_slogan_visible : v));
|
||||||
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
|
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
|
||||||
setKeywords((prev) =>
|
setKeywords((prev) =>
|
||||||
JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev,
|
JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev,
|
||||||
@@ -199,18 +207,49 @@ export default function BasicIdentityPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminField
|
<div className="admin-identity-field">
|
||||||
|
<div className="admin-field-head">
|
||||||
|
<label className="field-label" htmlFor="site-slogan">
|
||||||
|
标语
|
||||||
|
</label>
|
||||||
|
<span className="admin-field-count" aria-hidden>
|
||||||
|
{slogan.length}/{BRAND_LIMITS.slogan}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 mb-2"
|
||||||
|
title={slogan.trim() ? "在页眉站名下方显示副标题" : "先填写标语后可开启"}
|
||||||
|
>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={sloganVisible}
|
||||||
|
disabled={!slogan.trim()}
|
||||||
|
onChange={() => {
|
||||||
|
setSloganVisible((v) => !v);
|
||||||
|
clearSaveError();
|
||||||
|
}}
|
||||||
|
label="页眉显示标语"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-[12px]"
|
||||||
|
style={{ color: slogan.trim() ? "var(--ink-2)" : "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
页眉显示标语
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field-shell">
|
||||||
|
<input
|
||||||
id="site-slogan"
|
id="site-slogan"
|
||||||
label="标语"
|
className="admin-field-input"
|
||||||
className="admin-identity-field"
|
|
||||||
maxLength={BRAND_LIMITS.slogan}
|
maxLength={BRAND_LIMITS.slogan}
|
||||||
value={slogan}
|
value={slogan}
|
||||||
onChange={(v) => {
|
onChange={(e) => {
|
||||||
setSlogan(v);
|
setSlogan(e.target.value);
|
||||||
clearSaveError();
|
clearSaveError();
|
||||||
}}
|
}}
|
||||||
placeholder="技术分享与讨论"
|
placeholder="技术分享与讨论"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p className="admin-identity-title-preview" aria-live="polite">
|
<p className="admin-identity-title-preview" aria-live="polite">
|
||||||
浏览器标题预览 · <strong>{preview}</strong>
|
浏览器标题预览 · <strong>{preview}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { Images, Loader2 } from "lucide-react";
|
import { Images, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
import { apiAdminMediaLibrary, apiMyMedia } from "@/lib/api";
|
||||||
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
|
import { normalizeBrandURL, type BrandSlot } from "@/lib/brand";
|
||||||
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
const SLOT_LABEL: Record<BrandSlot, string> = {
|
const SLOT_LABEL: Record<BrandSlot, string> = {
|
||||||
logo_light: "浅色 Logo",
|
logo_light: "浅色 Logo",
|
||||||
@@ -12,6 +13,13 @@ const SLOT_LABEL: Record<BrandSlot, string> = {
|
|||||||
favicon: "Favicon",
|
favicon: "Favicon",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 选图条目:本人附件带 attachmentId;品牌目录历史图仅 URL(已在品牌目录,无需复制) */
|
||||||
|
export type BrandPickItem = {
|
||||||
|
key: string;
|
||||||
|
url: string;
|
||||||
|
attachmentId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default function BrandMediaPicker({
|
export default function BrandMediaPicker({
|
||||||
open,
|
open,
|
||||||
slot,
|
slot,
|
||||||
@@ -21,9 +29,9 @@ export default function BrandMediaPicker({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
slot: BrandSlot;
|
slot: BrandSlot;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPick: (item: MediaAttachment) => void;
|
onPick: (item: BrandPickItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const [items, setItems] = useState<MediaAttachment[]>([]);
|
const [items, setItems] = useState<BrandPickItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -32,16 +40,29 @@ export default function BrandMediaPicker({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
void apiMyMedia()
|
// 本人附件 + 媒体库盘点里的品牌目录历史图(品牌图不落附件,需合并展示)
|
||||||
.then((res) => {
|
Promise.all([apiMyMedia(), apiAdminMediaLibrary().catch(() => null)])
|
||||||
|
.then(([mediaRes, libRes]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (res.error) {
|
if (mediaRes.error) {
|
||||||
setError(res.error);
|
setError(mediaRes.error);
|
||||||
setItems([]);
|
setItems([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const all = res.attachments || [];
|
const out: BrandPickItem[] = (mediaRes.attachments || []).map((a) => ({
|
||||||
setItems(all.filter((item) => mediaURLFitsSlot(slot, item.url)));
|
key: `att-${a.id}`,
|
||||||
|
url: a.url,
|
||||||
|
attachmentId: a.id,
|
||||||
|
}));
|
||||||
|
const seen = new Set(out.map((i) => i.url));
|
||||||
|
for (const it of libRes?.items || []) {
|
||||||
|
if (it.category !== "brand") continue;
|
||||||
|
if (!normalizeBrandURL(slot, it.url)) continue;
|
||||||
|
if (seen.has(it.url)) continue;
|
||||||
|
seen.add(it.url);
|
||||||
|
out.unshift({ key: `brand-${it.url}`, url: it.url, attachmentId: null });
|
||||||
|
}
|
||||||
|
setItems(out);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setError("加载媒体库失败");
|
if (!cancelled) setError("加载媒体库失败");
|
||||||
@@ -58,7 +79,7 @@ export default function BrandMediaPicker({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
|
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
|
||||||
description="只列出格式符合的图片。保存后会复制一份到品牌目录,之后删除原图不会影响 Logo。"
|
description="位图选中后进入裁剪并转成 WebP;SVG / ICO 与历史品牌图直接引用,之后删除原图不会影响 Logo。"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
maxWidthClass="max-w-lg"
|
maxWidthClass="max-w-lg"
|
||||||
dismissOnOverlay
|
dismissOnOverlay
|
||||||
@@ -72,12 +93,12 @@ export default function BrandMediaPicker({
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<p className="meta text-[13px] py-6 text-center">没有可用图片。Logo 需要 PNG / WebP / SVG,Favicon 还可选 ICO。</p>
|
<p className="meta text-[13px] py-6 text-center">媒体库还没有图片,先上传一张吧。</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPick(item);
|
onPick(item);
|
||||||
@@ -88,7 +109,14 @@ export default function BrandMediaPicker({
|
|||||||
data-tip="用作草稿预览,点保存后才生效"
|
data-tip="用作草稿预览,点保存后才生效"
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={item.url} alt={`${SLOT_LABEL[slot]}候选图`} className="w-full h-full object-contain" loading="lazy" />
|
<img
|
||||||
|
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
|
||||||
|
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
|
||||||
|
sizes="(max-width:520px) 23vw, 96px"
|
||||||
|
alt={`${SLOT_LABEL[slot]}候选图`}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||||
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, ImagePlus, Images, Link2, X } from "lucide-react";
|
import { Check, Crop, ImagePlus, Images, Link2, X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import {
|
import {
|
||||||
AdminSegmented,
|
AdminSegmented,
|
||||||
AdminSettingsActions,
|
AdminSettingsActions,
|
||||||
AdminSettingsGroup,
|
AdminSettingsGroup,
|
||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
|
import BrandCropModal from "@/components/BrandCropModal";
|
||||||
import BrandLockup from "@/components/BrandLockup";
|
import BrandLockup from "@/components/BrandLockup";
|
||||||
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
BRAND_LOGO_FITS,
|
|
||||||
BRAND_LOGO_SIZES,
|
|
||||||
brandAccept,
|
brandAccept,
|
||||||
brandAliasPath,
|
brandAliasPath,
|
||||||
brandFileOk,
|
brandFileOk,
|
||||||
|
normalizeBrandURL,
|
||||||
type BrandLogoFit,
|
type BrandLogoFit,
|
||||||
type BrandLogoSize,
|
type BrandLogoSize,
|
||||||
type BrandMark,
|
type BrandMark,
|
||||||
type BrandSlot,
|
type BrandSlot,
|
||||||
} from "@/lib/brand";
|
} from "@/lib/brand";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
import BrandMediaPicker from "./BrandMediaPicker";
|
import BrandMediaPicker from "./BrandMediaPicker";
|
||||||
|
|
||||||
type SlotDraft = {
|
type SlotDraft = {
|
||||||
@@ -76,6 +77,9 @@ export default function BrandSeoPanel({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState("");
|
const [saveError, setSaveError] = useState("");
|
||||||
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
||||||
|
const [cropSlot, setCropSlot] = useState<BrandSlot | null>(null);
|
||||||
|
const [cropSeedFile, setCropSeedFile] = useState<File | null>(null);
|
||||||
|
const [cropSeedUrl, setCropSeedUrl] = useState("");
|
||||||
const [copiedSlot, setCopiedSlot] = useState<BrandSlot | null>(null);
|
const [copiedSlot, setCopiedSlot] = useState<BrandSlot | null>(null);
|
||||||
const blobs = useRef<string[]>([]);
|
const blobs = useRef<string[]>([]);
|
||||||
const saveGen = useRef(0);
|
const saveGen = useRef(0);
|
||||||
@@ -122,6 +126,18 @@ export default function BrandSeoPanel({
|
|||||||
return draft.committed;
|
return draft.committed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCrop = (slot: BrandSlot, file: File | null, url: string) => {
|
||||||
|
setCropSlot(slot);
|
||||||
|
setCropSeedFile(file);
|
||||||
|
setCropSeedUrl(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCrop = () => {
|
||||||
|
setCropSlot(null);
|
||||||
|
setCropSeedFile(null);
|
||||||
|
setCropSeedUrl("");
|
||||||
|
};
|
||||||
|
|
||||||
const discard = () => {
|
const discard = () => {
|
||||||
[light, dark, favicon].forEach((d) => {
|
[light, dark, favicon].forEach((d) => {
|
||||||
if (d.blob) URL.revokeObjectURL(d.blob);
|
if (d.blob) URL.revokeObjectURL(d.blob);
|
||||||
@@ -233,8 +249,13 @@ export default function BrandSeoPanel({
|
|||||||
<div key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
|
<div key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
|
||||||
<div className={`admin-brand-swatch ${swatchClass}`}>
|
<div className={`admin-brand-swatch ${swatchClass}`}>
|
||||||
{preview ? (
|
{preview ? (
|
||||||
|
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={preview} alt="" />
|
<img
|
||||||
|
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(384)) : preview}
|
||||||
|
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 384) : undefined}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span>未设置</span>
|
<span>未设置</span>
|
||||||
)}
|
)}
|
||||||
@@ -271,7 +292,14 @@ export default function BrandSeoPanel({
|
|||||||
toast(err);
|
toast(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// SVG / ICO 无需裁剪,直接走原流程
|
||||||
|
const isVector = file.name.toLowerCase().endsWith(".svg");
|
||||||
|
const isIco = file.name.toLowerCase().endsWith(".ico");
|
||||||
|
if (isVector || isIco) {
|
||||||
replaceFile(slot, file);
|
replaceFile(slot, file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openCrop(slot, file, "");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -283,6 +311,17 @@ export default function BrandSeoPanel({
|
|||||||
>
|
>
|
||||||
<Images size={14} /> 库
|
<Images size={14} /> 库
|
||||||
</button>
|
</button>
|
||||||
|
{preview && !/\.(svg|ico)(\?.*)?$/i.test(preview) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={saving}
|
||||||
|
title="调整画幅、铺法或重新裁剪"
|
||||||
|
onClick={() => openCrop(slot, null, preview)}
|
||||||
|
>
|
||||||
|
<Crop size={14} /> 调整
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="admin-icon-btn"
|
className="admin-icon-btn"
|
||||||
@@ -331,7 +370,7 @@ export default function BrandSeoPanel({
|
|||||||
frame
|
frame
|
||||||
name={initial.site_name}
|
name={initial.site_name}
|
||||||
wordmark={initial.site_wordmark}
|
wordmark={initial.site_wordmark}
|
||||||
slogan={initial.site_slogan}
|
slogan={initial.brand_slogan_visible ? initial.site_slogan : ""}
|
||||||
logoLight={previewOf(light)}
|
logoLight={previewOf(light)}
|
||||||
logoDark={previewOf(dark)}
|
logoDark={previewOf(dark)}
|
||||||
/>
|
/>
|
||||||
@@ -358,46 +397,9 @@ export default function BrandSeoPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{mark !== "text" ? (
|
{mark !== "text" ? (
|
||||||
<>
|
<p className="admin-brand-studio-field text-[11.5px] self-center mb-0.5" style={{ color: "var(--ink-3)" }}>
|
||||||
<div className="admin-brand-studio-field">
|
画幅与铺法在上传/调整图片的裁剪弹窗中设置
|
||||||
<span className="admin-brand-studio-kicker">比例</span>
|
</p>
|
||||||
<div className="admin-brand-size-row" role="group" aria-label="Logo 比例">
|
|
||||||
{BRAND_LOGO_SIZES.map((item) => {
|
|
||||||
const on = logoSize === item.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
|
|
||||||
onClick={() => {
|
|
||||||
setLogoSize(item.id);
|
|
||||||
if (saveError) setSaveError("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="admin-brand-size-chip-label">{item.label}</span>
|
|
||||||
<span className="admin-brand-size-chip-px">
|
|
||||||
{item.w}×{item.h}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-brand-studio-field">
|
|
||||||
<span className="admin-brand-studio-kicker">铺法</span>
|
|
||||||
<AdminSegmented
|
|
||||||
role="group"
|
|
||||||
size="sm"
|
|
||||||
ariaLabel="适配方式"
|
|
||||||
value={logoFit}
|
|
||||||
onChange={(v) => {
|
|
||||||
setLogoFit(v);
|
|
||||||
if (saveError) setSaveError("");
|
|
||||||
}}
|
|
||||||
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -412,11 +414,60 @@ export default function BrandSeoPanel({
|
|||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
onPick={(item) => {
|
onPick={(item) => {
|
||||||
if (!picker) return;
|
if (!picker) return;
|
||||||
const [prev, set] = drafts[picker];
|
const slot = picker;
|
||||||
|
setPicker(null);
|
||||||
|
// 已是品牌目录的历史图(含裁剪产物):直接引用,不再复制
|
||||||
|
const brandURL = normalizeBrandURL(slot, item.url);
|
||||||
|
if (brandURL) {
|
||||||
|
const [prev, set] = drafts[slot];
|
||||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||||
set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url });
|
set({ committed: brandURL, file: null, blob: "", mediaId: null, mediaUrl: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lower = item.url.toLowerCase();
|
||||||
|
const isVector = lower.endsWith(".svg");
|
||||||
|
const isIco = lower.endsWith(".ico");
|
||||||
|
// SVG / ICO 不可裁剪,复制一份进品牌目录后引用
|
||||||
|
if ((isVector || isIco) && item.attachmentId != null) {
|
||||||
|
const [prev, set] = drafts[slot];
|
||||||
|
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||||
|
set({ ...prev, file: null, blob: "", mediaId: item.attachmentId, mediaUrl: item.url });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openCrop(slot, null, item.url);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{cropSlot ? (
|
||||||
|
<BrandCropModal
|
||||||
|
open
|
||||||
|
slot={cropSlot}
|
||||||
|
seedFile={cropSeedFile}
|
||||||
|
seedUrl={cropSeedUrl}
|
||||||
|
logoSize={logoSize}
|
||||||
|
logoFit={logoFit}
|
||||||
|
onSizeChange={(s) => {
|
||||||
|
setLogoSize(s);
|
||||||
|
if (saveError) setSaveError("");
|
||||||
|
}}
|
||||||
|
onFitChange={(f) => {
|
||||||
|
setLogoFit(f);
|
||||||
|
if (saveError) setSaveError("");
|
||||||
|
}}
|
||||||
|
onClose={closeCrop}
|
||||||
|
onSeedConsumed={() => {
|
||||||
|
setCropSeedFile(null);
|
||||||
|
setCropSeedUrl("");
|
||||||
|
}}
|
||||||
|
onCropped={(url) => {
|
||||||
|
const [, set] = drafts[cropSlot];
|
||||||
|
set((prev) => {
|
||||||
|
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||||
|
return { committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" };
|
||||||
|
});
|
||||||
|
closeCrop();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -64,6 +65,7 @@ import Modal from "@/components/Modal";
|
|||||||
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
|
import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
|
||||||
|
import { lintWidgetCode, type WidgetLintIssue } from "@/lib/widgetLint";
|
||||||
|
|
||||||
type Side = "left" | "right";
|
type Side = "left" | "right";
|
||||||
type SectionId = SidebarScope | "widgets";
|
type SectionId = SidebarScope | "widgets";
|
||||||
@@ -102,6 +104,9 @@ const SIDES: { scope: SidebarScope; icon: LucideIcon; desc: string; sides: Side[
|
|||||||
const SIDE_LABEL: Record<Side, string> = { left: "左栏", right: "右栏" };
|
const SIDE_LABEL: Record<Side, string> = { left: "左栏", right: "右栏" };
|
||||||
const WIDGET_CODE_MAX_BYTES = 64 * 1024;
|
const WIDGET_CODE_MAX_BYTES = 64 * 1024;
|
||||||
|
|
||||||
|
// 提交给后端的工具草稿(混写框拆分后的三字段 + 名称)
|
||||||
|
type WidgetDraftCode = { name: string; html: string; css: string; js: string };
|
||||||
|
|
||||||
export default function SidebarAdmin({
|
export default function SidebarAdmin({
|
||||||
initialConfig,
|
initialConfig,
|
||||||
initialWidgets,
|
initialWidgets,
|
||||||
@@ -125,6 +130,10 @@ export default function SidebarAdmin({
|
|||||||
const [draftName, setDraftName] = useState("");
|
const [draftName, setDraftName] = useState("");
|
||||||
const [mixed, setMixed] = useState("");
|
const [mixed, setMixed] = useState("");
|
||||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
// 保存时静态扫描结果(error 禁止保存并列在此处;warn 弹二次确认框)
|
||||||
|
const [lintIssues, setLintIssues] = useState<WidgetLintIssue[] | null>(null);
|
||||||
|
const [warnConfirmOpen, setWarnConfirmOpen] = useState(false);
|
||||||
|
const pendingCodeRef = useRef<WidgetDraftCode | null>(null);
|
||||||
|
|
||||||
const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]);
|
const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]);
|
||||||
const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
|
const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
|
||||||
@@ -196,6 +205,9 @@ export default function SidebarAdmin({
|
|||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setDraftName("");
|
setDraftName("");
|
||||||
setMixed("");
|
setMixed("");
|
||||||
|
setLintIssues(null);
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
pendingCodeRef.current = null;
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,14 +215,15 @@ export default function SidebarAdmin({
|
|||||||
setEditingId(w.id);
|
setEditingId(w.id);
|
||||||
setDraftName(w.name);
|
setDraftName(w.name);
|
||||||
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
|
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
|
||||||
|
setLintIssues(null);
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
pendingCodeRef.current = null;
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitWidget = async () => {
|
const doSubmitWidget = async (code: WidgetDraftCode) => {
|
||||||
if (!draftOk) return;
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const code = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
|
|
||||||
if (editingId == null) {
|
if (editingId == null) {
|
||||||
await apiAdminCreateSidebarWidget(code);
|
await apiAdminCreateSidebarWidget(code);
|
||||||
toast("工具已创建", "ok");
|
toast("工具已创建", "ok");
|
||||||
@@ -220,6 +233,9 @@ export default function SidebarAdmin({
|
|||||||
toast("工具已保存", "ok");
|
toast("工具已保存", "ok");
|
||||||
}
|
}
|
||||||
setEditorOpen(false);
|
setEditorOpen(false);
|
||||||
|
setLintIssues(null);
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
pendingCodeRef.current = null;
|
||||||
await reloadAll();
|
await reloadAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : "保存失败", "error");
|
toast(e instanceof Error ? e.message : "保存失败", "error");
|
||||||
@@ -228,6 +244,34 @@ export default function SidebarAdmin({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 保存按钮:先静态扫描——有 error 禁止保存并列出;只有 warn 弹确认框;无问题直接保存
|
||||||
|
const submitWidget = () => {
|
||||||
|
if (!draftOk || busy) return;
|
||||||
|
const code: WidgetDraftCode = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
|
||||||
|
const issues = lintWidgetCode(code);
|
||||||
|
setLintIssues(issues.length ? issues : null);
|
||||||
|
if (issues.some((i) => i.level === "error")) return;
|
||||||
|
const warns = issues.filter((i) => i.level === "warn");
|
||||||
|
if (warns.length > 0) {
|
||||||
|
pendingCodeRef.current = code;
|
||||||
|
setWarnConfirmOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void doSubmitWidget(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmWarnSubmit = () => {
|
||||||
|
const code = pendingCodeRef.current;
|
||||||
|
pendingCodeRef.current = null;
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
if (code) void doSubmitWidget(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelWarnSubmit = () => {
|
||||||
|
pendingCodeRef.current = null;
|
||||||
|
setWarnConfirmOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleWidget = async (w: SidebarWidgetRecord) => {
|
const toggleWidget = async (w: SidebarWidgetRecord) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -402,7 +446,7 @@ export default function SidebarAdmin({
|
|||||||
<SectionHead
|
<SectionHead
|
||||||
icon={<Code2 size={16} />}
|
icon={<Code2 size={16} />}
|
||||||
title="自定义工具"
|
title="自定义工具"
|
||||||
desc="HTML + CSS + JS 代码块,前台原样渲染执行(不做消毒)"
|
desc="HTML + CSS + JS 代码块,在独立 Shadow DOM 内渲染执行(不做消毒)"
|
||||||
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
|
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
|
||||||
/>
|
/>
|
||||||
<div className="p-4 sm:p-5">
|
<div className="p-4 sm:p-5">
|
||||||
@@ -487,7 +531,7 @@ export default function SidebarAdmin({
|
|||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
|
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
|
||||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => void submitWidget()}>
|
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => submitWidget()}>
|
||||||
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
|
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -506,13 +550,20 @@ export default function SidebarAdmin({
|
|||||||
<textarea
|
<textarea
|
||||||
className="field w-full min-h-[240px] font-mono text-[12.5px]"
|
className="field w-full min-h-[240px] font-mono text-[12.5px]"
|
||||||
value={mixed}
|
value={mixed}
|
||||||
onChange={(e) => setMixed(e.target.value)}
|
onChange={(e) => {
|
||||||
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 页面加载后在工具位置执行\n</script>`}
|
setMixed(e.target.value);
|
||||||
|
setLintIssues(null);
|
||||||
|
}}
|
||||||
|
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 脚本参数:(root, shadow, toolId, registerCleanup)\n // 元素查找一律从 root 出发:root.querySelector(".my-banner")\n</script>`}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{lintIssues && lintIssues.length > 0 && <WidgetLintPanel issues={lintIssues} />}
|
||||||
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
|
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
|
||||||
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:window.J13 / 主题变量</summary>
|
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:脚本参数 / window.J13 / 主题变量</summary>
|
||||||
<div className="mt-2 space-y-1.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
<div className="mt-2 space-y-1.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
|
||||||
|
<p>脚本接收参数 <code>(root, shadow, toolId, registerCleanup)</code>:元素查找一律 <code>root.querySelector(...)</code>,禁止 <code>document</code> 全局查找(同页多个同款工具会互相取错)</p>
|
||||||
|
<p>定时器 / 监听器请通过 <code>registerCleanup(fn)</code> 注册清理,离开页面时自动执行(如 <code>clearInterval</code>)</p>
|
||||||
|
<p>样式仅在工具内生效,直接写类名即可;禁止 <code>body</code> / <code>html</code> / <code>*</code> 全局选择器与 <code>position: fixed</code>,变量请定义在 <code>.tool-root</code> 上</p>
|
||||||
<p><code>J13.user</code>:当前登录用户(未登录为 null)</p>
|
<p><code>J13.user</code>:当前登录用户(未登录为 null)</p>
|
||||||
<p><code>J13.theme.get()</code> 返回 "light" | "dark";<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
|
<p><code>J13.theme.get()</code> 返回 "light" | "dark";<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
|
||||||
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
|
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
|
||||||
@@ -520,11 +571,22 @@ export default function SidebarAdmin({
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<p className="meta text-[11.5px]">
|
<p className="meta text-[11.5px]">
|
||||||
CSS 自动限定在本板块内、不污染其他区域;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
工具运行在独立 Shadow DOM 内,样式不外泄;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 仅 warn 级问题时的二次确认 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={warnConfirmOpen}
|
||||||
|
title="代码存在风险提醒"
|
||||||
|
message={`检测到 ${lintIssues?.filter((i) => i.level === "warn").length ?? 0} 条提醒(不阻止保存,明细见编辑器列表)。确认了解风险并保存?`}
|
||||||
|
confirmLabel="仍要保存"
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={confirmWarnSubmit}
|
||||||
|
onCancel={cancelWarnSubmit}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 删除工具确认 */}
|
{/* 删除工具确认 */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteId != null}
|
open={deleteId != null}
|
||||||
@@ -550,6 +612,75 @@ export default function SidebarAdmin({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── 工具代码静态扫描结果面板 ─── */
|
||||||
|
const LINT_FIELD_LABEL: Record<WidgetLintIssue["field"], string> = {
|
||||||
|
js: "JS",
|
||||||
|
css: "CSS",
|
||||||
|
html: "HTML",
|
||||||
|
};
|
||||||
|
|
||||||
|
function WidgetLintPanel({ issues }: { issues: WidgetLintIssue[] }) {
|
||||||
|
const errors = issues.filter((i) => i.level === "error");
|
||||||
|
const warns = issues.filter((i) => i.level === "warn");
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-2.5"
|
||||||
|
style={{
|
||||||
|
borderColor: "color-mix(in srgb, var(--danger) 40%, var(--line))",
|
||||||
|
background: "var(--danger-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>
|
||||||
|
<AlertTriangle size={13} aria-hidden />
|
||||||
|
{errors.length} 个错误,修复后才能保存
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{errors.map((issue, idx) => (
|
||||||
|
<LintIssueRow key={`e-${idx}`} issue={issue} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warns.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-2.5"
|
||||||
|
style={{
|
||||||
|
borderColor: "color-mix(in srgb, var(--gold) 40%, var(--line))",
|
||||||
|
background: "var(--gold-soft)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--gold)" }}>
|
||||||
|
<Info size={13} aria-hidden />
|
||||||
|
{warns.length} 个提醒,确认风险后仍可保存
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{warns.map((issue, idx) => (
|
||||||
|
<LintIssueRow key={`w-${idx}`} issue={issue} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LintIssueRow({ issue }: { issue: WidgetLintIssue }) {
|
||||||
|
const tone = issue.level === "error" ? "var(--danger)" : "var(--gold)";
|
||||||
|
return (
|
||||||
|
<li className="flex gap-1.5 text-[11.5px] leading-relaxed">
|
||||||
|
<span
|
||||||
|
className="mt-px shrink-0 rounded px-1 py-px font-mono text-[10.5px] font-semibold tabular-nums"
|
||||||
|
style={{ color: tone, background: "color-mix(in srgb, currentColor 10%, transparent)" }}
|
||||||
|
>
|
||||||
|
{LINT_FIELD_LABEL[issue.field]} {issue.line}:{issue.column}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--ink-2)" }}>{issue.message}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── 分区卡片头 ─── */
|
/* ─── 分区卡片头 ─── */
|
||||||
function SectionHead({
|
function SectionHead({
|
||||||
icon,
|
icon,
|
||||||
|
|||||||
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 />;
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import { formatDate, formatRelative } from "@/lib/format";
|
|||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||||
import RoleBadge from "@/components/RoleBadge";
|
import RoleBadge from "@/components/RoleBadge";
|
||||||
import { postPath, userPath } from "@/lib/urlStyle";
|
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
|
||||||
import {
|
import {
|
||||||
AdminEmpty,
|
AdminEmpty,
|
||||||
AdminPager,
|
AdminPager,
|
||||||
@@ -356,7 +356,7 @@ export default function UserAuditClient({
|
|||||||
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
|
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
|
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
|
||||||
className="text-[11.5px] hover:underline"
|
className="text-[11.5px] hover:underline"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
AD_IMAGE_WIDTH,
|
AD_IMAGE_WIDTH,
|
||||||
AD_TEXT_COL_WIDTH,
|
AD_TEXT_COL_WIDTH,
|
||||||
} from "@/lib/ads";
|
} from "@/lib/ads";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
type Kind = "image" | "text";
|
type Kind = "image" | "text";
|
||||||
|
|
||||||
@@ -183,7 +184,8 @@ export default function AdsBuyClient() {
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={selectedPay.qr_url}
|
src={isOwnImage(selectedPay.qr_url) ? variantURL(selectedPay.qr_url, pickVariantWidth(160)) : selectedPay.qr_url}
|
||||||
|
srcSet={isOwnImage(selectedPay.qr_url) ? densitySrcSet(selectedPay.qr_url, 160) : undefined}
|
||||||
alt="收款码"
|
alt="收款码"
|
||||||
className="mx-auto h-40 w-40 rounded-xl border bg-white object-contain p-2"
|
className="mx-auto h-40 w-40 rounded-xl border bg-white object-contain p-2"
|
||||||
style={{ borderColor: "var(--line)" }}
|
style={{ borderColor: "var(--line)" }}
|
||||||
@@ -381,7 +383,8 @@ export default function AdsBuyClient() {
|
|||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={selectedPay.qr_url}
|
src={isOwnImage(selectedPay.qr_url) ? variantURL(selectedPay.qr_url, pickVariantWidth(144)) : selectedPay.qr_url}
|
||||||
|
srcSet={isOwnImage(selectedPay.qr_url) ? densitySrcSet(selectedPay.qr_url, 144) : undefined}
|
||||||
alt={`${selectedPay.name}收款码`}
|
alt={`${selectedPay.name}收款码`}
|
||||||
className="h-36 w-36 object-contain"
|
className="h-36 w-36 object-contain"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone, Pin } from "lucide-react";
|
||||||
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
|
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
|
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
|
||||||
import MarkdownBody from "@/components/MarkdownBody";
|
import MarkdownBody from "@/components/MarkdownBody";
|
||||||
import PostToc from "@/components/PostToc";
|
import PostToc from "@/components/PostToc";
|
||||||
@@ -59,7 +59,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
|||||||
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
|
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
|
||||||
const sidebarPages = overview?.sidebar_pages ?? [];
|
const sidebarPages = overview?.sidebar_pages ?? [];
|
||||||
const announcements = overview?.announcements ?? [];
|
const announcements = overview?.announcements ?? [];
|
||||||
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
|
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
|
||||||
const siteLead = settings.site_description?.trim() || undefined;
|
const siteLead = settings.site_description?.trim() || undefined;
|
||||||
const urlStyle = settings.url_style;
|
const urlStyle = settings.url_style;
|
||||||
const toc = extractToc(a.content);
|
const toc = extractToc(a.content);
|
||||||
@@ -116,7 +116,11 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
|||||||
showTimeOfDay
|
showTimeOfDay
|
||||||
badges={
|
badges={
|
||||||
<>
|
<>
|
||||||
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
{a.pinned ? (
|
||||||
|
<span className="badge-pin" title="置顶" aria-label="置顶">
|
||||||
|
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Megaphone } from "lucide-react";
|
import { Megaphone, Pin } from "lucide-react";
|
||||||
import { fetchAnnouncementsPage, fetchOverview, type Announcement } from "@/lib/api";
|
import { fetchAnnouncementsPage, fetchOverview, type Announcement } from "@/lib/api";
|
||||||
import { authCookieHeader } from "@/lib/cookies";
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { isAdminOrAbove } from "@/lib/roles";
|
import { hasPerm, PERMS } from "@/lib/roles";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import Pagination from "@/components/Pagination";
|
import Pagination from "@/components/Pagination";
|
||||||
import {
|
import {
|
||||||
@@ -57,7 +57,11 @@ function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlS
|
|||||||
>
|
>
|
||||||
置顶公告
|
置顶公告
|
||||||
</span>
|
</span>
|
||||||
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
{a.pinned ? (
|
||||||
|
<span className="badge-pin" title="置顶" aria-label="置顶">
|
||||||
|
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||||
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
|
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
|
||||||
{formatDate(a.created_at)}
|
{formatDate(a.created_at)}
|
||||||
@@ -87,7 +91,11 @@ function AnnouncementRow({ a, showDivider, urlStyle }: { a: Announcement; showDi
|
|||||||
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
|
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{a.pinned ? <span className="badge-pin">置顶</span> : null}
|
{a.pinned ? (
|
||||||
|
<span className="badge-pin" title="置顶" aria-label="置顶">
|
||||||
|
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||||
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
|
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
|
||||||
{formatDate(a.created_at)}
|
{formatDate(a.created_at)}
|
||||||
@@ -133,7 +141,7 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
|
|||||||
const sidebarPages = overview?.sidebar_pages ?? [];
|
const sidebarPages = overview?.sidebar_pages ?? [];
|
||||||
const siteLead = settings.site_description?.trim() || undefined;
|
const siteLead = settings.site_description?.trim() || undefined;
|
||||||
const urlStyle = settings.url_style;
|
const urlStyle = settings.url_style;
|
||||||
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
|
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
|
||||||
|
|
||||||
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
|
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
|
||||||
const featured = page === 1 ? announcements.find((a) => a.pinned) : undefined;
|
const featured = page === 1 ? announcements.find((a) => a.pinned) : undefined;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
ChevronsDown,
|
ChevronsDown,
|
||||||
Smile,
|
Smile,
|
||||||
|
VolumeX,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
apiListChatMessages,
|
apiListChatMessages,
|
||||||
@@ -41,11 +42,13 @@ import {
|
|||||||
RT_CHAT_MESSAGE,
|
RT_CHAT_MESSAGE,
|
||||||
RT_CHAT_MEMBERSHIP,
|
RT_CHAT_MEMBERSHIP,
|
||||||
RT_CHAT_RECALLED,
|
RT_CHAT_RECALLED,
|
||||||
|
RT_CHAT_MEMBER_MUTED,
|
||||||
} from "@/lib/realtime";
|
} from "@/lib/realtime";
|
||||||
import type {
|
import type {
|
||||||
RtChatMessageData,
|
RtChatMessageData,
|
||||||
RtChatMembershipData,
|
RtChatMembershipData,
|
||||||
RtChatRecalledData,
|
RtChatRecalledData,
|
||||||
|
RtChatMemberMutedData,
|
||||||
} from "@/lib/realtime";
|
} from "@/lib/realtime";
|
||||||
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
|
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
@@ -55,6 +58,7 @@ import LevelBadge from "@/components/LevelBadge";
|
|||||||
import BadgeChip from "@/components/BadgeChip";
|
import BadgeChip from "@/components/BadgeChip";
|
||||||
import AutolinkText from "@/components/AutolinkText";
|
import AutolinkText from "@/components/AutolinkText";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||||
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
|
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
|
||||||
import EmojiPicker from "@/components/EmojiPicker";
|
import EmojiPicker from "@/components/EmojiPicker";
|
||||||
@@ -577,6 +581,14 @@ export default function ChatRoomClient({
|
|||||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
||||||
const [hasMore, setHasMore] = useState(initialHasMore);
|
const [hasMore, setHasMore] = useState(initialHasMore);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
// 加载历史的在飞锁(同步 ref,滚动事件连发时 state 闭包来不及更新,见 loadMore)
|
||||||
|
const loadingMoreRef = useRef(false);
|
||||||
|
// 上翻加载完成后恢复 scrollTop:column-reverse 理论上 prepend 不移动视口,
|
||||||
|
// 但 React 重渲染期间某些浏览器/场景会重置 scrollTop,强制恢复兜底
|
||||||
|
const stabilizeScrollRef = useRef(false);
|
||||||
|
const stabilizeScrollValueRef = useRef(0);
|
||||||
|
// handleScroll 冷却:一次自动加载后 800ms 内不再触发,防链式冲顶
|
||||||
|
const lastAutoLoadRef = useRef(0);
|
||||||
// 「回到底部」悬浮钮:column-reverse 下 scrollTop≤0(视觉底部为 0,上翻为负),
|
// 「回到底部」悬浮钮:column-reverse 下 scrollTop≤0(视觉底部为 0,上翻为负),
|
||||||
// -scrollTop 即距底部距离;newBelowCount 为翻历史期间新到的、尚未“看见”的消息数
|
// -scrollTop 即距底部距离;newBelowCount 为翻历史期间新到的、尚未“看见”的消息数
|
||||||
const [showJumpBottom, setShowJumpBottom] = useState(false);
|
const [showJumpBottom, setShowJumpBottom] = useState(false);
|
||||||
@@ -591,8 +603,21 @@ export default function ChatRoomClient({
|
|||||||
y: number;
|
y: number;
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
/** 头像右键菜单:对成员的管理操作 */
|
||||||
|
const [userCtxMenu, setUserCtxMenu] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
user: User;
|
||||||
|
member?: ChatRoomMember;
|
||||||
|
} | null>(null);
|
||||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||||
const [confirmBusy, setConfirmBusy] = useState(false);
|
const [confirmBusy, setConfirmBusy] = useState(false);
|
||||||
|
/** 禁言弹窗(含时长选择,QQ 风格) */
|
||||||
|
const [muteTarget, setMuteTarget] = useState<{
|
||||||
|
uid: number;
|
||||||
|
nickname: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [muteBusy, setMuteBusy] = useState(false);
|
||||||
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
|
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
|
||||||
const [mentionOpen, setMentionOpen] = useState(false);
|
const [mentionOpen, setMentionOpen] = useState(false);
|
||||||
const [mentionFilter, setMentionFilter] = useState("");
|
const [mentionFilter, setMentionFilter] = useState("");
|
||||||
@@ -683,8 +708,11 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const openUserCard = useCallback(
|
const openUserCard = useCallback(
|
||||||
(u: User) => {
|
(u: User) => {
|
||||||
|
// 菜单互斥:打开资料卡前关掉两个右键菜单
|
||||||
|
setCtxMenu(null);
|
||||||
|
setUserCtxMenu(null);
|
||||||
const mem = members.find((m) => m.user_id === u.id);
|
const mem = members.find((m) => m.user_id === u.id);
|
||||||
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted });
|
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted, mutedUntil: mem?.muted_until });
|
||||||
},
|
},
|
||||||
[members]
|
[members]
|
||||||
);
|
);
|
||||||
@@ -793,8 +821,9 @@ export default function ChatRoomClient({
|
|||||||
if (-el.scrollTop < 120) {
|
if (-el.scrollTop < 120) {
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
markReadToLatest();
|
markReadToLatest();
|
||||||
} else {
|
} else if (data.sender_id !== 0) {
|
||||||
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示
|
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示;
|
||||||
|
// 系统提示(禁言/解禁等管理动作)不计入「新消息」提醒
|
||||||
setNewBelowCount((c) => c + 1);
|
setNewBelowCount((c) => c + 1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -845,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 {
|
||||||
@@ -854,44 +887,97 @@ export default function ChatRoomClient({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
return off;
|
return off;
|
||||||
}, [roomId, router]);
|
}, [roomId, router, refreshMembers]);
|
||||||
|
|
||||||
|
// 禁言/解禁实时通知:更新成员列表 + 插入系统提示
|
||||||
|
useEffect(() => {
|
||||||
|
const off = realtime.on<RtChatMemberMutedData>(
|
||||||
|
RT_CHAT_MEMBER_MUTED,
|
||||||
|
(data) => {
|
||||||
|
if (!data || data.room_id !== roomId) return;
|
||||||
|
setMembers((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.user_id === data.user_id
|
||||||
|
? { ...m, muted: data.muted, muted_until: data.muted_until }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return off;
|
||||||
|
}, [roomId, members, meId]);
|
||||||
|
|
||||||
|
// 禁言/解禁系统提示(居中展示,类似撤回提示)
|
||||||
|
const mutedNotice = useMemo(() => {
|
||||||
|
const mem = members.find((m) => m.user_id === meId);
|
||||||
|
if (!mem?.muted) return null;
|
||||||
|
return mem;
|
||||||
|
}, [members, meId]);
|
||||||
|
|
||||||
|
const muteCountdown = useCountdown(mutedNotice?.muted_until, () => {
|
||||||
|
// 倒计时到期:前端自动解除禁言状态(惰性清理,无需等待 WS 或发消息)
|
||||||
|
setMembers((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.user_id === meId ? { ...m, muted: false, muted_until: null } : m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
|
// 在飞锁必须用 ref:滚动事件在一次上翻里连发,React 尚未重渲染时
|
||||||
|
// loadingMore(state 闭包)仍是 false,会并发连发多页请求。
|
||||||
|
if (loadingMoreRef.current) return;
|
||||||
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
|
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
|
||||||
|
loadingMoreRef.current = true;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
const el0 = listRef.current;
|
// 保存当前滚动位置,prepend 完成后恢复(防 React 重渲染导致的意外重置)
|
||||||
const prevHeight = el0?.scrollHeight ?? 0;
|
const el = listRef.current;
|
||||||
const prevTop = el0?.scrollTop ?? 0;
|
const savedScrollTop = el?.scrollTop ?? 0;
|
||||||
try {
|
try {
|
||||||
const before = messages[0].id;
|
const before = messages[0].id;
|
||||||
const res = await apiListChatMessages(roomId, before, 30);
|
const res = await apiListChatMessages(roomId, before, 30);
|
||||||
if ((res.messages || []).length > 0) {
|
if ((res.messages || []).length > 0) {
|
||||||
setMessages((prev) => [...res.messages, ...prev]);
|
// 去重兜底:防止旧闭包重复拉同一页导致重复 key
|
||||||
setHasMore(res.has_more);
|
setMessages((prev) => {
|
||||||
requestAnimationFrame(() => {
|
const seen = new Set(prev.map((p) => p.id));
|
||||||
const el = listRef.current;
|
const fresh = res.messages.filter((x) => !seen.has(x.id));
|
||||||
if (!el) return;
|
return fresh.length ? [...fresh, ...prev] : prev;
|
||||||
// column-reverse:历史插在上方,scrollTop 为负;
|
|
||||||
// 新增高度 H 使锚点内容距底部多 H,需 scrollTop 再减 H(更负)保持视口不动
|
|
||||||
el.scrollTop = prevTop - (el.scrollHeight - prevHeight);
|
|
||||||
});
|
});
|
||||||
|
setHasMore(res.has_more);
|
||||||
|
// 标记需要在提交后恢复滚动位置
|
||||||
|
stabilizeScrollValueRef.current = savedScrollTop;
|
||||||
|
stabilizeScrollRef.current = true;
|
||||||
} else {
|
} else {
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast("加载历史消息失败");
|
toast("加载历史消息失败");
|
||||||
} finally {
|
} finally {
|
||||||
|
loadingMoreRef.current = false;
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
}, [canView, hasMore, loadingMore, messages, roomId]);
|
}, [canView, hasMore, loadingMore, messages, roomId]);
|
||||||
|
|
||||||
|
// prepend 完成后恢复滚动位置:column-reverse 理论上应稳定,但某些浏览器在
|
||||||
|
// React 提交 DOM 时会重置 scrollTop,强制恢复以保证视口不动
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!stabilizeScrollRef.current) return;
|
||||||
|
stabilizeScrollRef.current = false;
|
||||||
|
const el = listRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = stabilizeScrollValueRef.current;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = listRef.current;
|
const el = listRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
// column-reverse:scrollTop≤0(视觉底部为 0,上翻为负),-scrollTop 即距底部距离
|
// column-reverse:scrollTop≤0(视觉底部为 0,上翻为负),-scrollTop 即距底部距离
|
||||||
const fromBottom = -el.scrollTop;
|
const fromBottom = -el.scrollTop;
|
||||||
// 滚向上方历史:距顶部不足 60px 时加载更多
|
// 滚向上方历史:距顶部不足 60px 时加载更多;加 800ms 冷却防链式冲顶
|
||||||
if (el.scrollHeight - el.clientHeight - fromBottom < 60) {
|
const now = Date.now();
|
||||||
|
if (el.scrollHeight - el.clientHeight - fromBottom < 60 && now - lastAutoLoadRef.current > 800) {
|
||||||
|
lastAutoLoadRef.current = now;
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
if (fromBottom < 60) {
|
if (fromBottom < 60) {
|
||||||
@@ -1119,7 +1205,7 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const handleRichInput = () => {
|
const handleRichInput = () => {
|
||||||
const el = inputRef.current;
|
const el = inputRef.current;
|
||||||
if (!el) return;
|
if (!el || mutedNotice) return;
|
||||||
const text = domToText(el);
|
const text = domToText(el);
|
||||||
if (text === lastRichText.current) return;
|
if (text === lastRichText.current) return;
|
||||||
if (text.length > 2000) {
|
if (text.length > 2000) {
|
||||||
@@ -1153,6 +1239,10 @@ export default function ChatRoomClient({
|
|||||||
const handleRichKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
const handleRichKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
// IME 组合中不拦截按键
|
// IME 组合中不拦截按键
|
||||||
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
||||||
|
if (mutedNotice) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mentionOpen && mentionCandidates.length > 0) {
|
if (mentionOpen && mentionCandidates.length > 0) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1196,6 +1286,10 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
/** 粘贴一律按纯文本插入(换行转 <br>),避免富文本结构污染序列化 */
|
/** 粘贴一律按纯文本插入(换行转 <br>),避免富文本结构污染序列化 */
|
||||||
const handleRichPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
|
const handleRichPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
|
if (mutedNotice) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = e.clipboardData.getData("text/plain");
|
const text = e.clipboardData.getData("text/plain");
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -1226,6 +1320,9 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
|
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
|
||||||
if (m.recalled_at) return;
|
if (m.recalled_at) return;
|
||||||
|
// 菜单互斥:打开消息菜单前关掉头像菜单与资料卡
|
||||||
|
setUserCtxMenu(null);
|
||||||
|
setUserCard(null);
|
||||||
setCtxMenu({ x, y, message: m });
|
setCtxMenu({ x, y, message: m });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1309,23 +1406,59 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const handleMute = (uid: number, nickname: string, muted: boolean) => {
|
const handleMute = (uid: number, nickname: string, muted: boolean) => {
|
||||||
if (!canKickMembers) return;
|
if (!canKickMembers) return;
|
||||||
|
// 入口可能是头像右键菜单或成员列表按钮:先兜底关闭浮层菜单,
|
||||||
|
// 避免低概率时序下菜单残留并与弹窗叠层(菜单 z-index 高于弹窗)
|
||||||
|
setUserCtxMenu(null);
|
||||||
|
setCtxMenu(null);
|
||||||
|
if (muted) {
|
||||||
|
// 打开时长选择弹窗(QQ 风格)
|
||||||
|
setMuteTarget({ uid, nickname });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setConfirm({
|
setConfirm({
|
||||||
title: muted ? "禁言成员" : "解除禁言",
|
title: "解除禁言",
|
||||||
message: muted
|
message: `确定解除 ${nickname} 的禁言?`,
|
||||||
? `确定禁言 ${nickname}?禁言后无法在本群发言。`
|
confirmLabel: "解禁",
|
||||||
: `确定解除 ${nickname} 的禁言?`,
|
|
||||||
confirmLabel: muted ? "禁言" : "解禁",
|
|
||||||
danger: muted,
|
|
||||||
action: async () => {
|
action: async () => {
|
||||||
await apiMuteChatMember(roomId, uid, muted);
|
await apiMuteChatMember(roomId, uid, false);
|
||||||
setMembers((prev) =>
|
setMembers((prev) =>
|
||||||
prev.map((m) => (m.user_id === uid ? { ...m, muted } : m))
|
prev.map((m) =>
|
||||||
|
m.user_id === uid ? { ...m, muted: false, muted_until: null } : m
|
||||||
|
)
|
||||||
);
|
);
|
||||||
toast(muted ? "已禁言" : "已解禁", "ok");
|
toast("已解禁", "ok");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitMute = async (durationMinutes: number) => {
|
||||||
|
if (!muteTarget) return;
|
||||||
|
setMuteBusy(true);
|
||||||
|
try {
|
||||||
|
await apiMuteChatMember(roomId, muteTarget.uid, true, durationMinutes);
|
||||||
|
const until =
|
||||||
|
durationMinutes > 0
|
||||||
|
? new Date(Date.now() + durationMinutes * 60_000).toISOString()
|
||||||
|
: null;
|
||||||
|
setMembers((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.user_id === muteTarget.uid
|
||||||
|
? { ...m, muted: true, muted_until: until }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast(
|
||||||
|
durationMinutes > 0 ? `已禁言 ${formatMuteDuration(durationMinutes)}` : "已永久禁言",
|
||||||
|
"ok"
|
||||||
|
);
|
||||||
|
setMuteTarget(null);
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "禁言失败");
|
||||||
|
} finally {
|
||||||
|
setMuteBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSetAdmin = (uid: number, nickname: string, makeAdmin: boolean) => {
|
const handleSetAdmin = (uid: number, nickname: string, makeAdmin: boolean) => {
|
||||||
if (!siteOwner) return;
|
if (!siteOwner) return;
|
||||||
setConfirm({
|
setConfirm({
|
||||||
@@ -1349,7 +1482,11 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const canRecallMsg = (m: ChatMessage) => {
|
const canRecallMsg = (m: ChatMessage) => {
|
||||||
if (m.recalled_at) return false;
|
if (m.recalled_at) return false;
|
||||||
if (canModerateChat) return true;
|
if (canModerateChat) {
|
||||||
|
// 站长(站点 Owner)的消息仅全站监管可撤回,群主/群管/站点消息管无权
|
||||||
|
if (!oversee && isSiteOwner(m.sender.role)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return m.sender_id === meId && isRealMember;
|
return m.sender_id === meId && isRealMember;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1454,6 +1591,62 @@ export default function ChatRoomClient({
|
|||||||
return items;
|
return items;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 用户头像右键菜单:按权限展示成员管理项 */
|
||||||
|
const userMenuItems = (u: User, mem?: ChatRoomMember): CtxMenuItem[] => {
|
||||||
|
const close = () => setUserCtxMenu(null);
|
||||||
|
const items: CtxMenuItem[] = [
|
||||||
|
{
|
||||||
|
key: "profile",
|
||||||
|
label: "查看资料卡",
|
||||||
|
icon: <Users size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
close();
|
||||||
|
openUserCard(u);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (u.id === meId || isDirect) return items;
|
||||||
|
|
||||||
|
const isTargetOwner = mem?.role === "owner";
|
||||||
|
const isTargetAdmin = mem?.role === "admin";
|
||||||
|
|
||||||
|
if (canKickMembers && !isTargetOwner) {
|
||||||
|
const muted = mem?.muted ?? false;
|
||||||
|
items.push({
|
||||||
|
key: "mute",
|
||||||
|
label: muted ? "解禁" : "禁言",
|
||||||
|
icon: <VolumeX size={14} />,
|
||||||
|
danger: !muted,
|
||||||
|
onClick: () => {
|
||||||
|
close();
|
||||||
|
handleMute(u.id, u.nickname || u.username, !muted);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
key: "kick",
|
||||||
|
label: "移出群聊",
|
||||||
|
icon: <UserX size={14} />,
|
||||||
|
danger: true,
|
||||||
|
onClick: () => {
|
||||||
|
close();
|
||||||
|
handleKick(u.id, u.nickname || u.username);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (siteOwner && !isTargetOwner) {
|
||||||
|
items.push({
|
||||||
|
key: "setAdmin",
|
||||||
|
label: isTargetAdmin ? "取消管理员" : "设为管理员",
|
||||||
|
icon: <Crown size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
close();
|
||||||
|
handleSetAdmin(u.id, u.nickname || u.username, !isTargetAdmin);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
// 点击空白关闭右键菜单 — ContextMenu 自带
|
// 点击空白关闭右键菜单 — ContextMenu 自带
|
||||||
|
|
||||||
const shellClass = embedded
|
const shellClass = embedded
|
||||||
@@ -1605,7 +1798,9 @@ export default function ChatRoomClient({
|
|||||||
ref={listRef}
|
ref={listRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="j13-chat-scroll flex-1 overflow-y-auto overflow-x-hidden flex flex-col-reverse px-4 sm:px-5 py-4"
|
className="j13-chat-scroll flex-1 overflow-y-auto overflow-x-hidden flex flex-col-reverse px-4 sm:px-5 py-4"
|
||||||
style={{ minHeight: 0 }}
|
// overflowAnchor:none:column-reverse 原点在底部,prepend 天然不移动视口;
|
||||||
|
// 关掉浏览器原生锚定,避免它对顶部内容变化做多余补偿
|
||||||
|
style={{ minHeight: 0, overflowAnchor: "none" }}
|
||||||
>
|
>
|
||||||
{!canView ? (
|
{!canView ? (
|
||||||
<div className="text-center py-12 px-4">
|
<div className="text-center py-12 px-4">
|
||||||
@@ -1649,6 +1844,33 @@ export default function ChatRoomClient({
|
|||||||
new Date(prev.created_at).getTime() <
|
new Date(prev.created_at).getTime() <
|
||||||
5 * 60_000;
|
5 * 60_000;
|
||||||
const reEditText = mine ? takeReEditDraft(m.id) : null;
|
const reEditText = mine ? takeReEditDraft(m.id) : null;
|
||||||
|
// 发送者的群内角色(群主/管理徽标展示用;私聊不需要)
|
||||||
|
const senderRoomRole = !isDirect
|
||||||
|
? members.find((x) => x.user_id === m.sender_id)?.role
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// 系统消息(如禁言通知):居中灰字;禁言类通知红色突出
|
||||||
|
if (m.sender_id === 0) {
|
||||||
|
const isMuteNotice = m.content.includes("已被禁言");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="flex justify-center px-2 py-0.5"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-[12px] leading-relaxed text-center max-w-[90%]"
|
||||||
|
style={{
|
||||||
|
color: isMuteNotice
|
||||||
|
? "var(--danger)"
|
||||||
|
: "var(--ink-3)",
|
||||||
|
fontWeight: isMuteNotice ? 600 : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
|
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
|
||||||
if (recalled && !(canModerateChat && m.content)) {
|
if (recalled && !(canModerateChat && m.content)) {
|
||||||
@@ -1704,10 +1926,29 @@ export default function ChatRoomClient({
|
|||||||
>
|
>
|
||||||
<div className="w-8 shrink-0">
|
<div className="w-8 shrink-0">
|
||||||
{!grouped && (
|
{!grouped && (
|
||||||
<button
|
<div
|
||||||
type="button"
|
role="button"
|
||||||
className="rounded-full transition-opacity hover:opacity-85"
|
tabIndex={0}
|
||||||
|
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
|
||||||
onClick={() => openUserCard(m.sender)}
|
onClick={() => openUserCard(m.sender)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (m.sender_id === meId || isDirect) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const mem = members.find((x) => x.user_id === m.sender_id);
|
||||||
|
// 菜单互斥:打开头像菜单前关掉消息菜单与资料卡
|
||||||
|
setCtxMenu(null);
|
||||||
|
setUserCard(null);
|
||||||
|
setUserCtxMenu({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
user: m.sender,
|
||||||
|
member: mem,
|
||||||
|
});
|
||||||
|
}}
|
||||||
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -1715,7 +1956,7 @@ export default function ChatRoomClient({
|
|||||||
src={m.sender.avatar || undefined}
|
src={m.sender.avatar || undefined}
|
||||||
size={32}
|
size={32}
|
||||||
/>
|
/>
|
||||||
</button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1733,6 +1974,18 @@ export default function ChatRoomClient({
|
|||||||
>
|
>
|
||||||
{m.sender.nickname}
|
{m.sender.nickname}
|
||||||
</span>
|
</span>
|
||||||
|
{!isDirect && senderRoomRole === "admin" && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-medium px-1 rounded"
|
||||||
|
style={{
|
||||||
|
color: "var(--accent)",
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--accent) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
管理
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!isDirect && (
|
{!isDirect && (
|
||||||
<>
|
<>
|
||||||
<LevelBadge level={m.sender.level} />
|
<LevelBadge level={m.sender.level} />
|
||||||
@@ -1748,23 +2001,37 @@ export default function ChatRoomClient({
|
|||||||
<div
|
<div
|
||||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] [overflow-wrap:anywhere] whitespace-pre-wrap select-text w-full"
|
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] [overflow-wrap:anywhere] whitespace-pre-wrap select-text w-full"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--panel-2)",
|
background:
|
||||||
|
"color-mix(in srgb, var(--gold) 7%, var(--panel-2))",
|
||||||
border:
|
border:
|
||||||
"1px solid color-mix(in srgb, var(--line) 80%, var(--ink-3))",
|
"1.5px dashed color-mix(in srgb, var(--gold) 55%, transparent)",
|
||||||
color: "var(--ink)",
|
color: "var(--ink)",
|
||||||
borderBottomRightRadius: mine ? 4 : undefined,
|
borderBottomRightRadius: mine ? 4 : undefined,
|
||||||
borderBottomLeftRadius: mine ? undefined : 4,
|
borderBottomLeftRadius: mine ? undefined : 4,
|
||||||
opacity: 0.92,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
className="inline-flex items-center gap-1 mb-1.5 text-[11px]"
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-[10.5px] font-medium px-1.5 py-px rounded-full"
|
||||||
|
style={{
|
||||||
|
color: "var(--gold)",
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--gold) 14%, transparent)",
|
||||||
|
border:
|
||||||
|
"1px solid color-mix(in srgb, var(--gold) 35%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Undo2 size={10} strokeWidth={2.4} />
|
||||||
|
已撤回
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[10.5px]"
|
||||||
style={{ color: "var(--ink-3)" }}
|
style={{ color: "var(--ink-3)" }}
|
||||||
>
|
>
|
||||||
<Undo2 size={11} strokeWidth={2.4} />
|
仅管理可见
|
||||||
已撤回
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "var(--ink-2)" }}>
|
<div className="italic" style={{ color: "var(--ink-2)" }}>
|
||||||
{renderChatContent(m.content, userIndex, openUserCard)}
|
{renderChatContent(m.content, userIndex, openUserCard)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1784,10 +2051,29 @@ export default function ChatRoomClient({
|
|||||||
>
|
>
|
||||||
<div className="w-8 shrink-0">
|
<div className="w-8 shrink-0">
|
||||||
{!grouped && (
|
{!grouped && (
|
||||||
<button
|
<div
|
||||||
type="button"
|
role="button"
|
||||||
className="rounded-full transition-opacity hover:opacity-85"
|
tabIndex={0}
|
||||||
|
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
|
||||||
onClick={() => openUserCard(m.sender)}
|
onClick={() => openUserCard(m.sender)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
|
||||||
|
}}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (m.sender_id === meId || isDirect) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const mem = members.find((x) => x.user_id === m.sender_id);
|
||||||
|
// 菜单互斥:打开头像菜单前关掉消息菜单与资料卡
|
||||||
|
setCtxMenu(null);
|
||||||
|
setUserCard(null);
|
||||||
|
setUserCtxMenu({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
user: m.sender,
|
||||||
|
member: mem,
|
||||||
|
});
|
||||||
|
}}
|
||||||
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -1795,7 +2081,7 @@ export default function ChatRoomClient({
|
|||||||
src={m.sender.avatar || undefined}
|
src={m.sender.avatar || undefined}
|
||||||
size={32}
|
size={32}
|
||||||
/>
|
/>
|
||||||
</button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -1836,6 +2122,23 @@ export default function ChatRoomClient({
|
|||||||
)}
|
)}
|
||||||
{m.sender.nickname}
|
{m.sender.nickname}
|
||||||
</button>
|
</button>
|
||||||
|
{!isDirect && senderRoomRole === "admin" && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-medium px-1 rounded"
|
||||||
|
style={{
|
||||||
|
color: "var(--accent)",
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--accent) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
管理
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* 群聊与私聊都展示等级/徽章标识 */}
|
||||||
|
<LevelBadge level={m.sender.level} />
|
||||||
|
{(m.sender.badges ?? []).slice(0, 1).map((ub) => (
|
||||||
|
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||||
|
))}
|
||||||
<span className="meta text-[10.5px]">
|
<span className="meta text-[10.5px]">
|
||||||
{formatTime(m.created_at)}
|
{formatTime(m.created_at)}
|
||||||
</span>
|
</span>
|
||||||
@@ -2028,18 +2331,32 @@ export default function ChatRoomClient({
|
|||||||
<div className="relative shrink-0 flex items-center" ref={emojiPanelWrapRef}>
|
<div className="relative shrink-0 flex items-center" ref={emojiPanelWrapRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={!!mutedNotice}
|
||||||
className={`w-9 h-9 flex items-center justify-center rounded-full shrink-0 transition-colors${
|
className={`w-9 h-9 flex items-center justify-center rounded-full shrink-0 transition-colors${
|
||||||
emojiOpen ? " bg-[var(--accent-soft)]" : " hover:bg-[var(--panel-2)]"
|
mutedNotice
|
||||||
|
? " opacity-40 cursor-not-allowed"
|
||||||
|
: emojiOpen
|
||||||
|
? " bg-[var(--accent-soft)]"
|
||||||
|
: " hover:bg-[var(--panel-2)]"
|
||||||
}`}
|
}`}
|
||||||
style={{ color: emojiOpen ? "var(--accent)" : "var(--ink-2)" }}
|
style={{
|
||||||
aria-label="表情"
|
color: mutedNotice
|
||||||
|
? "var(--ink-3)"
|
||||||
|
: emojiOpen
|
||||||
|
? "var(--accent)"
|
||||||
|
: "var(--ink-2)",
|
||||||
|
}}
|
||||||
|
aria-label={mutedNotice ? "禁言中,无法发送表情" : "表情"}
|
||||||
aria-expanded={emojiOpen}
|
aria-expanded={emojiOpen}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => setEmojiOpen((v) => !v)}
|
onClick={() => {
|
||||||
|
if (mutedNotice) return;
|
||||||
|
setEmojiOpen((v) => !v);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Smile size={19} />
|
<Smile size={19} />
|
||||||
</button>
|
</button>
|
||||||
{emojiOpen && (
|
{emojiOpen && !mutedNotice && (
|
||||||
<div className="j13-chat-emoji-panel">
|
<div className="j13-chat-emoji-panel">
|
||||||
<EmojiPicker onPick={insertEmoji} />
|
<EmojiPicker onPick={insertEmoji} />
|
||||||
</div>
|
</div>
|
||||||
@@ -2047,17 +2364,28 @@ export default function ChatRoomClient({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
contentEditable
|
contentEditable={!mutedNotice}
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
role="textbox"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label="输入消息"
|
aria-label={mutedNotice ? "禁言中" : "输入消息"}
|
||||||
data-placeholder={isDirect ? "输入消息…" : "输入消息,@ 可提醒成员"}
|
data-placeholder={
|
||||||
|
mutedNotice
|
||||||
|
? `禁言中${muteCountdown ? `,剩余 ${muteCountdown}` : "(永久)"}`
|
||||||
|
: isDirect
|
||||||
|
? "输入消息…"
|
||||||
|
: "输入消息,@ 可提醒成员"
|
||||||
|
}
|
||||||
className="j13-chat-scroll j13-chat-composer-input j13-chat-rich-input relative flex-1 min-w-0 text-[14px] leading-5 py-2"
|
className="j13-chat-scroll j13-chat-composer-input j13-chat-rich-input relative flex-1 min-w-0 text-[14px] leading-5 py-2"
|
||||||
|
style={mutedNotice ? { color: "var(--ink-3)", cursor: "not-allowed" } : undefined}
|
||||||
onInput={handleRichInput}
|
onInput={handleRichInput}
|
||||||
onKeyDown={handleRichKeyDown}
|
onKeyDown={handleRichKeyDown}
|
||||||
onPaste={handleRichPaste}
|
onPaste={handleRichPaste}
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
|
if (mutedNotice) {
|
||||||
|
e.currentTarget.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
|
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
|
||||||
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
|
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
|
||||||
const composer = e.currentTarget.closest(".j13-chat-composer");
|
const composer = e.currentTarget.closest(".j13-chat-composer");
|
||||||
@@ -2067,7 +2395,7 @@ export default function ChatRoomClient({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={send}
|
onClick={send}
|
||||||
disabled={sending || !content.trim()}
|
disabled={sending || !content.trim() || !!mutedNotice}
|
||||||
className="btn btn-accent btn-sm gap-1.5 h-9 px-3.5 shrink-0 rounded-full"
|
className="btn btn-accent btn-sm gap-1.5 h-9 px-3.5 shrink-0 rounded-full"
|
||||||
aria-label="发送"
|
aria-label="发送"
|
||||||
>
|
>
|
||||||
@@ -2156,6 +2484,7 @@ export default function ChatRoomClient({
|
|||||||
user: mem.user,
|
user: mem.user,
|
||||||
roomRole: mem.role,
|
roomRole: mem.role,
|
||||||
muted: mem.muted,
|
muted: mem.muted,
|
||||||
|
mutedUntil: mem.muted_until,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -2204,6 +2533,7 @@ export default function ChatRoomClient({
|
|||||||
user: mem.user,
|
user: mem.user,
|
||||||
roomRole: mem.role,
|
roomRole: mem.role,
|
||||||
muted: mem.muted,
|
muted: mem.muted,
|
||||||
|
mutedUntil: mem.muted_until,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
showTitle
|
showTitle
|
||||||
@@ -2211,7 +2541,8 @@ export default function ChatRoomClient({
|
|||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ctxMenu && (
|
{/* 弹层打开期间不再渲染右键菜单:渲染级互斥兜底,杜绝菜单悬在弹窗上方 */}
|
||||||
|
{ctxMenu && !muteTarget && !confirm && !userCard && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
x={ctxMenu.x}
|
x={ctxMenu.x}
|
||||||
y={ctxMenu.y}
|
y={ctxMenu.y}
|
||||||
@@ -2220,6 +2551,15 @@ export default function ChatRoomClient({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{userCtxMenu && !muteTarget && !confirm && !userCard && (
|
||||||
|
<ContextMenu
|
||||||
|
x={userCtxMenu.x}
|
||||||
|
y={userCtxMenu.y}
|
||||||
|
items={userMenuItems(userCtxMenu.user, userCtxMenu.member)}
|
||||||
|
onClose={() => setUserCtxMenu(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{userCard && (
|
{userCard && (
|
||||||
<ChatUserCard
|
<ChatUserCard
|
||||||
target={userCard}
|
target={userCard}
|
||||||
@@ -2240,10 +2580,179 @@ export default function ChatRoomClient({
|
|||||||
if (!confirmBusy) setConfirm(null);
|
if (!confirmBusy) setConfirm(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MuteModal
|
||||||
|
open={!!muteTarget}
|
||||||
|
nickname={muteTarget?.nickname ?? ""}
|
||||||
|
busy={muteBusy}
|
||||||
|
onConfirm={submitMute}
|
||||||
|
onClose={() => {
|
||||||
|
if (!muteBusy) setMuteTarget(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 禁言时长选项(分钟),0 = 永久,参考 QQ 禁言 */
|
||||||
|
const MUTE_DURATIONS: { value: number; label: string }[] = [
|
||||||
|
{ value: 10, label: "10 分钟" },
|
||||||
|
{ value: 60, label: "1 小时" },
|
||||||
|
{ value: 12 * 60, label: "12 小时" },
|
||||||
|
{ value: 24 * 60, label: "1 天" },
|
||||||
|
{ value: 3 * 24 * 60, label: "3 天" },
|
||||||
|
{ value: 7 * 24 * 60, label: "7 天" },
|
||||||
|
{ value: 30 * 24 * 60, label: "30 天" },
|
||||||
|
{ value: 0, label: "永久" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatMuteDuration(minutes: number): string {
|
||||||
|
return MUTE_DURATIONS.find((d) => d.value === minutes)?.label ?? `${minutes} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 禁言系统提示用的时长格式:返回「x天x小时x分钟」 */
|
||||||
|
function formatMuteCountdown(untilIso: string): string {
|
||||||
|
const until = new Date(untilIso).getTime();
|
||||||
|
const diff = until - Date.now();
|
||||||
|
if (diff <= 0) return "";
|
||||||
|
const days = Math.floor(diff / 86_400_000);
|
||||||
|
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
|
||||||
|
const minutes = Math.floor((diff % 3_600_000) / 60_000);
|
||||||
|
if (days > 0) return `${days} 天 ${hours} 小时`;
|
||||||
|
if (hours > 0) return `${hours} 小时 ${minutes} 分钟`;
|
||||||
|
return `${minutes} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成员列表禁言徽标内的剩余时长,永久/null 返回空串 */
|
||||||
|
function formatMuteRemainShort(until?: string | null): string {
|
||||||
|
if (!until) return "";
|
||||||
|
const diff = new Date(until).getTime() - Date.now();
|
||||||
|
if (diff <= 0) return "";
|
||||||
|
const days = Math.floor(diff / 86_400_000);
|
||||||
|
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
|
||||||
|
const minutes = Math.floor((diff % 3_600_000) / 60_000);
|
||||||
|
if (days > 0) return ` ${days}d`;
|
||||||
|
if (hours > 0) return ` ${hours}h`;
|
||||||
|
return ` ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 禁言倒计时:返回「x天x小时x分钟」或 null(永久/未禁言);到期触发 onExpire */
|
||||||
|
function useCountdown(
|
||||||
|
until?: string | null,
|
||||||
|
onExpire?: () => void
|
||||||
|
): string | null {
|
||||||
|
const [text, setText] = useState<string | null>(null);
|
||||||
|
const expiredRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
expiredRef.current = false;
|
||||||
|
if (!until) {
|
||||||
|
setText(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const update = () => {
|
||||||
|
const diff = new Date(until).getTime() - Date.now();
|
||||||
|
if (diff <= 0) {
|
||||||
|
if (!expiredRef.current) {
|
||||||
|
expiredRef.current = true;
|
||||||
|
onExpire?.();
|
||||||
|
}
|
||||||
|
setText("已到期");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const days = Math.floor(diff / 86_400_000);
|
||||||
|
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
|
||||||
|
const minutes = Math.floor((diff % 3_600_000) / 60_000);
|
||||||
|
const seconds = Math.floor((diff % 60_000) / 1000);
|
||||||
|
if (days > 0) setText(`${days} 天 ${hours} 小时`);
|
||||||
|
else if (hours > 0) setText(`${hours} 小时 ${minutes} 分钟`);
|
||||||
|
else if (minutes > 0) setText(`${minutes} 分 ${seconds} 秒`);
|
||||||
|
else setText(`${seconds} 秒`);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
const t = setInterval(update, 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [until, onExpire]);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 禁言成员弹窗(QQ 风格:选择时长) */
|
||||||
|
function MuteModal({
|
||||||
|
open,
|
||||||
|
nickname,
|
||||||
|
busy,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
nickname: string;
|
||||||
|
busy: boolean;
|
||||||
|
onConfirm: (durationMinutes: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [duration, setDuration] = useState<number>(24 * 60);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setDuration(24 * 60);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="禁言成员"
|
||||||
|
onClose={onClose}
|
||||||
|
busy={busy}
|
||||||
|
maxWidthClass="max-w-sm"
|
||||||
|
role="alertdialog"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onConfirm(duration)}
|
||||||
|
disabled={busy}
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
>
|
||||||
|
{busy ? "禁言中..." : "禁言"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-[13px]" style={{ color: "var(--ink-2)" }}>
|
||||||
|
选择 <span style={{ color: "var(--ink)" }}>{nickname}</span> 的禁言时长,到期自动解除。
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{MUTE_DURATIONS.map((d) => {
|
||||||
|
const active = d.value === duration;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDuration(d.value)}
|
||||||
|
className="h-9 rounded-lg text-[13px] transition-colors"
|
||||||
|
style={{
|
||||||
|
background: active ? "var(--accent-soft)" : "var(--bg)",
|
||||||
|
color: active ? "var(--accent)" : "var(--ink-2)",
|
||||||
|
border: `1px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function JoinButton({
|
function JoinButton({
|
||||||
roomId,
|
roomId,
|
||||||
onJoined,
|
onJoined,
|
||||||
@@ -2361,7 +2870,7 @@ function MembersPanel({
|
|||||||
{name}
|
{name}
|
||||||
{m.muted ? (
|
{m.muted ? (
|
||||||
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||||
· 禁言
|
· 禁言{formatMuteRemainShort(m.muted_until)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function ChatLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-chat-viewport
|
data-chat-viewport
|
||||||
className="flex flex-col flex-1 min-h-0 w-full max-w-6xl mx-auto"
|
className="flex flex-col flex-1 min-h-0 w-full"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|
||||||
@@ -237,7 +239,11 @@ body {
|
|||||||
/* 亚像素渲染在 Windows 高分屏更实;强制 grayscale 会让 400/500 中文偏虚 */
|
/* 亚像素渲染在 Windows 高分屏更实;强制 grayscale 会让 400/500 中文偏虚 */
|
||||||
-webkit-font-smoothing: auto;
|
-webkit-font-smoothing: auto;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
font-synthesis: none; /* 禁止伪粗,避免缺档字重糊成一团 */
|
/* 长手属性只禁伪粗(缺档字重糊成一团);放开伪斜体,让 em/斜体按钮生效。
|
||||||
|
首行兜底:不支持长手属性的老浏览器维持全禁。 */
|
||||||
|
font-synthesis: none;
|
||||||
|
font-synthesis-weight: none;
|
||||||
|
font-synthesis-style: auto;
|
||||||
isolation: isolate; /* 让 z-index:-1 的画布层停在内容之下、html 之上 */
|
isolation: isolate; /* 让 z-index:-1 的画布层停在内容之下、html 之上 */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +382,11 @@ html[data-lv-fx="off"] .lv-text {
|
|||||||
hsl(var(--cover-h) 28% 16%);
|
hsl(var(--cover-h) 28% 16%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 书库页标题:书香气衬线(宋体/楷体回退) */
|
||||||
|
.j13-library-title {
|
||||||
|
font-family: "Songti SC", "STSong", "SimSun", "Noto Serif CJK SC", serif;
|
||||||
|
}
|
||||||
|
|
||||||
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
|
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
|
||||||
.j13-tip-bubble {
|
.j13-tip-bubble {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -2421,14 +2432,23 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
/* 窄屏(手机端)编辑工具栏:收成单行核心工具 + 「更多」菜单(评论栏与正文编辑器共用) */
|
/* 窄屏(手机端)编辑工具栏:收成单行核心工具 + 「更多」菜单(评论栏与正文编辑器共用)。
|
||||||
|
空间不足时允许「编写/预览」折到第二行,不能让固定宽按钮溢出后与模式切换重叠 */
|
||||||
.j13-md-editor.is-narrow .j13-md-toolbar {
|
.j13-md-editor.is-narrow .j13-md-toolbar {
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
row-gap: 6px;
|
||||||
padding: 2px 0 8px;
|
padding: 2px 0 8px;
|
||||||
}
|
}
|
||||||
.j13-md-editor.is-narrow .j13-md-tool-groups {
|
.j13-md-editor.is-narrow .j13-md-tool-groups {
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
|
/* 默认 min-width:0 会让本组被压缩、内部按钮溢出到模式切换下方;
|
||||||
|
内容宽度作为最小宽,放不下时把右侧切换挤到下一行 */
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
.j13-md-editor.is-narrow .j13-md-toolbar-end {
|
||||||
|
/* 单行时左侧 flex-1 已把它顶到右侧;折行后在第二行保持右对齐 */
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.j13-md-editor.is-narrow .j13-md-tool {
|
.j13-md-editor.is-narrow .j13-md-tool {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
@@ -3988,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);
|
||||||
@@ -4685,7 +4734,9 @@ a.j13-mention-token:hover {
|
|||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
}
|
}
|
||||||
.admin-brand-swatch img {
|
.admin-brand-swatch img {
|
||||||
max-height: 3.5rem;
|
/* 按槽位高度铺满,宽度按比例自适应;超宽时由 max-width + contain 收边 */
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
@@ -5130,6 +5181,15 @@ a.j13-mention-token:hover {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.j13-brand-slot[data-size="auto"] {
|
||||||
|
width: auto;
|
||||||
|
max-width: 176px;
|
||||||
|
}
|
||||||
|
.j13-brand-slot[data-size="auto"] > .j13-brand-logo {
|
||||||
|
width: auto;
|
||||||
|
max-width: 176px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
.j13-brand-slot[data-frame="1"] {
|
.j13-brand-slot[data-frame="1"] {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
||||||
@@ -6396,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);
|
||||||
@@ -6405,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6489,29 +6592,23 @@ a.j13-mention-token:hover {
|
|||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.badge-pin {
|
/* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间 */
|
||||||
display: inline-flex;
|
.badge-pin,
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
padding: 4px 11px;
|
|
||||||
background: var(--clay-soft);
|
|
||||||
color: var(--clay);
|
|
||||||
border-radius: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.badge-rec {
|
.badge-rec {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
justify-content: center;
|
||||||
font-size: 12px;
|
width: 20px;
|
||||||
font-weight: 500;
|
height: 20px;
|
||||||
padding: 4px 11px;
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.badge-pin {
|
||||||
|
background: var(--clay-soft);
|
||||||
|
color: var(--clay);
|
||||||
|
}
|
||||||
|
.badge-rec {
|
||||||
background: var(--gold-soft);
|
background: var(--gold-soft);
|
||||||
color: var(--gold);
|
color: var(--gold);
|
||||||
border-radius: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.badge-pending {
|
.badge-pending {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -7111,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); }
|
||||||
|
|
||||||
@@ -8206,6 +8307,15 @@ a.j13-mention-token:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 书库全书搜索:正文命中样式由 BookSearch 运行时注入 ::highlight() 规则
|
||||||
|
(构建器 CSS 压缩器不识别该伪元素);.j13-find-mark 仅用于搜索弹窗片段预览 */
|
||||||
|
.j13-find-mark {
|
||||||
|
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Markdown 正文
|
Markdown 正文
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -8254,6 +8364,248 @@ a.j13-mention-token:hover {
|
|||||||
--entry-title-size: 1.4rem;
|
--entry-title-size: 1.4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----- 正文短代码:[details] 折叠 / [bilibili][youtube] 视频 / ==高亮== / [kbd] 键帽 ----- */
|
||||||
|
.prose-content .j13-md-details {
|
||||||
|
margin: 1rem 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--panel);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details > summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95em;
|
||||||
|
color: var(--ink);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details > summary:hover {
|
||||||
|
background: var(--tint);
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details[open] > summary {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details .j13-md-details-caret {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--ink-3);
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details[open] .j13-md-details-caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details-body {
|
||||||
|
padding: 0.35rem 0.9rem 0.6rem;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details-body > :first-child {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details-body > :last-child {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视频嵌入:16:9 容器 + 懒加载 iframe 铺满 */
|
||||||
|
.j13-md-video {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--tint);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.j13-md-video iframe {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==高亮==:荧光笔。UA 默认黄底黑字在暗色下刺眼,显式覆盖 */
|
||||||
|
.prose-content mark {
|
||||||
|
background: color-mix(in srgb, #fde047 55%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.05em 0.25em;
|
||||||
|
border-radius: 4px;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
.dark .prose-content mark {
|
||||||
|
background: color-mix(in srgb, #facc15 30%, transparent);
|
||||||
|
color: #fef9c3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* [kbd] 键帽:等宽 + 下边加厚模拟按键立体 */
|
||||||
|
.prose-content kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1em 0.45em;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82em;
|
||||||
|
line-height: 1.45;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑器「文字颜色」弹窗:前景/背景两行,每行预设 + 色轮 + hex 输入 */
|
||||||
|
.j13-md-color-popover {
|
||||||
|
width: 288px;
|
||||||
|
}
|
||||||
|
.j13-md-color-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.j13-md-color-row-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.j13-md-color-row-items {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.j13-md-color-none {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.j13-md-color-none.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.j13-md-color-dot {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.j13-md-color-dot.is-active {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
/* 自定义取色器触发按钮:彩虹渐变底 + 吸管图标 */
|
||||||
|
.j13-md-color-wheel {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: conic-gradient(
|
||||||
|
from 210deg,
|
||||||
|
#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.j13-md-color-wheel svg {
|
||||||
|
filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.65));
|
||||||
|
}
|
||||||
|
.j13-md-color-wheel:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
.j13-md-color-wheel.is-active {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.j13-md-color-hex {
|
||||||
|
flex: 1 1 90px;
|
||||||
|
min-width: 90px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
/* 内联 HSV 取色器:在弹层文档流内展开,只撑开不浮层,不遮挡预览 */
|
||||||
|
.j13-md-hsv {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.j13-md-hsv-sv {
|
||||||
|
position: relative;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.j13-md-hsv-sv-thumb {
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.j13-md-hsv-hue {
|
||||||
|
position: relative;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.j13-md-hsv-hue-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 10px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.35);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* 实时预览置于弹层顶部:取色器在行内展开时始终可见,未选背景时透明棋盘格 */
|
||||||
|
.j13-md-color-preview {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 7px 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
repeating-conic-gradient(var(--tint) 0% 25%, transparent 0% 50%) 0 0 / 14px 14px;
|
||||||
|
}
|
||||||
|
.j13-md-color-preview span {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.prose-content h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; }
|
.prose-content h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; }
|
||||||
.prose-content h2 {
|
.prose-content h2 {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
@@ -8763,6 +9115,14 @@ html.dark .prose-content pre[data-theme] span {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 移动端聚焦评论框(主评论/回复/编辑)时隐藏回顶浮钮:
|
||||||
|
虚拟键盘弹起后浮钮会贴近输入区造成遮挡与误触,失焦后自动恢复 */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
body:has(#comments .j13-md-textarea:focus) .j13-back-top {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 首页移动端:板块条白底上贴页头(#main 顶部不留白,板块条与帖子框连成一张白纸) */
|
/* 首页移动端:板块条白底上贴页头(#main 顶部不留白,板块条与帖子框连成一张白纸) */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
body:has(.j13-fluid-home) #main {
|
body:has(.j13-fluid-home) #main {
|
||||||
@@ -8792,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);
|
||||||
@@ -9284,12 +9645,12 @@ button.cmt-edited {
|
|||||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 评论内联回复表单:出现在目标评论正下方 */
|
/* 评论内联回复表单:与楼层块同级全宽显示(DOM 见 CommentSection 列表 map)。
|
||||||
|
前侧间距由 .cmt-item 的 padding-bottom 提供 */
|
||||||
.cmt-reply-form {
|
.cmt-reply-form {
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: var(--panel-2);
|
background: var(--panel-2);
|
||||||
margin-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 评论区编辑器:矮输入区,底栏含提交 */
|
/* 评论区编辑器:矮输入区,底栏含提交 */
|
||||||
@@ -9312,12 +9673,32 @@ button.cmt-edited {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@container (max-width: 420px) {
|
@container (max-width: 420px) {
|
||||||
|
/* 手机端评论栏:工具按钮收到 30px(与 is-narrow 一致),「编写/预览」收成图标,
|
||||||
|
保证核心工具与「更多」单行排开不与模式切换重叠;
|
||||||
|
极端窄宽(<340px)放不下时,模式切换整体换到第二行而不是压在「更多」上 */
|
||||||
|
.cmt-md-editor .j13-md-editor.is-comment .j13-md-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 6px;
|
||||||
|
}
|
||||||
.cmt-md-editor .j13-md-editor.is-comment .j13-md-tool {
|
.cmt-md-editor .j13-md-editor.is-comment .j13-md-tool {
|
||||||
width: 36px;
|
width: 30px;
|
||||||
height: 36px;
|
height: 30px;
|
||||||
|
}
|
||||||
|
.cmt-md-editor .j13-md-editor.is-comment .j13-md-tool-groups {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
min-width: min-content;
|
||||||
|
}
|
||||||
|
.cmt-md-editor .j13-md-toolbar-end {
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.cmt-md-editor .j13-md-mode button {
|
.cmt-md-editor .j13-md-mode button {
|
||||||
padding: 0 6px;
|
width: 30px;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.cmt-md-editor .j13-md-mode-text {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9943,15 +10324,12 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 侧栏管理:站长自定义工具容器 ──
|
/* ── 侧栏管理:站长自定义工具容器(Shadow DOM 宿主) ──
|
||||||
不附加面板底色/边框,视觉完全交给工具自带的 HTML/CSS;
|
不附加面板底色/边框,视觉完全交给工具自带的 HTML/CSS;
|
||||||
仅保证在侧栏列内不横向溢出。 */
|
仅保证在侧栏列内不横向溢出,工具内容隔离在 shadow 内(见 widgetRuntime)。 */
|
||||||
.j13-widget-body {
|
.j13-widget-body {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.j13-widget-body > .j13-widget-html {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 抖音表情(douyin-emoji):编辑器弹层面板 / 聊天面板 / 行内图片 ── */
|
/* ── 抖音表情(douyin-emoji):编辑器弹层面板 / 聊天面板 / 行内图片 ── */
|
||||||
.j13-emoji-picker {
|
.j13-emoji-picker {
|
||||||
@@ -10100,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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,18 @@ export default async function RootLayout({
|
|||||||
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
|
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
|
||||||
>
|
>
|
||||||
<body className="min-h-screen flex flex-col">
|
<body className="min-h-screen flex flex-col">
|
||||||
|
{/* Noto Sans SC 字体声明异步加载(postbuild 抽到 public/fonts/noto-sans-sc.css):
|
||||||
|
动态插入的 stylesheet 不阻塞渲染;度量校正 fallback 面仍在阻塞 CSS 中,
|
||||||
|
首屏先用栈内系统字体(PingFang/雅黑),切片就绪后 swap */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html:
|
||||||
|
'(function(){var l=document.createElement("link");l.rel="stylesheet";l.href="/fonts/noto-sans-sc.css";document.head.appendChild(l)})();',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<noscript>
|
||||||
|
<link rel="stylesheet" href="/fonts/noto-sans-sc.css" />
|
||||||
|
</noscript>
|
||||||
{accentCSS ? (
|
{accentCSS ? (
|
||||||
// React 托管的 SSR 主题节点;客户端换肤走 j13-accent-runtime,禁止对此外挂 remove
|
// React 托管的 SSR 主题节点;客户端换肤走 j13-accent-runtime,禁止对此外挂 remove
|
||||||
<style
|
<style
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import { getPublicSettingsCached } from "@/lib/serverData";
|
|||||||
import MarkdownBody from "@/components/MarkdownBody";
|
import MarkdownBody from "@/components/MarkdownBody";
|
||||||
import { SiteDocMasthead } from "@/components/site-doc";
|
import { SiteDocMasthead } from "@/components/site-doc";
|
||||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||||
import { libraryDocPath, libraryDocReadPath } from "@/lib/urlStyle";
|
import { libraryDocPath, libraryDocReadPath, userPath } from "@/lib/urlStyle";
|
||||||
import { webPageJsonLd } from "@/lib/jsonLd";
|
import { webPageJsonLd } from "@/lib/jsonLd";
|
||||||
import JsonLd from "@/components/JsonLd";
|
import JsonLd from "@/components/JsonLd";
|
||||||
import { estimateReadingMinutes } from "@/lib/toc";
|
import { estimateReadingMinutes } from "@/lib/toc";
|
||||||
import { groupSections } from "@/lib/librarySections";
|
import { groupSections } from "@/lib/librarySections";
|
||||||
import LightboxImage from "@/components/LightboxImage";
|
import LightboxImage from "@/components/LightboxImage";
|
||||||
import AdaptiveCoverSlot from "@/components/AdaptiveCoverSlot";
|
import AdaptiveCoverSlot from "@/components/AdaptiveCoverSlot";
|
||||||
|
import Avatar from "@/components/Avatar";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -64,23 +65,27 @@ function ExtChip({ ext }: { ext: string }) {
|
|||||||
function FileRow({ f }: { f: LibraryFileItem }) {
|
function FileRow({ f }: { f: LibraryFileItem }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-xl px-4 py-3"
|
className="rounded-xl px-3 py-2.5"
|
||||||
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
|
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
{/* 紧凑两行式:适配左栏窄列,文件名过长截断(悬停可见全名),避免被压成逐字换行 */}
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<ExtChip ext={f.ext} />
|
<ExtChip ext={f.ext} />
|
||||||
<span
|
<span
|
||||||
className="text-[13.5px] font-medium min-w-0 break-all"
|
className="text-[13px] font-medium min-w-0 truncate"
|
||||||
style={{ color: "var(--ink)" }}
|
style={{ color: "var(--ink)" }}
|
||||||
|
title={f.name}
|
||||||
>
|
>
|
||||||
{f.name}
|
{f.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="meta text-[11.5px]">
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
|
<span className="meta text-[11.5px] shrink-0">
|
||||||
{formatSize(f.size)} · {f.download_count} 次下载
|
{formatSize(f.size)} · {f.download_count} 次下载
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href={`/api/library/files/${f.id}/download`}
|
href={`/api/library/files/${f.id}/download`}
|
||||||
className="btn btn-sm btn-primary ml-auto inline-flex items-center gap-1.5 shrink-0"
|
className="btn btn-sm btn-primary inline-flex items-center gap-1.5 shrink-0"
|
||||||
>
|
>
|
||||||
<Download size={13} /> 下载
|
<Download size={13} /> 下载
|
||||||
</a>
|
</a>
|
||||||
@@ -159,12 +164,23 @@ export default async function LibraryDocPage({ params }: PageProps) {
|
|||||||
{/* 横版封面(宽>高且足够大):全宽置于内容上方;竖版或过小的横版图时此槽位不渲染。
|
{/* 横版封面(宽>高且足够大):全宽置于内容上方;竖版或过小的横版图时此槽位不渲染。
|
||||||
小图不强行放大占满全宽:max-w-full 下保持自然尺寸,容器随内容收缩并水平居中 */}
|
小图不强行放大占满全宽:max-w-full 下保持自然尺寸,容器随内容收缩并水平居中 */}
|
||||||
{d.cover_url ? (
|
{d.cover_url ? (
|
||||||
<AdaptiveCoverSlot orientation="landscape" src={d.cover_url}>
|
<AdaptiveCoverSlot
|
||||||
|
orientation="landscape"
|
||||||
|
src={d.cover_url}
|
||||||
|
naturalWidth={d.cover_width}
|
||||||
|
naturalHeight={d.cover_height}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="mt-6 w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
|
className="mt-6 w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
|
||||||
style={{ border: "1px solid var(--line)" }}
|
style={{ border: "1px solid var(--line)" }}
|
||||||
>
|
>
|
||||||
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
|
<LightboxImage
|
||||||
|
src={d.cover_url}
|
||||||
|
alt={d.title}
|
||||||
|
className="max-w-full h-auto block"
|
||||||
|
variantWidths={[768, 1080, 1920]}
|
||||||
|
sizes="(max-width:1280px) 92vw, 1080px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AdaptiveCoverSlot>
|
</AdaptiveCoverSlot>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -175,18 +191,40 @@ export default async function LibraryDocPage({ params }: PageProps) {
|
|||||||
{d.cover_url ? (
|
{d.cover_url ? (
|
||||||
// 竖版封面(长>宽)及过小的横版封面:按图片自然比例显示在左栏;
|
// 竖版封面(长>宽)及过小的横版封面:按图片自然比例显示在左栏;
|
||||||
// 小图不强行放大占满:保持自然尺寸,在槽位最大区域内水平垂直居中
|
// 小图不强行放大占满:保持自然尺寸,在槽位最大区域内水平垂直居中
|
||||||
<AdaptiveCoverSlot orientation="portrait" src={d.cover_url}>
|
<AdaptiveCoverSlot
|
||||||
|
orientation="portrait"
|
||||||
|
src={d.cover_url}
|
||||||
|
naturalWidth={d.cover_width}
|
||||||
|
naturalHeight={d.cover_height}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
|
className="w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
|
||||||
style={{ border: "1px solid var(--line)" }}
|
style={{ border: "1px solid var(--line)" }}
|
||||||
>
|
>
|
||||||
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
|
<LightboxImage
|
||||||
|
src={d.cover_url}
|
||||||
|
alt={d.title}
|
||||||
|
className="max-w-full h-auto block"
|
||||||
|
variantWidths={[384, 768]}
|
||||||
|
sizes="(max-width:1023px) 92vw, 340px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AdaptiveCoverSlot>
|
</AdaptiveCoverSlot>
|
||||||
) : (
|
) : (
|
||||||
<CoverFallback title={d.title} />
|
<CoverFallback title={d.title} />
|
||||||
)}
|
)}
|
||||||
<dl className="mt-4 flex flex-col gap-2 text-[13px]">
|
<dl className="mt-4 flex flex-col gap-2 text-[13px]">
|
||||||
|
{d.author ? (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<dt className="meta shrink-0">作者</dt>
|
||||||
|
<dd
|
||||||
|
className="font-semibold text-right min-w-0"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{d.author}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<dt className="meta">章节数</dt>
|
<dt className="meta">章节数</dt>
|
||||||
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
|
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
@@ -199,6 +237,30 @@ export default async function LibraryDocPage({ params }: PageProps) {
|
|||||||
{d.files.length}
|
{d.files.length}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
{d.creator ? (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<dt className="meta shrink-0">创建者</dt>
|
||||||
|
<dd className="min-w-0">
|
||||||
|
<Link
|
||||||
|
href={userPath(urlStyle, d.creator.id)}
|
||||||
|
className="inline-flex items-center gap-1.5 min-w-0"
|
||||||
|
title={`@${d.creator.username}`}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
size={20}
|
||||||
|
name={d.creator.nickname || d.creator.username}
|
||||||
|
src={d.creator.avatar}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-[12.5px] font-medium truncate max-w-[9rem]"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
{d.creator.nickname || d.creator.username}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</dl>
|
</dl>
|
||||||
{chapterCount > 0 ? (
|
{chapterCount > 0 ? (
|
||||||
<Link
|
<Link
|
||||||
@@ -208,9 +270,28 @@ export default async function LibraryDocPage({ params }: PageProps) {
|
|||||||
<BookOpen size={15} aria-hidden /> 开始阅读
|
<BookOpen size={15} aria-hidden /> 开始阅读
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* 附件下载紧跟阅读按钮:左栏桌面端 sticky 常驻,移动端排在介绍之前,
|
||||||
|
避免介绍过长时用户需要长距离滚动寻找下载入口 */}
|
||||||
|
{d.files.length > 0 ? (
|
||||||
|
<section id="files" className="mt-5 scroll-mt-24">
|
||||||
|
<h2
|
||||||
|
className="text-[13.5px] font-bold inline-flex items-center gap-2"
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
<Download size={14} style={{ color: "var(--accent)" }} aria-hidden />
|
||||||
|
附件下载({d.files.length})
|
||||||
|
</h2>
|
||||||
|
<div className="mt-2.5 flex flex-col gap-2">
|
||||||
|
{d.files.map((f) => (
|
||||||
|
<FileRow key={f.id} f={f} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 右栏:介绍 + 附件 */}
|
{/* 右栏:介绍(附件下载已移至左栏阅读按钮下方) */}
|
||||||
<div className="mt-6 lg:mt-0 min-w-0">
|
<div className="mt-6 lg:mt-0 min-w-0">
|
||||||
<section>
|
<section>
|
||||||
<h2
|
<h2
|
||||||
@@ -228,25 +309,6 @@ export default async function LibraryDocPage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="files" className="mt-9 scroll-mt-24">
|
|
||||||
<h2
|
|
||||||
className="text-[15px] font-bold inline-flex items-center gap-2"
|
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
|
||||||
<Download size={15} style={{ color: "var(--accent)" }} aria-hidden />
|
|
||||||
附件下载({d.files.length})
|
|
||||||
</h2>
|
|
||||||
{d.files.length === 0 ? (
|
|
||||||
<p className="meta text-[12.5px] mt-3">暂无附件</p>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3 flex flex-col gap-2.5">
|
|
||||||
{d.files.map((f) => (
|
|
||||||
<FileRow key={f.id} f={f} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import {
|
|||||||
groupSections,
|
groupSections,
|
||||||
type ChapterNav,
|
type ChapterNav,
|
||||||
} from "@/lib/librarySections";
|
} from "@/lib/librarySections";
|
||||||
import { extractToc, estimateReadingMinutes, markdownHeadingToc, buildTocFromSegments } from "@/lib/toc";
|
import { extractToc, estimateReadingMinutes, formatReadingDuration, markdownHeadingToc, buildTocFromSegments } from "@/lib/toc";
|
||||||
import { splitContentSegments } from "@/lib/directiveBlocks";
|
import { splitContentSegments } from "@/lib/directiveBlocks";
|
||||||
import { wrapImplicitEntries } from "@/lib/entryBlocks";
|
import { wrapImplicitEntries } from "@/lib/entryBlocks";
|
||||||
import PostToc, { type TocItem } from "@/components/PostToc";
|
import PostToc, { type TocItem } from "@/components/PostToc";
|
||||||
import MobileRailDrawers from "@/components/MobileRailDrawers";
|
import MobileRailDrawers from "@/components/MobileRailDrawers";
|
||||||
|
import BookSearch from "@/components/BookSearch";
|
||||||
import { formatRelative } from "@/lib/format";
|
import { formatRelative } from "@/lib/format";
|
||||||
|
import { markdownToSearchText, type BookChapterIndex } from "@/lib/bookSearch";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -127,6 +129,18 @@ export default async function LibraryReadPage({ params, searchParams }: PageProp
|
|||||||
[current.content, ...subs.map((s) => s.content)].filter(Boolean).join("\n\n")
|
[current.content, ...subs.map((s) => s.content)].filter(Boolean).join("\n\n")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 全书搜索索引:详情接口已一次返回全部章节,这里把每章(含小节标题/正文)
|
||||||
|
// 剥成可见纯文本随 RSC 下发给 BookSearch,无需额外交互请求
|
||||||
|
const searchIndex: BookChapterIndex[] = groups.map((g, i) => ({
|
||||||
|
no: i + 1,
|
||||||
|
title: g.chapter.title,
|
||||||
|
plain: markdownToSearchText(
|
||||||
|
[g.chapter.title, g.chapter.content, ...g.subs.flatMap((s) => [s.title, s.content])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n")
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
// 书籍侧栏内容:桌面左栏与移动端左抽屉共用(详情入口 + 章节列表)
|
// 书籍侧栏内容:桌面左栏与移动端左抽屉共用(详情入口 + 章节列表)
|
||||||
const bookRail = (
|
const bookRail = (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -209,7 +223,7 @@ export default async function LibraryReadPage({ params, searchParams }: PageProp
|
|||||||
{current.title}
|
{current.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="meta mt-0.5 text-[10.5px] leading-tight">
|
<p className="meta mt-0.5 text-[10.5px] leading-tight">
|
||||||
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
|
第 {nav.index + 1} / {nav.total} 章 · 约 {formatReadingDuration(chapterMinutes)} 读完
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -247,11 +261,19 @@ export default async function LibraryReadPage({ params, searchParams }: PageProp
|
|||||||
|
|
||||||
{/* 中间:单章正文 + 上下章导航(占满中栏全宽;手机端 panel 贴边铺满) */}
|
{/* 中间:单章正文 + 上下章导航(占满中栏全宽;手机端 panel 贴边铺满) */}
|
||||||
<div className="j13-doc-bleed min-w-0">
|
<div className="j13-doc-bleed min-w-0">
|
||||||
<article className="panel p-6 sm:p-8">
|
<article id="j13-book-article" className="panel p-6 sm:p-8">
|
||||||
<header>
|
<header>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="meta text-[12px]">
|
<p className="meta text-[12px]">
|
||||||
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
|
第 {nav.index + 1} / {nav.total} 章 · 约 {formatReadingDuration(chapterMinutes)} 读完
|
||||||
</p>
|
</p>
|
||||||
|
<BookSearch
|
||||||
|
slug={d.slug}
|
||||||
|
urlStyle={urlStyle}
|
||||||
|
currentNo={nav.index + 1}
|
||||||
|
chapters={searchIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<h2
|
<h2
|
||||||
id="ch-top"
|
id="ch-top"
|
||||||
className="mt-1.5 scroll-mt-24 text-[24px] sm:text-[26px] font-extrabold leading-snug"
|
className="mt-1.5 scroll-mt-24 text-[24px] sm:text-[26px] font-extrabold leading-snug"
|
||||||
|
|||||||
@@ -1,106 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { BookOpen, FileDown } from "lucide-react";
|
|
||||||
import { fetchLibraryList, type LibraryDocListItem } from "@/lib/api";
|
import { fetchLibraryList, type LibraryDocListItem } from "@/lib/api";
|
||||||
import { coverHue } from "@/lib/coverColor";
|
|
||||||
import { getPublicSettingsCached } from "@/lib/serverData";
|
import { getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||||
import { libraryPath, libraryDocPath } from "@/lib/urlStyle";
|
import { libraryPath } from "@/lib/urlStyle";
|
||||||
|
import LibrarySearchGrid from "@/components/LibrarySearchGrid";
|
||||||
function formatSize(n: number): string {
|
|
||||||
if (!Number.isFinite(n) || n <= 0) return "0 B";
|
|
||||||
if (n < 1024) return `${n} B`;
|
|
||||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 文字封面:无图时用完整书名 + 书名派生稳定色相洗底(lib/coverColor.ts) */
|
|
||||||
function TextCover({ title }: { title: string }) {
|
|
||||||
const name = title.trim() || "书";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="j13-text-cover w-full h-full flex items-center justify-center px-5 select-none"
|
|
||||||
style={{ "--cover-h": coverHue(name) } as CSSProperties}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-[22px] font-extrabold leading-snug text-center line-clamp-3 break-all"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExtChip({ ext }: { ext: string }) {
|
|
||||||
return (
|
|
||||||
<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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ext}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DocCard({
|
|
||||||
doc,
|
|
||||||
href,
|
|
||||||
}: {
|
|
||||||
doc: LibraryDocListItem;
|
|
||||||
href: string;
|
|
||||||
}) {
|
|
||||||
const exts = Array.from(new Set(doc.files.map((f) => f.ext))).slice(0, 4);
|
|
||||||
const totalSize = doc.files.reduce((acc, f) => acc + f.size, 0);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className="group flex flex-col overflow-hidden rounded-[20px] transition-transform hover:-translate-y-0.5"
|
|
||||||
style={{
|
|
||||||
background: "var(--panel)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="h-36 overflow-hidden" style={{ borderBottom: "1px solid var(--line)" }}>
|
|
||||||
{doc.cover_url ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
src={doc.cover_url}
|
|
||||||
alt={doc.title}
|
|
||||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TextCover title={doc.title} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="p-4 flex flex-col gap-2 flex-1">
|
|
||||||
<h2
|
|
||||||
className="text-[15px] font-bold leading-snug line-clamp-2"
|
|
||||||
style={{ color: "var(--ink)" }}
|
|
||||||
>
|
|
||||||
{doc.title}
|
|
||||||
</h2>
|
|
||||||
<div className="mt-auto flex flex-wrap items-center gap-1.5">
|
|
||||||
{exts.map((ext) => (
|
|
||||||
<ExtChip key={ext} ext={ext} />
|
|
||||||
))}
|
|
||||||
<span className="meta text-[11px] ml-auto inline-flex items-center gap-1">
|
|
||||||
<FileDown size={11} />
|
|
||||||
{doc.file_count > 0 ? `${doc.file_count} 个文件 · ${formatSize(totalSize)}` : "暂无文件"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const settings = await getPublicSettingsCached();
|
const settings = await getPublicSettingsCached();
|
||||||
@@ -113,7 +16,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
|
|
||||||
export default async function LibraryPage() {
|
export default async function LibraryPage() {
|
||||||
const settings = await getPublicSettingsCached();
|
const settings = await getPublicSettingsCached();
|
||||||
const urlStyle = settings.url_style;
|
|
||||||
|
|
||||||
// 接口失败降级为空态,不让页面 500
|
// 接口失败降级为空态,不让页面 500
|
||||||
let docs: LibraryDocListItem[] = [];
|
let docs: LibraryDocListItem[] = [];
|
||||||
@@ -126,53 +28,52 @@ export default async function LibraryPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{/* Hero:渐变洗底(与排行榜同语言) */}
|
{/* Hero:书香气——serif 大标题、两侧晕染、底部水纹线 */}
|
||||||
<section
|
<section
|
||||||
className="relative overflow-hidden rounded-[28px] px-6 py-8 sm:px-10 sm:py-10 text-center"
|
className="relative overflow-hidden px-6 py-12 sm:px-12 sm:py-16 text-center"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"radial-gradient(120% 160% at 12% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 55%)," +
|
"radial-gradient(140% 180% at 50% -20%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%)," +
|
||||||
"radial-gradient(90% 140% at 90% -20%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 50%)",
|
"radial-gradient(80% 120% at 8% 0%, color-mix(in srgb, var(--accent) 6%, transparent), transparent 50%)," +
|
||||||
border: "1px solid var(--line)",
|
"radial-gradient(80% 120% at 92% 0%, color-mix(in srgb, var(--accent) 6%, transparent), transparent 50%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute -top-16 -right-10 w-56 h-56 rounded-full pointer-events-none"
|
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] h-[520px] rounded-full pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
background: "color-mix(in srgb, var(--accent) 14%, transparent)",
|
background: "color-mix(in srgb, var(--accent) 10%, transparent)",
|
||||||
filter: "blur(48px)",
|
filter: "blur(72px)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
<p
|
||||||
|
className="text-[11px] sm:text-[12px] font-semibold tracking-[0.35em] uppercase mb-4"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
Library
|
||||||
|
</p>
|
||||||
<h1
|
<h1
|
||||||
className="text-[26px] sm:text-[32px] leading-tight font-extrabold tracking-tight inline-flex items-center gap-2.5"
|
className="j13-library-title text-[32px] sm:text-[44px] leading-tight font-bold tracking-wide"
|
||||||
style={{ color: "var(--ink)" }}
|
style={{ color: "var(--ink)" }}
|
||||||
>
|
>
|
||||||
<BookOpen size={26} style={{ color: "var(--accent)" }} />
|
|
||||||
书库
|
书库
|
||||||
</h1>
|
</h1>
|
||||||
<p className="meta mt-2 text-[13.5px]">书籍、文档与页面作品,在线阅读或下载</p>
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="mx-auto mt-5 h-px w-24 sm:w-32"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 40%, var(--line)), transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="meta mt-4 text-[13.5px] sm:text-[14px] tracking-wide">
|
||||||
|
书籍、文档与页面作品,在线阅读或下载
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{docs.length === 0 ? (
|
<LibrarySearchGrid docs={docs} urlStyle={settings.url_style} />
|
||||||
<div
|
|
||||||
className="mt-6 rounded-[20px] px-6 py-14 text-center"
|
|
||||||
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
|
|
||||||
>
|
|
||||||
<p className="text-[14px] font-medium" style={{ color: "var(--ink-2)" }}>
|
|
||||||
书库还没有内容
|
|
||||||
</p>
|
|
||||||
<p className="meta text-[12.5px] mt-1.5">管理员可在后台「书库管理」发布条目</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{docs.map((d) => (
|
|
||||||
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type FriendLinkStatus,
|
type FriendLinkStatus,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import LinksClient from "./LinksClient";
|
import LinksClient from "./LinksClient";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
links: FriendLink[];
|
links: FriendLink[];
|
||||||
@@ -240,9 +241,11 @@ export default function LinksBoard({
|
|||||||
{/* 头部:LOGO + 名称 + 状态 */}
|
{/* 头部:LOGO + 名称 + 状态 */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{edit.logo_url ? (
|
{edit.logo_url ? (
|
||||||
|
// 40px 位:本站图走 48/96 变体;外链保持原样
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={edit.logo_url}
|
src={isOwnImage(edit.logo_url) ? variantURL(edit.logo_url, pickVariantWidth(40)) : edit.logo_url}
|
||||||
|
srcSet={isOwnImage(edit.logo_url) ? densitySrcSet(edit.logo_url, 40) : undefined}
|
||||||
alt=""
|
alt=""
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
@@ -475,9 +478,11 @@ export default function LinksBoard({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{l.logo_url ? (
|
{l.logo_url ? (
|
||||||
|
// 48px 位:本站图走 48/96 变体;外链保持原样
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={l.logo_url}
|
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(48)) : l.logo_url}
|
||||||
|
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 48) : undefined}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
width={48}
|
width={48}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Loader2, Upload } from "lucide-react";
|
import { Loader2, Upload } from "lucide-react";
|
||||||
import { apiApplyFriendLink, apiUploadPostImage } from "@/lib/api";
|
import { apiApplyFriendLink, apiUploadPostImage } from "@/lib/api";
|
||||||
|
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
@@ -155,9 +156,11 @@ export default function LinksClient({ isLoggedIn, canManage, onClose }: Props) {
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{form.logo_url ? (
|
{form.logo_url ? (
|
||||||
|
// 64px 预览位:本站图走 96/160 变体;外链地址保持原样
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={form.logo_url}
|
src={isOwnImage(form.logo_url) ? variantURL(form.logo_url, pickVariantWidth(64)) : form.logo_url}
|
||||||
|
srcSet={isOwnImage(form.logo_url) ? densitySrcSet(form.logo_url, 64) : undefined}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-contain"
|
className="h-full w-full object-contain"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|||||||