1623
This commit is contained in:
1 parent
310be913ef
commit
fe8c99a53e
8 files changed
+367
-157
No files matched your search
@@ -40,6 +40,7 @@ html {
|
|||||||
[data-theme="dark"] body {
|
[data-theme="dark"] body {
|
||||||
color: var(--text-content-dark-color);
|
color: var(--text-content-dark-color);
|
||||||
background-color: var(--background-dark-color);
|
background-color: var(--background-dark-color);
|
||||||
|
background-image: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .main {
|
[data-theme="dark"] .main {
|
||||||
@@ -5706,3 +5707,84 @@ a.link-card:hover .link-card-arrow {
|
|||||||
font-size: inherit !important;
|
font-size: inherit !important;
|
||||||
color: var(--accent, #3b82f6) !important;
|
color: var(--accent, #3b82f6) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==================== 视频样式 ==================== */
|
||||||
|
|
||||||
|
.post-video-wrapper {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .post-video-wrapper {
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: 80vh;
|
||||||
|
border-radius: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container video::-webkit-media-controls-panel {
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-caption {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .video-caption {
|
||||||
|
color: #9ca3af;
|
||||||
|
background: #1f2937;
|
||||||
|
border-top-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bilibili / YouTube 嵌入 */
|
||||||
|
.video-embed {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 56.25%; /* 16:9 */
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-embed iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文章内直接写 <video> 标签的样式 */
|
||||||
|
.post-content video {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .post-content video {
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{/*
|
||||||
|
Render hook for HTML video tags in Markdown
|
||||||
|
让 Markdown 中的 <video> 标签自动包裹 figure 容器
|
||||||
|
*/}}
|
||||||
|
<figure class="post-video-wrapper">
|
||||||
|
<div class="video-container">
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
playsinline
|
||||||
|
webkit-playsinline
|
||||||
|
{{ with .Attributes.poster }}poster="{{ . }}"{{ end }}
|
||||||
|
{{ with .Attributes.class }}class="{{ . }}"{{ end }}
|
||||||
|
>
|
||||||
|
<source src="{{ .Destination | safeURL }}" type="{{ .MediaType.Type }}">
|
||||||
|
你的浏览器不支持视频播放。
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
{{ with .Text }}
|
||||||
|
<figcaption class="video-caption">{{ . }}</figcaption>
|
||||||
|
{{ end }}
|
||||||
|
</figure>
|
||||||
@@ -35,8 +35,45 @@
|
|||||||
content.querySelectorAll('.post-video, .link-card').forEach(function(el) {
|
content.querySelectorAll('.post-video, .link-card').forEach(function(el) {
|
||||||
if (!el.hasAttribute('uk-scrollspy')) el.setAttribute('uk-scrollspy', 'cls: uk-animation-slide-bottom-small; delay: 100');
|
if (!el.hasAttribute('uk-scrollspy')) el.setAttribute('uk-scrollspy', 'cls: uk-animation-slide-bottom-small; delay: 100');
|
||||||
});
|
});
|
||||||
|
// 初始化视频播放器
|
||||||
|
initVideoPlayers();
|
||||||
}
|
}
|
||||||
document.addEventListener('DOMContentLoaded', initPostScrollspy);
|
document.addEventListener('DOMContentLoaded', initPostScrollspy);
|
||||||
|
|
||||||
|
// 视频 PJAX 适配
|
||||||
|
function initVideoPlayers() {
|
||||||
|
document.querySelectorAll('.post-content video, .post-video-wrapper video').forEach(function(video) {
|
||||||
|
// 暗黑模式下视频控件适配
|
||||||
|
if (document.documentElement.dataset.theme === 'dark') {
|
||||||
|
video.style.filter = 'brightness(0.95)';
|
||||||
|
} else {
|
||||||
|
video.style.filter = '';
|
||||||
|
}
|
||||||
|
// 确保不自动播放
|
||||||
|
video.removeAttribute('autoplay');
|
||||||
|
video.pause();
|
||||||
|
});
|
||||||
|
// 嵌入视频(B站/YouTube)在 PJAX 后重新加载
|
||||||
|
document.querySelectorAll('.video-embed iframe').forEach(function(iframe) {
|
||||||
|
var src = iframe.src;
|
||||||
|
if (src) {
|
||||||
|
iframe.src = src; // 触发重新加载
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PJAX 导航前暂停所有视频
|
||||||
|
document.addEventListener('pjax:send', function() {
|
||||||
|
document.querySelectorAll('video').forEach(function(video) {
|
||||||
|
video.pause();
|
||||||
|
video.removeAttribute('autoplay');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// PJAX 加载完成后重新初始化
|
||||||
|
document.addEventListener('pjax:complete', function() {
|
||||||
|
setTimeout(initPostScrollspy, 100);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{{/*
|
||||||
|
视频嵌入 shortcode
|
||||||
|
用法:
|
||||||
|
{{< video src="/video/example.mp4" >}}
|
||||||
|
{{< video src="https://example.com/video.mp4" poster="/image/poster.jpg" >}}
|
||||||
|
{{< video src="/video/example.mp4" caption="视频说明文字" >}}
|
||||||
|
{{< bilibili BV1xx411c7mD >}}
|
||||||
|
{{< youtube dQw4w9WgXcQ >}}
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{ if .Get "bvid" | or (.Get 0) }}
|
||||||
|
{{/* Bilibili 嵌入 */}}
|
||||||
|
{{ $bvid := .Get "bvid" | default (.Get 0) }}
|
||||||
|
<div class="post-video-wrapper">
|
||||||
|
<div class="video-embed bilibili-embed">
|
||||||
|
<iframe
|
||||||
|
src="//player.bilibili.com/player.html?bvid={{ $bvid }}&high_quality=1&danmaku=0"
|
||||||
|
scrolling="no"
|
||||||
|
frameborder="0"
|
||||||
|
allowfullscreen="true"
|
||||||
|
loading="lazy"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ else if .Get "id" | or (and (.Get 0) (not (.Get "src"))) }}
|
||||||
|
{{/* YouTube 嵌入 */}}
|
||||||
|
{{ $id := .Get "id" | default (.Get 0) }}
|
||||||
|
<div class="post-video-wrapper">
|
||||||
|
<div class="video-embed youtube-embed">
|
||||||
|
<iframe
|
||||||
|
src="https://www.youtube.com/embed/{{ $id }}"
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen="true"
|
||||||
|
loading="lazy"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ else }}
|
||||||
|
{{/* 本地/外部视频 */}}
|
||||||
|
{{ $src := .Get "src" }}
|
||||||
|
{{ $poster := .Get "poster" }}
|
||||||
|
{{ $caption := .Get "caption" }}
|
||||||
|
<figure class="post-video-wrapper">
|
||||||
|
<div class="video-container">
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
{{ with $poster }}poster="{{ . }}"{{ end }}
|
||||||
|
playsinline
|
||||||
|
webkit-playsinline
|
||||||
|
>
|
||||||
|
<source src="{{ $src }}">
|
||||||
|
你的浏览器不支持视频播放。
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
{{ with $caption }}
|
||||||
|
<figcaption class="video-caption">{{ . }}</figcaption>
|
||||||
|
{{ end }}
|
||||||
|
</figure>
|
||||||
|
{{ end }}
|
||||||
+139
-152
@@ -1,51 +1,111 @@
|
|||||||
# Write Server
|
# Write Server
|
||||||
|
|
||||||
Hugo 博客的本地管理后台,基于 Next.js 16 + Tailwind CSS 4,Docker 容器化部署。
|
Hugo 博客的管理后台,基于 Next.js 16 + Tailwind CSS 4,Docker 容器化部署。
|
||||||
|
|
||||||
域名:`https://post.usj.cc`
|
域名:`https://post.usj.cc`
|
||||||
|
|
||||||
## 功能模块
|
## 功能模块
|
||||||
|
|
||||||
| 模块 | 路径 | 说明 |
|
| 模块 | 路径 | 权限 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|------|
|
||||||
| 文章管理 | `/` | 列表、搜索、编辑、删除、预览 |
|
| 文章管理 | `/` | admin/user | 列表、搜索、编辑、删除、预览(user 只看自己的文章) |
|
||||||
| 撰写文章 | `/edit` | Markdown 编辑器,图片上传 |
|
| 撰写文章 | `/edit` | admin/user | Markdown 编辑器,图片上传,作者自动填充 |
|
||||||
| 评论管理 | `/comments` | Artalk 评论回复、删除 |
|
| 评论管理 | `/comments` | admin/user | Artalk 评论回复、删除 |
|
||||||
| 订阅源 | `/feeds` | RSS 订阅源管理 |
|
| 图片管理 | `/images` | admin/user | 图片上传管理 |
|
||||||
| 友链管理 | `/links` | 友链增删改查 |
|
| 回收站 | `/recycle` | admin/user | 已删文章恢复(30 天) |
|
||||||
| 图片管理 | `/images` | 图片上传管理 |
|
| 订阅源 | `/feeds` | admin | RSS 订阅源管理 |
|
||||||
| 公众号 | `/wechat` | 微信公众号发布(自包含) |
|
| 友链管理 | `/links` | admin | 友链增删改查 |
|
||||||
| 回收站 | `/recycle` | 已删文章恢复 |
|
| 公众号 | `/wechat` | admin | 微信公众号发布(自包含,直连微信 API) |
|
||||||
| Bot 管理 | `/bot` | Telegram Bot / Hugo 状态 |
|
| Bot 管理 | `/bot` | admin | Telegram Bot / Hugo 状态 |
|
||||||
|
| 用户管理 | `/users` | admin | 添加/删除用户、修改密码 |
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **框架**: Next.js 16 (Turbopack, standalone 输出)
|
- **框架**: Next.js 16 (Turbopack, standalone 输出)
|
||||||
- **样式**: Tailwind CSS 4, SEN 暖色系
|
- **样式**: Tailwind CSS 4, SEN 暖色系
|
||||||
- **数据**: 直接读写 Hugo Markdown 文件,无数据库
|
- **数据**: 直接读写 Hugo Markdown 文件,无数据库
|
||||||
- **评论**: Artalk API v2
|
- **认证**: Service Worker + SHA-256 密码哈希,3 小时自动过期
|
||||||
- **Bot**: Telegram Bot (长轮询)
|
- **评论**: Artalk API v2 + 新评论 Telegram 推送
|
||||||
- **公众号**: 微信 API 直连(不依赖外部服务)
|
- **Bot**: Telegram Bot(长轮询,支持语音转文字写作)
|
||||||
- **反向代理**: Nginx + Basic Auth + Service Worker
|
- **公众号**: 微信 API 直连(access_token 自动管理)
|
||||||
|
- **反向代理**: Nginx + HTTPS
|
||||||
- **Hugo 预览**: 独立端口 1313,HTTPS 访问
|
- **Hugo 预览**: 独立端口 1313,HTTPS 访问
|
||||||
|
|
||||||
|
## Telegram Bot 功能
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `/new` | 新建文章 |
|
||||||
|
| `/edit` | 编辑文章 |
|
||||||
|
| `/delete` | 删除文章 |
|
||||||
|
| `/list` | 文章列表 |
|
||||||
|
| `/publish` | 保存发布 |
|
||||||
|
| `/draft` | 保存草稿 |
|
||||||
|
| `/deploy` | 推送到线上 |
|
||||||
|
| `/links` | 友链管理 |
|
||||||
|
| `/feeds` | 订阅管理 |
|
||||||
|
| `/stats` | 网站数据 |
|
||||||
|
| 语音消息 | 自动转文字写入文章 |
|
||||||
|
| 图片/文档 | 自动保存到文章目录 |
|
||||||
|
|
||||||
|
Bot 支持多用户,通过 `TG_ALLOWED_CHAT_IDS` 配置授权。
|
||||||
|
|
||||||
|
## 用户系统
|
||||||
|
|
||||||
|
用户配置文件:`users.json`(明文写入,容器启动时自动哈希)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{ "username": "admin", "password": "密码", "name": "昵称", "role": "admin" },
|
||||||
|
{ "username": "user1", "password": "密码", "name": "昵称", "role": "user" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **admin**: 看所有文章,管理所有模块,可添加/删除用户
|
||||||
|
- **user**: 只看自己写的文章,只能用文章/撰写/评论/图片/回收站
|
||||||
|
|
||||||
|
修改密码:登录后在「用户管理」页面操作,或直接编辑 `users.json` 重启容器。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
write-server/
|
write-server/
|
||||||
├── src/ # 源代码
|
├── src/
|
||||||
│ ├── app/ # Next.js 页面和 API
|
│ ├── app/ # Next.js 页面和 API
|
||||||
|
│ │ ├── api/auth/login/ # 登录认证
|
||||||
|
│ │ ├── api/posts/ # 文章 CRUD(按用户过滤)
|
||||||
|
│ │ ├── api/users/ # 用户管理
|
||||||
|
│ │ ├── api/rss/wechat/ # 微信公众号发布
|
||||||
|
│ │ ├── api/recycle/ # 回收站
|
||||||
|
│ │ ├── login/ # 登录页面
|
||||||
|
│ │ └── users/ # 用户管理页面
|
||||||
│ ├── components/ # React 组件
|
│ ├── components/ # React 组件
|
||||||
│ └── lib/ # 核心库(posts, recycle, bot, wechat, hugo)
|
│ │ ├── AppShell.tsx # 布局(登录页/主界面切换)
|
||||||
├── nginx/ # Nginx 配置
|
│ │ ├── AuthGuard.tsx # 登录状态检查
|
||||||
│ ├── conf.d/ # 站点配置(主站 + Hugo 预览)
|
│ │ ├── PostEditor.tsx # 文章编辑器
|
||||||
│ ├── login.html # 登录页面
|
│ │ └── Sidebar.tsx # 侧边栏(按角色显示菜单)
|
||||||
|
│ └── lib/
|
||||||
|
│ ├── auth.ts # 认证(SHA-256 哈希)
|
||||||
|
│ ├── posts.ts # 文章读写
|
||||||
|
│ ├── recycle.ts # 回收站
|
||||||
|
│ ├── wechat.ts # 微信 API 直连
|
||||||
|
│ ├── voice.ts # 语音转文字
|
||||||
|
│ ├── hugo.ts # Hugo Server 管理
|
||||||
|
│ └── bot/ # Telegram Bot
|
||||||
|
│ ├── poll.ts # 消息轮询
|
||||||
|
│ ├── handlers.ts # 命令处理
|
||||||
|
│ └── notify.ts # 新评论推送
|
||||||
|
├── nginx/
|
||||||
|
│ ├── conf.d/
|
||||||
|
│ │ ├── write-server.conf # 主站配置
|
||||||
|
│ │ └── hugo-preview.conf # Hugo 预览 HTTPS
|
||||||
│ └── sw.js # Service Worker(认证)
|
│ └── sw.js # Service Worker(认证)
|
||||||
├── entrypoint.sh # Docker 入口脚本(权限修复)
|
├── users.json # 用户配置
|
||||||
├── Dockerfile # Docker 镜像
|
├── entrypoint.sh # Docker 入口(权限修复 + 密码哈希)
|
||||||
├── docker-compose.yml # Docker Compose
|
├── Dockerfile
|
||||||
├── .env.example # 环境变量模板
|
└── docker-compose.yml
|
||||||
└── .htpasswd # Basic Auth 密码文件
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 端口说明
|
## 端口说明
|
||||||
@@ -53,9 +113,8 @@ write-server/
|
|||||||
| 端口 | 用途 | 协议 |
|
| 端口 | 用途 | 协议 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 80 | HTTP → HTTPS 重定向 | HTTP |
|
| 80 | HTTP → HTTPS 重定向 | HTTP |
|
||||||
| 443 | 写作后台(nginx 代理 → 8016) | HTTPS |
|
| 443 | 写作后台(nginx → 8016) | HTTPS |
|
||||||
| 1313 | Hugo 预览(nginx 代理 → 容器内 1313) | HTTPS |
|
| 1313 | Hugo 预览(nginx → 容器内 1313) | HTTPS |
|
||||||
| 8016 | write-server(仅容器内部) | - |
|
|
||||||
|
|
||||||
## 环境变量 (.env)
|
## 环境变量 (.env)
|
||||||
|
|
||||||
@@ -66,9 +125,9 @@ ARTALK_SITE=优世界
|
|||||||
|
|
||||||
# Telegram Bot
|
# Telegram Bot
|
||||||
TG_BOT_TOKEN=your-bot-token
|
TG_BOT_TOKEN=your-bot-token
|
||||||
TG_ALLOWED_CHAT_IDS=your-chat-id
|
TG_ALLOWED_CHAT_IDS=7499586710,123456789
|
||||||
|
|
||||||
# 微信公众号(自包含,直连微信 API)
|
# 微信公众号(自包含)
|
||||||
WECHAT_APP_ID=your-app-id
|
WECHAT_APP_ID=your-app-id
|
||||||
WECHAT_APP_SECRET=your-app-secret
|
WECHAT_APP_SECRET=your-app-secret
|
||||||
|
|
||||||
@@ -76,10 +135,9 @@ WECHAT_APP_SECRET=your-app-secret
|
|||||||
RSS_API_BASE=https://api.usj.cc
|
RSS_API_BASE=https://api.usj.cc
|
||||||
RSS_API_TOKEN=your-token
|
RSS_API_TOKEN=your-token
|
||||||
|
|
||||||
# AI
|
# AI / 语音转文字
|
||||||
DEEPSEEK_API_KEY=your-key
|
DEEPSEEK_API_KEY=your-key
|
||||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||||
DEEPSEEK_MODEL=deepseek-chat
|
|
||||||
|
|
||||||
# 其他
|
# 其他
|
||||||
DEFAULT_AUTHOR=小赵
|
DEFAULT_AUTHOR=小赵
|
||||||
@@ -95,180 +153,109 @@ docker compose up -d
|
|||||||
# 停止
|
# 停止
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
# 重建(代码/Dockerfile 变更后)
|
# 重建(代码变更后)
|
||||||
docker compose down
|
docker compose down && docker compose build --no-cache && docker compose up -d
|
||||||
docker compose build --no-cache
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 查看日志
|
# 查看日志
|
||||||
docker logs write-server -f
|
docker logs write-server -f
|
||||||
docker logs write-nginx -f
|
|
||||||
|
|
||||||
# 重启单个服务
|
# 重启单个服务
|
||||||
docker compose restart write-server
|
docker compose restart write-server
|
||||||
docker compose restart nginx
|
docker compose restart nginx
|
||||||
|
|
||||||
|
# 修改用户密码(编辑后重启即可)
|
||||||
|
nano users.json
|
||||||
|
docker compose restart write-server
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 迁移到新服务器
|
## 迁移到新服务器
|
||||||
|
|
||||||
### 1. 新服务器准备
|
### 1. 安装 Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装 Docker
|
|
||||||
curl -fsSL https://get.docker.com | sh
|
curl -fsSL https://get.docker.com | sh
|
||||||
systemctl enable docker
|
systemctl enable docker && systemctl start docker
|
||||||
systemctl start docker
|
apt install -y docker-compose-plugin git certbot
|
||||||
|
|
||||||
# 安装 docker compose 插件
|
|
||||||
apt install -y docker-compose-plugin git
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 克隆项目
|
### 2. 克隆项目
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~
|
cd ~ && mkdir -p hugo && cd hugo
|
||||||
mkdir -p hugo && cd hugo
|
git clone <repo-url> blog
|
||||||
git clone <your-repo-url> blog
|
|
||||||
cd blog/write-server
|
cd blog/write-server
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 配置环境变量
|
### 3. 配置
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
# 环境变量
|
||||||
nano .env
|
cp .env.example .env && nano .env
|
||||||
|
|
||||||
|
# 用户(明文密码,启动时自动哈希)
|
||||||
|
cat > users.json << 'EOF'
|
||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{ "username": "admin", "password": "你的密码", "name": "小赵", "role": "admin" },
|
||||||
|
{ "username": "test", "password": "她的密码", "name": "辣辣", "role": "user" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
```
|
```
|
||||||
|
|
||||||
填写所有必要的 Token 和 API Key。
|
### 4. DNS + SSL
|
||||||
|
|
||||||
### 4. 生成 Basic Auth 密码
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm httpd:alpine htpasswd -nbB admin "your-password" > .htpasswd
|
# DNS: post.usj.cc → 新服务器 IP
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 配置域名 DNS
|
# SSL 证书
|
||||||
|
|
||||||
添加 A 记录指向新服务器 IP:
|
|
||||||
```
|
|
||||||
post.usj.cc → 新服务器IP
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 申请 SSL 证书
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apt install -y certbot
|
|
||||||
|
|
||||||
# 先启动 nginx(HTTP 模式)
|
|
||||||
docker compose up -d nginx
|
docker compose up -d nginx
|
||||||
|
|
||||||
# 停 nginx 释放 80 端口
|
|
||||||
docker compose stop nginx
|
docker compose stop nginx
|
||||||
|
certbot certonly --standalone -d post.usj.cc --agree-tos --email you@email.com
|
||||||
# 申请证书
|
|
||||||
certbot certonly --standalone -d post.usj.cc --agree-tos --email your@email.com
|
|
||||||
|
|
||||||
# 复制证书
|
|
||||||
mkdir -p nginx/ssl
|
mkdir -p nginx/ssl
|
||||||
cp /etc/letsencrypt/live/post.usj.cc/fullchain.pem nginx/ssl/
|
cp /etc/letsencrypt/live/post.usj.cc/fullchain.pem nginx/ssl/
|
||||||
cp /etc/letsencrypt/live/post.usj.cc/privkey.pem nginx/ssl/
|
cp /etc/letsencrypt/live/post.usj.cc/privkey.pem nginx/ssl/
|
||||||
|
```
|
||||||
|
|
||||||
# 启动所有服务
|
### 5. 防火墙 + 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ufw allow 22,80,443,1313/tcp && ufw enable
|
||||||
|
git config --global --add safe.directory /root/hugo/blog
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. 配置防火墙
|
### 6. 自动续期
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ufw allow 22/tcp
|
|
||||||
ufw allow 80/tcp
|
|
||||||
ufw allow 443/tcp
|
|
||||||
ufw allow 1313/tcp
|
|
||||||
ufw enable
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. 配置 Git
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 博客目录需要 git 可写
|
|
||||||
git config --global --add safe.directory /root/hugo/blog
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9. 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查容器状态
|
|
||||||
docker ps
|
|
||||||
|
|
||||||
# 检查 API
|
|
||||||
curl -s https://post.usj.cc/api/stats
|
|
||||||
|
|
||||||
# 检查 Hugo 预览
|
|
||||||
curl -s https://post.usj.cc:1313/ | head -5
|
|
||||||
```
|
|
||||||
|
|
||||||
### 10. 自动续期 SSL 证书
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 添加 crontab
|
|
||||||
crontab -e
|
crontab -e
|
||||||
|
# 添加: 0 3 1 * * certbot renew --quiet && docker compose restart nginx
|
||||||
# 添加:
|
|
||||||
0 3 1 * * certbot renew --quiet && docker compose restart nginx
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 迁移清单
|
## 迁移清单
|
||||||
|
|
||||||
换服务器时需要迁移的内容:
|
| 类型 | 内容 | 说明 |
|
||||||
|
|
||||||
### 必须迁移
|
|
||||||
|
|
||||||
| 内容 | 位置 | 说明 |
|
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 博客源码 | `~/hugo/blog/` | git clone 即可 |
|
| 必须 | 博客源码 | `git clone` |
|
||||||
| write-server 代码 | `~/hugo/blog/write-server/` | git clone 即可 |
|
| 必须 | `.env` | 手动填写 Token |
|
||||||
| .env 文件 | `write-server/.env` | 手动创建,填写 Token |
|
| 必须 | `users.json` | 手动创建,明文密码 |
|
||||||
| .htpasswd 文件 | `write-server/.htpasswd` | 重新生成 |
|
| 必须 | SSL 证书 | 新服务器重新申请 |
|
||||||
| SSL 证书 | `nginx/ssl/` | 重新申请 |
|
| 可选 | `recycle/` | 30 天过期,可不迁移 |
|
||||||
|
| 不需要 | Docker 镜像 | 重新构建 |
|
||||||
### 可选迁移
|
| 不需要 | `node_modules` | 自动安装 |
|
||||||
|
|
||||||
| 内容 | 位置 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| 回收站数据 | `write-server/recycle/` | 30 天自动过期,可不迁移 |
|
|
||||||
| 日志 | `write-server/logs/` | 可不迁移 |
|
|
||||||
|
|
||||||
### 需要在新服务器操作
|
|
||||||
|
|
||||||
| 操作 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| DNS 解析 | 把 post.usj.cc 指向新 IP |
|
|
||||||
| SSL 证书 | 在新服务器重新申请 |
|
|
||||||
| 防火墙 | 放行 80, 443, 1313 端口 |
|
|
||||||
| git safe.directory | 新服务器执行 `git config --global --add safe.directory ...` |
|
|
||||||
| 微信 IP 白名单 | 如果用公众号功能,新服务器 IP 需加入微信白名单 |
|
|
||||||
|
|
||||||
### 不需要迁移
|
|
||||||
|
|
||||||
| 内容 | 原因 |
|
|
||||||
|------|------|
|
|
||||||
| Docker 镜像 | 新服务器重新构建 |
|
|
||||||
| node_modules | Docker 构建时自动安装 |
|
|
||||||
| .next 目录 | Docker 构建时自动生成 |
|
|
||||||
| Hugo 二进制 | Dockerfile 自动安装 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 已知限制
|
## 已知限制
|
||||||
|
|
||||||
- Hugo 预览和写作后台共用域名,Hugo 导航链接只在 1313 端口有效
|
- Hugo 预览(1313 端口)导航链接独立,和主站不互通
|
||||||
- Telegram Bot 通过 Docker 挂载的 SSH 密钥进行 git push
|
- 博客目录 owner 会被 entrypoint 改为 uid 1001
|
||||||
- 博客目录 owner 会被 entrypoint 改为 uid 1001(nextjs 用户)
|
- 语音转文字需要配置 `DEEPSEEK_API_KEY` 或 `WHISPER_API_KEY`
|
||||||
- 3 小时登录过期(localStorage + Service Worker 双重检查)
|
- 新评论推送每 60 秒检查一次
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function PostEditor({ initialData, isEditing }: PostEditorProps)
|
|||||||
date: initialData?.frontMatter.date ? new Date(initialData.frontMatter.date).toISOString().slice(0, 10) : today,
|
date: initialData?.frontMatter.date ? new Date(initialData.frontMatter.date).toISOString().slice(0, 10) : today,
|
||||||
categories: initialData?.frontMatter.categories?.join("、") || "",
|
categories: initialData?.frontMatter.categories?.join("、") || "",
|
||||||
tags: initialData?.frontMatter.tags?.join("、") || "",
|
tags: initialData?.frontMatter.tags?.join("、") || "",
|
||||||
author: initialData?.frontMatter.author || "小赵",
|
author: initialData?.frontMatter.author || (() => { try { return JSON.parse(localStorage.getItem("wauth_user") || "{}").name || "小赵"; } catch { return "小赵"; } })(),
|
||||||
});
|
});
|
||||||
const [content, setContent] = useState(initialData?.content || "");
|
const [content, setContent] = useState(initialData?.content || "");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RefObject } from "react";
|
import { RefObject } from "react";
|
||||||
import { Heading2, Heading3, Code, Quote, Image, Link, Minus, Bold, FileCode, Grid3X3 } from "lucide-react";
|
import { Heading2, Heading3, Code, Quote, Image, Link, Minus, Bold, FileCode, Grid3X3, Video, Play } from "lucide-react";
|
||||||
|
|
||||||
interface ShortcutPanelProps {
|
interface ShortcutPanelProps {
|
||||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||||
@@ -51,6 +51,15 @@ export default function ShortcutPanel({ textareaRef, value, onChange }: Shortcut
|
|||||||
{{< /grid >}}
|
{{< /grid >}}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const videoTemplate = `{{< video src="/video/example.mp4" caption="视频说明" >}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const bilibiliTemplate = `{{< bilibili BV1xx411c7mD >}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const youtubeTemplate = `{{< youtube dQw4w9WgXcQ >}}
|
||||||
|
`;
|
||||||
|
|
||||||
const shortcuts = [
|
const shortcuts = [
|
||||||
{ label: "H2", icon: Heading2, action: () => insert("## ", "\n", "二级标题") },
|
{ label: "H2", icon: Heading2, action: () => insert("## ", "\n", "二级标题") },
|
||||||
{ label: "H3", icon: Heading3, action: () => insert("### ", "\n", "三级标题") },
|
{ label: "H3", icon: Heading3, action: () => insert("### ", "\n", "三级标题") },
|
||||||
@@ -60,6 +69,9 @@ export default function ShortcutPanel({ textareaRef, value, onChange }: Shortcut
|
|||||||
{ label: "引用", icon: Quote, action: () => insert("> ", "\n", "引用内容") },
|
{ label: "引用", icon: Quote, action: () => insert("> ", "\n", "引用内容") },
|
||||||
{ label: "图片", icon: Image, action: () => insert("![", "]()", "描述") },
|
{ label: "图片", icon: Image, action: () => insert("![", "]()", "描述") },
|
||||||
{ label: "图片网格", icon: Grid3X3, action: () => insertBlock(gridTemplate) },
|
{ label: "图片网格", icon: Grid3X3, action: () => insertBlock(gridTemplate) },
|
||||||
|
{ label: "本地视频", icon: Video, action: () => insertBlock(videoTemplate) },
|
||||||
|
{ label: "B站视频", icon: Play, action: () => insertBlock(bilibiliTemplate) },
|
||||||
|
{ label: "YouTube", icon: Play, action: () => insertBlock(youtubeTemplate) },
|
||||||
{ label: "链接", icon: Link, action: () => insert("[", "]()", "链接文本") },
|
{ label: "链接", icon: Link, action: () => insert("[", "]()", "链接文本") },
|
||||||
{ label: "分割线", icon: Minus, action: () => insert("\n---\n", "", "") },
|
{ label: "分割线", icon: Minus, action: () => insert("\n---\n", "", "") },
|
||||||
];
|
];
|
||||||
@@ -86,7 +98,8 @@ export default function ShortcutPanel({ textareaRef, value, onChange }: Shortcut
|
|||||||
<ul className="text-[11px] text-muted/70 space-y-1 leading-relaxed">
|
<ul className="text-[11px] text-muted/70 space-y-1 leading-relaxed">
|
||||||
<li>选中文字后点按钮可包裹</li>
|
<li>选中文字后点按钮可包裹</li>
|
||||||
<li>未选中则插入模板 + 占位文字</li>
|
<li>未选中则插入模板 + 占位文字</li>
|
||||||
<li>代码块自动补全语言标注</li>
|
<li>视频需替换 src 中的路径</li>
|
||||||
|
<li>B站/YouTube 替换 BV号/ID</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -257,11 +257,17 @@ async function handleDocument(chatId: number, fileId: string, fileName?: string,
|
|||||||
const filename = Date.now() + "-" + fileId.slice(0, 8) + "." + ext;
|
const filename = Date.now() + "-" + fileId.slice(0, 8) + "." + ext;
|
||||||
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(fileBuffer));
|
fs.writeFileSync(path.join(articleDir, filename), Buffer.from(fileBuffer));
|
||||||
|
|
||||||
// GIF 用图片语法,其他用链接
|
// GIF 用图片语法,视频用 shortcode,其他用链接
|
||||||
const isImage = ["gif", "webp", "png", "jpg", "jpeg"].includes(ext.toLowerCase());
|
const extLower = ext.toLowerCase();
|
||||||
|
const isImage = ["gif", "webp", "png", "jpg", "jpeg"].includes(extLower);
|
||||||
|
const isVideo = ["mp4", "webm", "mov", "avi", "mkv", "flv"].includes(extLower);
|
||||||
const fileCaption = caption || fileName || "file";
|
const fileCaption = caption || fileName || "file";
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
appendContent(chatId, `\n\n`);
|
appendContent(chatId, `\n\n`);
|
||||||
|
} else if (isVideo) {
|
||||||
|
appendContent(chatId, `\n{{< video src="${filename}" caption="${fileCaption}" >}}\n`);
|
||||||
|
await sendMessage(chatId, `🎬 视频已保存:${filename}`);
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
appendContent(chatId, `\n[${fileCaption}](${filename})\n`);
|
appendContent(chatId, `\n[${fileCaption}](${filename})\n`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user