feat: 帖子全局/板块置顶支持权重排序,优化列表与详情渲染
This commit is contained in:
@@ -28,6 +28,14 @@
|
|||||||
|
|
||||||
访问路径:`/page/changelog`。
|
访问路径:`/page/changelog`。
|
||||||
|
|
||||||
|
## 部署指南(置顶帖)
|
||||||
|
|
||||||
|
| 文件 | 标题 | 标签 | 操作 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| [deployment-guide.md](./deployment-guide.md) | 从零开始部署姜十三论坛 | `部署` `教程` | **置顶**(可选精华) |
|
||||||
|
|
||||||
|
覆盖:服务器选型、宝塔 / 1Panel、Docker / 二进制、域名 HTTPS、备份升级、常见问题。
|
||||||
|
|
||||||
## 可选短文
|
## 可选短文
|
||||||
|
|
||||||
若站务区希望不止一篇,见 [optional.md](./optional.md):
|
若站务区希望不止一篇,见 [optional.md](./optional.md):
|
||||||
|
|||||||
244
docs/site-posts/deployment-guide.md
Normal file
244
docs/site-posts/deployment-guide.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# 从零开始部署姜十三论坛
|
||||||
|
|
||||||
|
> 建议标题:从零开始部署姜十三论坛
|
||||||
|
> 建议 slug:`deploy`
|
||||||
|
> 建议操作:发布后设为「置顶」
|
||||||
|
> 复制正文时:从下方第一个 `---` **之后**开始粘贴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 为什么选姜十三
|
||||||
|
|
||||||
|
单二进制 + 内置 SQLite,**不需要 MySQL、PHP、Redis**。面板(宝塔 / 1Panel)只是帮你更方便地管理,不是必须的。
|
||||||
|
|
||||||
|
## 服务器选型
|
||||||
|
|
||||||
|
| 部署方式 | CPU | 内存 | 磁盘 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 纯二进制(无面板) | 1 核 | 512MB | 10GB SSD |
|
||||||
|
| 二进制 + 宝塔 | 1 核 | 1GB | 20GB SSD |
|
||||||
|
| Docker + 1Panel | 2 核 | 2GB | 40GB SSD |
|
||||||
|
|
||||||
|
> 推荐 Debian 12 / Ubuntu 22.04。建议直接上 2 核 2G。
|
||||||
|
|
||||||
|
## 部署方式对比
|
||||||
|
|
||||||
|
| | 1Panel + Docker | 宝塔 + 二进制 | 纯命令行 Docker |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 适合 | 新手 / 还要跑其他容器 | 习惯宝塔 / 只跑这一个 | 熟悉 Linux / 资源紧张 |
|
||||||
|
| 资源占用 | 较高(自带 Docker) | 低 | 最低 |
|
||||||
|
| HTTPS | 面板一键 | 面板一键 | Caddy / certbot |
|
||||||
|
| 备份 | 面板计划任务 | 面板计划任务 | `tar` 打包 |
|
||||||
|
| 升级 | 面板点一下 | 替换二进制 | `docker pull` + 重启 |
|
||||||
|
|
||||||
|
## 前置准备
|
||||||
|
|
||||||
|
```bash
|
||||||
|
apt update && apt upgrade -y
|
||||||
|
apt install -y curl wget ca-certificates
|
||||||
|
timedatectl set-timezone Asia/Shanghai
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方式一:1Panel + Docker
|
||||||
|
|
||||||
|
### 1. 安装 1Panel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSL https://resource.fit2cloud.com/1panel/package/v2/quick_start.sh \
|
||||||
|
-o quick_start.sh && bash quick_start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- 安装目录:默认 `/opt`
|
||||||
|
- 是否装 Docker:**y**(必须)
|
||||||
|
- 面板端口 / 安全入口 / 账号密码自定义,记好
|
||||||
|
|
||||||
|
安全组放行设置的端口,然后浏览器打开面板。
|
||||||
|
|
||||||
|
### 2. 启动姜十三
|
||||||
|
|
||||||
|
1Panel 面板 → 容器 → 创建:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 镜像 | `hangzhang714128/jiang13-forum:latest` |
|
||||||
|
| 端口 | `3000:3000` |
|
||||||
|
| 数据卷 | 主机目录 → 容器内 **`/data`** |
|
||||||
|
| 重启策略 | `unless-stopped` |
|
||||||
|
|
||||||
|
浏览器访问 `http://IP:3000` → **首个注册用户自动成为管理员**。
|
||||||
|
|
||||||
|
> 若容器报 `permission denied`:`chown -R 1000:1000 /数据目录`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方式二:宝塔 + 二进制
|
||||||
|
|
||||||
|
### 1. 安装宝塔
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 正式版 13.0.0(含 AI,需绑定账号)
|
||||||
|
if [ -f /usr/bin/curl ];then curl -sSO https://download.bt.cn/install/install_panel.sh;else wget -O install_panel.sh https://download.bt.cn/install/install_panel.sh;fi;bash install_panel.sh KZOqff1mn
|
||||||
|
|
||||||
|
# 稳定版 12.0.0(纯净、免绑定)
|
||||||
|
url=https://download.bt.cn/install/installStable_12.sh;if [ -f /usr/bin/curl ];then curl -sSO $url;else wget -O installStable_12.sh $url;fi;bash installStable_12.sh 5x59vxy56
|
||||||
|
```
|
||||||
|
|
||||||
|
安全组放行随机端口,浏览器打开(自签名证书提示 → 高级 → 继续前往)。
|
||||||
|
|
||||||
|
### 2. 下载并注册服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/jiang13 && cd /opt/jiang13
|
||||||
|
# 版本号以更新日志为准:https://bbs.iioio.com/page/update-log.html
|
||||||
|
wget -O jiang13 https://git.iioio.com/freefire/jiang13-forum/releases/download/v1.1.8/jiang13_v1.1.8_linux-amd64
|
||||||
|
chmod +x jiang13
|
||||||
|
|
||||||
|
# 注册为 systemd 服务(install 创建 unit 文件,start 启动,enable 开机自启)
|
||||||
|
./jiang13 -service install
|
||||||
|
./jiang13 -service start
|
||||||
|
systemctl enable jiang13
|
||||||
|
|
||||||
|
# 日常管理
|
||||||
|
./jiang13 -service status # 查看状态
|
||||||
|
./jiang13 -service restart # 重启
|
||||||
|
|
||||||
|
# 验证
|
||||||
|
curl http://127.0.0.1:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
> 只是临时跑一下?直接 `./jiang13` 前台启动即可,Ctrl+C 停止,不用 `install`。
|
||||||
|
|
||||||
|
### 2.1 升级二进制
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/jiang13
|
||||||
|
./jiang13 -service stop
|
||||||
|
wget -O jiang13.new https://git.iioio.com/freefire/jiang13-forum/releases/download/v新版本/jiang13_v新版本_linux-amd64
|
||||||
|
mv jiang13.new jiang13 && chmod +x jiang13
|
||||||
|
./jiang13 -service start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 宝塔反代 + SSL
|
||||||
|
|
||||||
|
网站 → 添加站点(域名,PHP 选**纯静态**)→ 反向代理(目标 `http://127.0.0.1:3000`)→ SSL → Let's Encrypt 一键签发 → 强制 HTTPS。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 方式三:纯 Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Docker
|
||||||
|
bash <(curl -sSL https://linuxmirrors.cn/docker.sh)
|
||||||
|
|
||||||
|
# 启动姜十三
|
||||||
|
docker run -d \
|
||||||
|
--name jiang13 \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v /opt/jiang13-data:/data \
|
||||||
|
--restart unless-stopped \
|
||||||
|
hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
或用 docker compose:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
jiang13:
|
||||||
|
image: hangzhang714128/jiang13-forum:latest
|
||||||
|
ports: ["3000:3000"]
|
||||||
|
volumes: ["./data:/data"]
|
||||||
|
environment: {TZ: Asia/Shanghai}
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTPS 用 Caddy 最省心:
|
||||||
|
|
||||||
|
```
|
||||||
|
bbs.yourdomain.com {
|
||||||
|
reverse_proxy 127.0.0.1:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy 自动申请 Let's Encrypt。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 域名 + HTTPS
|
||||||
|
|
||||||
|
域名服务商添加 **A 记录**:`bbs` → 服务器公网 IP。
|
||||||
|
|
||||||
|
| 方式 | HTTPS 操作 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1Panel | 网站 → 创建 → 反向代理 → SSL → Let's Encrypt |
|
||||||
|
| 宝塔 | 同上 |
|
||||||
|
| 纯 Docker | Caddy 自动 / Nginx + certbot |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据备份
|
||||||
|
|
||||||
|
所有数据(SQLite + 上传 + JWT 密钥)都在**一个目录**里,备份 = 复制目录。
|
||||||
|
|
||||||
|
| 方式 | 数据目录 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1Panel Docker | 你映射的宿主机目录 |
|
||||||
|
| 宝塔二进制 | 二进制同目录下 `data/` |
|
||||||
|
| 纯 Docker | `-v` 映射的目录 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -czf jiang13-backup-$(date +%Y%m%d).tar.gz /数据目录
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `data/.jwt_secret` 务必一起备份,丢了所有用户已登录的 cookie 会失效,需要重新输入密码登录(密码本身存 SQLite 里不受影响)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本升级
|
||||||
|
|
||||||
|
版本号以 [更新日志](https://bbs.iioio.com/page/update-log.html) 为准。
|
||||||
|
|
||||||
|
| 方式 | 升级 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1Panel | 容器列表 → 选中 → 镜像更新 |
|
||||||
|
| docker compose | `docker compose pull && docker compose up -d` |
|
||||||
|
| 二进制 | 见上方「2.1 升级二进制」 |
|
||||||
|
|
||||||
|
升级前先备份:`tar -czf backup.tar.gz /数据目录`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 面板常用命令
|
||||||
|
|
||||||
|
| 操作 | 宝塔 | 1Panel |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 菜单 | `bt` | `1pctl` |
|
||||||
|
| 查入口 / 密码 | `bt default` | `1pctl user-info` |
|
||||||
|
| 改密码 | `bt 14` | `1pctl update password` |
|
||||||
|
| 重启面板 | `bt restart` | — |
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**Q:面板 / 网站打不开?** 安全组有没有放行对应端口?`netstat -tlnp | grep 端口` 看服务在不在监听。
|
||||||
|
|
||||||
|
**Q:宝塔 vs 1Panel 怎么选?** 只跑姜十三 → 宝塔 + 二进制(省资源);还要跑别的 → 1Panel。
|
||||||
|
|
||||||
|
**Q:支持邮件 / SSO 吗?** 邮件 SMTP 和 OIDC SSO 都在**管理后台 → 系统设置**里配置。
|
||||||
|
|
||||||
|
**Q:源码编译?** 见项目 README。推荐用预编译包。
|
||||||
|
|
||||||
|
## 链接
|
||||||
|
|
||||||
|
| 项目 | 地址 |
|
||||||
|
| --- | --- |
|
||||||
|
| 在线演示 | https://bbs.iioio.com/ |
|
||||||
|
| 更新日志 | https://bbs.iioio.com/page/update-log.html |
|
||||||
|
| Docker Hub | hangzhang714128/jiang13-forum |
|
||||||
|
| 预编译下载 | https://git.iioio.com/freefire/jiang13-forum/releases |
|
||||||
|
|
||||||
|
有问题欢迎本帖留言。
|
||||||
@@ -154,12 +154,12 @@ export const api = {
|
|||||||
},
|
},
|
||||||
adminApprovePost: (id: number) =>
|
adminApprovePost: (id: number) =>
|
||||||
request<{ message: string; status: string }>(`/api/admin/posts/${id}/approve`, { method: 'POST' }),
|
request<{ message: string; status: string }>(`/api/admin/posts/${id}/approve`, { method: 'POST' }),
|
||||||
adminPinPost: (id: number, pinned: boolean) =>
|
adminPinPost: (id: number, pinned: number) =>
|
||||||
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
|
request<{ message: string; pinned: number }>(`/api/admin/posts/${id}/pin`, {
|
||||||
method: 'POST', body: JSON.stringify({ pinned }),
|
method: 'POST', body: JSON.stringify({ pinned }),
|
||||||
}),
|
}),
|
||||||
adminBoardPinPost: (id: number, boardPinned: boolean) =>
|
adminBoardPinPost: (id: number, boardPinned: number) =>
|
||||||
request<{ message: string; board_pinned: boolean }>(`/api/admin/posts/${id}/board-pin`, {
|
request<{ message: string; board_pinned: number }>(`/api/admin/posts/${id}/board-pin`, {
|
||||||
method: 'POST', body: JSON.stringify({ board_pinned: boardPinned }),
|
method: 'POST', body: JSON.stringify({ board_pinned: boardPinned }),
|
||||||
}),
|
}),
|
||||||
adminFeaturePost: (id: number, featured: boolean) =>
|
adminFeaturePost: (id: number, featured: boolean) =>
|
||||||
|
|||||||
@@ -94,9 +94,9 @@ export interface PostItem {
|
|||||||
bounty_comment_id?: number;
|
bounty_comment_id?: number;
|
||||||
lottery_winner_count?: number;
|
lottery_winner_count?: number;
|
||||||
lottery_status?: 'open' | 'drawn' | string;
|
lottery_status?: 'open' | 'drawn' | string;
|
||||||
pinned: boolean;
|
pinned: number;
|
||||||
/** 板块内置顶(仅板块列表抬升,首页不抬升) */
|
/** 板块内置顶(仅板块列表抬升,首页不抬升) */
|
||||||
board_pinned?: boolean;
|
board_pinned?: number;
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
edit_locked?: boolean;
|
edit_locked?: boolean;
|
||||||
/** 禁止新评论(结贴) */
|
/** 禁止新评论(结贴) */
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ function PostListItem({ post, sort = 'latest', boardId = 0, onSelect }: Props) {
|
|||||||
|
|
||||||
const titleRow = (
|
const titleRow = (
|
||||||
<div className="post-title-row">
|
<div className="post-title-row">
|
||||||
{post.pinned && (
|
{post.pinned > 0 && (
|
||||||
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
|
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
|
||||||
)}
|
)}
|
||||||
{post.board_pinned && (
|
{(post.board_pinned ?? 0) > 0 && (
|
||||||
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
|
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
|
||||||
)}
|
)}
|
||||||
{post.featured && (
|
{post.featured && (
|
||||||
|
|||||||
@@ -151,11 +151,11 @@ export default function PostManageMenu({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={onPin}>
|
<DropdownMenuItem onSelect={onPin}>
|
||||||
<Pin size={14} aria-hidden />
|
<Pin size={14} aria-hidden />
|
||||||
{post.pinned ? '取消全局置顶' : '全局置顶'}
|
{post.pinned > 0 ? '取消全局置顶' : '全局置顶'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={onBoardPin}>
|
<DropdownMenuItem onSelect={onBoardPin}>
|
||||||
<Pin size={14} aria-hidden />
|
<Pin size={14} aria-hidden />
|
||||||
{post.board_pinned ? '取消板块置顶' : '板块置顶'}
|
{(post.board_pinned ?? 0) > 0 ? '取消板块置顶' : '板块置顶'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
|||||||
@@ -98,10 +98,10 @@ export default function StaticFeedList({ posts, sort, boardId }: Props) {
|
|||||||
|
|
||||||
const titleRow = (
|
const titleRow = (
|
||||||
<div className="post-title-row">
|
<div className="post-title-row">
|
||||||
{post.pinned ? (
|
{post.pinned > 0 ? (
|
||||||
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
|
<span className="post-pin-badge" title="全局置顶">全局置顶</span>
|
||||||
) : null}
|
) : null}
|
||||||
{post.board_pinned ? (
|
{(post.board_pinned ?? 0) > 0 ? (
|
||||||
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
|
<span className="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>
|
||||||
) : null}
|
) : null}
|
||||||
{post.featured ? <span className="post-feature-badge" title="推荐">推荐</span> : null}
|
{post.featured ? <span className="post-feature-badge" title="推荐">推荐</span> : null}
|
||||||
|
|||||||
@@ -806,7 +806,8 @@ export default function PostDetailPage() {
|
|||||||
const handlePin = async () => {
|
const handlePin = async () => {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
try {
|
try {
|
||||||
const r = await api.adminPinPost(postId, !post.pinned);
|
const next = post.pinned > 0 ? 0 : 1;
|
||||||
|
const r = await api.adminPinPost(postId, next);
|
||||||
setPost(p => p ? { ...p, pinned: r.pinned } : p);
|
setPost(p => p ? { ...p, pinned: r.pinned } : p);
|
||||||
clearAllFeedCache();
|
clearAllFeedCache();
|
||||||
window.dispatchEvent(new Event('posts-refresh'));
|
window.dispatchEvent(new Event('posts-refresh'));
|
||||||
@@ -819,7 +820,8 @@ export default function PostDetailPage() {
|
|||||||
const handleBoardPin = async () => {
|
const handleBoardPin = async () => {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
try {
|
try {
|
||||||
const r = await api.adminBoardPinPost(postId, !post.board_pinned);
|
const next = (post.board_pinned ?? 0) > 0 ? 0 : 1;
|
||||||
|
const r = await api.adminBoardPinPost(postId, next);
|
||||||
setPost(p => p ? { ...p, board_pinned: r.board_pinned } : p);
|
setPost(p => p ? { ...p, board_pinned: r.board_pinned } : p);
|
||||||
clearAllFeedCache();
|
clearAllFeedCache();
|
||||||
window.dispatchEvent(new Event('posts-refresh'));
|
window.dispatchEvent(new Event('posts-refresh'));
|
||||||
@@ -995,10 +997,10 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
<div className="post-detail-head">
|
<div className="post-detail-head">
|
||||||
<h1 className="post-detail-title">
|
<h1 className="post-detail-title">
|
||||||
{post.pinned && (
|
{post.pinned > 0 && (
|
||||||
<span className="post-pin-badge post-pin-badge--detail" title="全局置顶">全局置顶</span>
|
<span className="post-pin-badge post-pin-badge--detail" title="全局置顶">全局置顶</span>
|
||||||
)}
|
)}
|
||||||
{post.board_pinned && (
|
{(post.board_pinned ?? 0) > 0 && (
|
||||||
<span className="post-pin-badge post-pin-badge--board post-pin-badge--detail" title="板块置顶">板块置顶</span>
|
<span className="post-pin-badge post-pin-badge--board post-pin-badge--detail" title="板块置顶">板块置顶</span>
|
||||||
)}
|
)}
|
||||||
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
|
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
|
||||||
|
|||||||
@@ -246,9 +246,9 @@ export default function AdminDashboardPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="space-x-1">
|
<td className="space-x-1">
|
||||||
{p.featured ? <Badge variant="orange">推荐</Badge> : null}
|
{p.featured ? <Badge variant="orange">推荐</Badge> : null}
|
||||||
{p.pinned ? <Badge variant="green">全局置顶</Badge> : null}
|
{p.pinned > 0 ? <Badge variant="green">全局置顶</Badge> : null}
|
||||||
{p.board_pinned ? <Badge variant="green">板块置顶</Badge> : null}
|
{(p.board_pinned ?? 0) > 0 ? <Badge variant="green">板块置顶</Badge> : null}
|
||||||
{!p.featured && !p.pinned && !p.board_pinned ? '—' : null}
|
{!p.featured && p.pinned <= 0 && (p.board_pinned ?? 0) <= 0 ? '—' : null}
|
||||||
</td>
|
</td>
|
||||||
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
|
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -141,9 +141,13 @@ export default function AdminPostsPage() {
|
|||||||
setSearch('');
|
setSearch('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePin = async (post: PostItem) => {
|
const togglePin = async (post: PostItem, nextValue?: number) => {
|
||||||
try {
|
try {
|
||||||
const r = await api.adminPinPost(post.id, !post.pinned);
|
let value = nextValue;
|
||||||
|
if (value === undefined) {
|
||||||
|
value = post.pinned > 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
const r = await api.adminPinPost(post.id, value);
|
||||||
clearAllFeedCache();
|
clearAllFeedCache();
|
||||||
window.dispatchEvent(new Event('posts-refresh'));
|
window.dispatchEvent(new Event('posts-refresh'));
|
||||||
notify.success(r.message);
|
notify.success(r.message);
|
||||||
@@ -153,9 +157,13 @@ export default function AdminPostsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleBoardPin = async (post: PostItem) => {
|
const toggleBoardPin = async (post: PostItem, nextValue?: number) => {
|
||||||
try {
|
try {
|
||||||
const r = await api.adminBoardPinPost(post.id, !post.board_pinned);
|
let value = nextValue;
|
||||||
|
if (value === undefined) {
|
||||||
|
value = (post.board_pinned ?? 0) > 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
const r = await api.adminBoardPinPost(post.id, value);
|
||||||
clearAllFeedCache();
|
clearAllFeedCache();
|
||||||
window.dispatchEvent(new Event('posts-refresh'));
|
window.dispatchEvent(new Event('posts-refresh'));
|
||||||
notify.success(r.message);
|
notify.success(r.message);
|
||||||
@@ -417,8 +425,8 @@ export default function AdminPostsPage() {
|
|||||||
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
|
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
|
||||||
<td>{p.comment_count ?? 0}</td>
|
<td>{p.comment_count ?? 0}</td>
|
||||||
<td>{p.featured ? <Badge variant="orange">是</Badge> : '—'}</td>
|
<td>{p.featured ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||||
<td>{p.pinned ? <Badge variant="green">是</Badge> : '—'}</td>
|
<td>{p.pinned > 0 ? <Badge variant="green">置顶 {p.pinned}</Badge> : '—'}</td>
|
||||||
<td>{p.board_pinned ? <Badge variant="green">是</Badge> : '—'}</td>
|
<td>{(p.board_pinned ?? 0) > 0 ? <Badge variant="green">置顶 {p.board_pinned}</Badge> : '—'}</td>
|
||||||
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||||
<td>{p.comments_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
<td>{p.comments_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||||
<td>{p.like_count}</td>
|
<td>{p.like_count}</td>
|
||||||
@@ -444,11 +452,55 @@ export default function AdminPostsPage() {
|
|||||||
<Button size="sm" variant="outline" onClick={() => toggleFeature(p)}>
|
<Button size="sm" variant="outline" onClick={() => toggleFeature(p)}>
|
||||||
{p.featured ? '取消推荐' : '推荐'}
|
{p.featured ? '取消推荐' : '推荐'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
|
<Button size="sm" variant="outline" onClick={() => {
|
||||||
{p.pinned ? '取消全局置顶' : '全局置顶'}
|
const cur = p.pinned;
|
||||||
|
if (cur > 0) {
|
||||||
|
// 已置顶:弹出输入框让管理员调整顺序值
|
||||||
|
const input = window.prompt(`全局置顶顺序(当前: ${cur},输入 0 取消置顶)`, String(cur));
|
||||||
|
if (input == null) return;
|
||||||
|
const n = parseInt(input, 10);
|
||||||
|
if (Number.isNaN(n) || n < 0) {
|
||||||
|
notify.warning('请输入有效的非负整数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void togglePin(p, n);
|
||||||
|
} else {
|
||||||
|
// 未置顶:输入框让管理员设置初始值
|
||||||
|
const input = window.prompt('设置全局置顶顺序(输入 1 即置顶,0 表示不置顶)', '1');
|
||||||
|
if (input == null) return;
|
||||||
|
const n = parseInt(input, 10);
|
||||||
|
if (Number.isNaN(n) || n < 0) {
|
||||||
|
notify.warning('请输入有效的非负整数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void togglePin(p, n);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{p.pinned > 0 ? `全局置顶(${p.pinned})` : '全局置顶'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => toggleBoardPin(p)}>
|
<Button size="sm" variant="outline" onClick={() => {
|
||||||
{p.board_pinned ? '取消板块置顶' : '板块置顶'}
|
const cur = p.board_pinned ?? 0;
|
||||||
|
if (cur > 0) {
|
||||||
|
const input = window.prompt(`板块置顶顺序(当前: ${cur},输入 0 取消置顶)`, String(cur));
|
||||||
|
if (input == null) return;
|
||||||
|
const n = parseInt(input, 10);
|
||||||
|
if (Number.isNaN(n) || n < 0) {
|
||||||
|
notify.warning('请输入有效的非负整数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void toggleBoardPin(p, n);
|
||||||
|
} else {
|
||||||
|
const input = window.prompt('设置板块置顶顺序(输入 1 即置顶,0 表示不置顶)', '1');
|
||||||
|
if (input == null) return;
|
||||||
|
const n = parseInt(input, 10);
|
||||||
|
if (Number.isNaN(n) || n < 0) {
|
||||||
|
notify.warning('请输入有效的非负整数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void toggleBoardPin(p, n);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{(p.board_pinned ?? 0) > 0 ? `板块置顶(${p.board_pinned})` : '板块置顶'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
|
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
|
||||||
{p.edit_locked ? <><LockOpen size={14} /> 解锁编辑</> : <><Lock size={14} /> 锁定编辑</>}
|
{p.edit_locked ? <><LockOpen size={14} /> 解锁编辑</> : <><Lock size={14} /> 锁定编辑</>}
|
||||||
|
|||||||
@@ -5905,15 +5905,6 @@ a.post-title:visited {
|
|||||||
background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-block-muted));
|
background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-block-muted));
|
||||||
color: var(--color-text-2);
|
color: var(--color-text-2);
|
||||||
border-radius: 0 10px 10px 0;
|
border-radius: 0 10px 10px 0;
|
||||||
transition:
|
|
||||||
border-left-width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
|
||||||
background 0.28s ease,
|
|
||||||
padding-left 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
.post-detail-content blockquote:hover {
|
|
||||||
border-left-width: 5px;
|
|
||||||
padding-left: calc(1em - 2px);
|
|
||||||
background: color-mix(in srgb, var(--j13-green) 11%, var(--j13-bg-block-muted));
|
|
||||||
}
|
}
|
||||||
.post-detail-content blockquote p:last-child { margin-bottom: 0; }
|
.post-detail-content blockquote p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
@@ -6194,7 +6185,7 @@ a.post-title:visited {
|
|||||||
|
|
||||||
/* highlight.js token(亮色) */
|
/* highlight.js token(亮色) */
|
||||||
.post-detail-content .hljs-comment,
|
.post-detail-content .hljs-comment,
|
||||||
.post-detail-content .hljs-quote { color: #6e7781; font-style: italic; }
|
.post-detail-content .hljs-quote { color: #6e7781; }
|
||||||
.post-detail-content .hljs-keyword,
|
.post-detail-content .hljs-keyword,
|
||||||
.post-detail-content .hljs-selector-tag,
|
.post-detail-content .hljs-selector-tag,
|
||||||
.post-detail-content .hljs-addition { color: #cf222e; }
|
.post-detail-content .hljs-addition { color: #cf222e; }
|
||||||
|
|||||||
@@ -101,28 +101,22 @@ export function enhanceCodeBlocks(root: ParentNode): void {
|
|||||||
const raw = code.textContent || '';
|
const raw = code.textContent || '';
|
||||||
const display = readDisplayOptions(pre);
|
const display = readDisplayOptions(pre);
|
||||||
|
|
||||||
// 作者写了语言标签则以标注为准,绝不被自动识别覆盖
|
// 作者写了语言标签则以标注为准,绝不被自动识别覆盖;纯文本不做自动检测
|
||||||
const declaredLang = detectLang(code, pre);
|
const declaredLang = detectLang(code, pre);
|
||||||
let label = formatLangLabel(declaredLang || 'code');
|
let label = declaredLang ? formatLangLabel(declaredLang) : '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (declaredLang && hljs.getLanguage(declaredLang)) {
|
if (declaredLang && hljs.getLanguage(declaredLang)) {
|
||||||
|
// 作者标注且 hljs 收录 → 语法高亮
|
||||||
const result = hljs.highlight(raw, { language: declaredLang, ignoreIllegals: true });
|
const result = hljs.highlight(raw, { language: declaredLang, ignoreIllegals: true });
|
||||||
code.innerHTML = wrapCodeLines(result.value, display.lineNumbers);
|
code.innerHTML = wrapCodeLines(result.value, display.lineNumbers);
|
||||||
code.classList.add('hljs', `language-${declaredLang}`);
|
code.classList.add('hljs', `language-${declaredLang}`);
|
||||||
} else if (declaredLang) {
|
} else if (declaredLang) {
|
||||||
// 未收录语言:保留原文与标签,不做自动猜测
|
// 作者标注但 hljs 未收录 → 保留原文与标签,不做自动猜测
|
||||||
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
|
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
|
||||||
code.classList.add('hljs', `language-${declaredLang}`);
|
code.classList.add('hljs', `language-${declaredLang}`);
|
||||||
} else if (raw.length >= 24) {
|
|
||||||
const result = hljs.highlightAuto(raw);
|
|
||||||
code.innerHTML = wrapCodeLines(result.value, display.lineNumbers);
|
|
||||||
code.classList.add('hljs');
|
|
||||||
if (result.language) {
|
|
||||||
label = formatLangLabel(result.language);
|
|
||||||
code.classList.add(`language-${result.language}`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
// 纯文本(无语言标注)→ 不做自动检测,保持纯文本显示
|
||||||
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
|
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
|
||||||
code.classList.add('hljs');
|
code.classList.add('hljs');
|
||||||
}
|
}
|
||||||
@@ -146,7 +140,7 @@ export function enhanceCodeBlocks(root: ParentNode): void {
|
|||||||
initiallyCollapsed ? 'md-codeblock--collapsed' : '',
|
initiallyCollapsed ? 'md-codeblock--collapsed' : '',
|
||||||
initiallyCollapsed && lineCount <= CODE_FOLD_MIN_LINES ? 'md-codeblock--short' : '',
|
initiallyCollapsed && lineCount <= CODE_FOLD_MIN_LINES ? 'md-codeblock--short' : '',
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
wrap.setAttribute('data-lang', label);
|
if (label) wrap.setAttribute('data-lang', label);
|
||||||
if (display.lineNumbers) wrap.setAttribute('data-line-numbers', 'true');
|
if (display.lineNumbers) wrap.setAttribute('data-line-numbers', 'true');
|
||||||
if (display.collapsed) wrap.setAttribute('data-collapsed', 'true');
|
if (display.collapsed) wrap.setAttribute('data-collapsed', 'true');
|
||||||
wrap.setAttribute('data-line-count', String(lineCount));
|
wrap.setAttribute('data-line-count', String(lineCount));
|
||||||
@@ -165,8 +159,9 @@ export function enhanceCodeBlocks(root: ParentNode): void {
|
|||||||
}
|
}
|
||||||
actions.push('<button type="button" class="md-codeblock__copy" data-code-copy>复制</button>');
|
actions.push('<button type="button" class="md-codeblock__copy" data-code-copy>复制</button>');
|
||||||
|
|
||||||
|
const langHtml = label ? `<span class="md-codeblock__lang">${escapeHtml(label)}</span>` : '';
|
||||||
head.innerHTML = `
|
head.innerHTML = `
|
||||||
<span class="md-codeblock__lang">${escapeHtml(label)}</span>
|
${langHtml}
|
||||||
<span class="md-codeblock__actions">${actions.join('')}</span>
|
<span class="md-codeblock__actions">${actions.join('')}</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -244,11 +244,11 @@ func (h *Handlers) APIAdminCommentsLockPost(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "comments_locked": req.Locked})
|
c.JSON(http.StatusOK, gin.H{"message": msg, "comments_locked": req.Locked})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIAdminPinPost 全局置顶/取消全局置顶(JSON)
|
// APIAdminPinPost 全局置顶/取消全局置顶(JSON),支持设置置顶顺序数值
|
||||||
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
|
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
var req struct {
|
var req struct {
|
||||||
Pinned bool `json:"pinned"`
|
Pinned int `json:"pinned"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
@@ -259,17 +259,17 @@ func (h *Handlers) APIAdminPinPost(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := "已取消全局置顶"
|
msg := "已取消全局置顶"
|
||||||
if req.Pinned {
|
if req.Pinned > 0 {
|
||||||
msg = "已全局置顶"
|
msg = "已全局置顶"
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": req.Pinned})
|
c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": req.Pinned})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIAdminBoardPinPost 板块内置顶/取消板块内置顶(JSON)
|
// APIAdminBoardPinPost 板块内置顶/取消板块内置顶(JSON),支持设置置顶顺序数值
|
||||||
func (h *Handlers) APIAdminBoardPinPost(c *gin.Context) {
|
func (h *Handlers) APIAdminBoardPinPost(c *gin.Context) {
|
||||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
var req struct {
|
var req struct {
|
||||||
BoardPinned bool `json:"board_pinned"`
|
BoardPinned int `json:"board_pinned"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
@@ -280,7 +280,7 @@ func (h *Handlers) APIAdminBoardPinPost(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := "已取消板块置顶"
|
msg := "已取消板块置顶"
|
||||||
if req.BoardPinned {
|
if req.BoardPinned > 0 {
|
||||||
msg = "已板块置顶"
|
msg = "已板块置顶"
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "board_pinned": req.BoardPinned})
|
c.JSON(http.StatusOK, gin.H{"message": msg, "board_pinned": req.BoardPinned})
|
||||||
|
|||||||
@@ -673,10 +673,10 @@ func writeSSRPostRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString(`<div class="post-title-row">`)
|
b.WriteString(`<div class="post-title-row">`)
|
||||||
if post.Pinned {
|
if post.Pinned > 0 {
|
||||||
b.WriteString(`<span class="post-pin-badge" title="全局置顶">全局置顶</span>`)
|
b.WriteString(`<span class="post-pin-badge" title="全局置顶">全局置顶</span>`)
|
||||||
}
|
}
|
||||||
if post.BoardPinned {
|
if post.BoardPinned > 0 {
|
||||||
b.WriteString(`<span class="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>`)
|
b.WriteString(`<span class="post-pin-badge post-pin-badge--board" title="板块置顶">板块置顶</span>`)
|
||||||
}
|
}
|
||||||
if post.Featured {
|
if post.Featured {
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ type Post struct {
|
|||||||
BountyCommentID uint `gorm:"default:0" json:"bounty_comment_id"` // 采纳的评论
|
BountyCommentID uint `gorm:"default:0" json:"bounty_comment_id"` // 采纳的评论
|
||||||
LotteryWinnerCount int `gorm:"default:1" json:"lottery_winner_count"` // 抽奖人数(仅 lottery)
|
LotteryWinnerCount int `gorm:"default:1" json:"lottery_winner_count"` // 抽奖人数(仅 lottery)
|
||||||
LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
|
LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
|
||||||
Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
|
Pinned int `gorm:"default:0" json:"pinned"` // 全局置顶(0=未置顶,>0=置顶,数值越大越靠前)
|
||||||
BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
|
BoardPinned int `gorm:"default:0" json:"board_pinned"` // 板块内置顶(0=未置顶,>0=置顶,数值越大越靠前)
|
||||||
Featured bool `gorm:"default:false;index" json:"featured"` // 推荐帖(原精华)
|
Featured bool `gorm:"default:false;index" json:"featured"` // 推荐帖(原精华)
|
||||||
EditLocked bool `gorm:"default:false" json:"edit_locked"`
|
EditLocked bool `gorm:"default:false" json:"edit_locked"`
|
||||||
CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
|
CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
|
||||||
|
|||||||
@@ -813,11 +813,17 @@ func (s *PostService) Purge(postID uint) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PostService) SetPinned(postID uint, pinned bool) error {
|
func (s *PostService) SetPinned(postID uint, pinned int) error {
|
||||||
|
if pinned < 0 {
|
||||||
|
pinned = 0
|
||||||
|
}
|
||||||
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("pinned", pinned).Error
|
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("pinned", pinned).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PostService) SetBoardPinned(postID uint, boardPinned bool) error {
|
func (s *PostService) SetBoardPinned(postID uint, boardPinned int) error {
|
||||||
|
if boardPinned < 0 {
|
||||||
|
boardPinned = 0
|
||||||
|
}
|
||||||
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("board_pinned", boardPinned).Error
|
return model.DB.Model(&model.Post{}).Where("id = ?", postID).Update("board_pinned", boardPinned).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user