init
This commit is contained in:
30
.claude/settings.local.json
Normal file
30
.claude/settings.local.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(node src/scripts/initDb.js)",
|
||||
"Bash(node -e \"require\\('dotenv'\\).config\\(\\); console.log\\('DB_HOST:', process.env.DB_HOST\\); console.log\\('DB_PORT:', process.env.DB_PORT\\); console.log\\('DB_USER:', process.env.DB_USER\\); console.log\\('DB_NAME:', process.env.DB_NAME\\);\")",
|
||||
"Bash(node -e ' *)",
|
||||
"Bash(curl -s http://localhost:3000/api/settings)",
|
||||
"Bash(curl *)",
|
||||
"Bash(node src/scripts/migrateColumns.js)",
|
||||
"Bash(mysql *)",
|
||||
"Bash(node -e \"const d=require\\('fs'\\).readFileSync\\(0,'utf8'\\); const j=JSON.parse\\(d\\); console.log\\('uploadedImages length:', j.uploadedImages?.length\\); console.log\\('uploadedImages[0] id:', j.uploadedImages?.[0]?.id\\);\")",
|
||||
"Bash(node -e \"const j=require\\('/tmp/settings.json'\\); console.log\\('uploadedImages length:', j.uploadedImages?.length\\); console.log\\('uploadedImages[0] id:', j.uploadedImages?.[0]?.id\\);\")",
|
||||
"Bash(node -p \"const j=JSON.parse\\(require\\('fs'\\).readFileSync\\(0,'utf8'\\)\\); j.uploadedImages?.length\")",
|
||||
"Bash(node src/index.js)",
|
||||
"Bash(netstat -ano)",
|
||||
"Bash(grep3000)",
|
||||
"Bash(node src/scripts/addSamplePosts.js)",
|
||||
"Bash(node src/scripts/insertPosts.js)",
|
||||
"Bash(npm list *)",
|
||||
"Bash(node -e \"require\\('./src/index.js'\\)\")",
|
||||
"Bash(npx pkg *)",
|
||||
"Bash(node package.js)",
|
||||
"Bash(npm view *)",
|
||||
"Bash(git restore *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
8
.env
Normal file
8
.env
Normal file
@@ -0,0 +1,8 @@
|
||||
# 本地开发环境配置
|
||||
# npm run dev 使用此配置(mode=development)
|
||||
|
||||
# 后端端口
|
||||
VITE_API_PORT=4501
|
||||
|
||||
# 留空,使用本地代理模式
|
||||
VITE_SERVER_URL=
|
||||
7
.env.development
Normal file
7
.env.development
Normal file
@@ -0,0 +1,7 @@
|
||||
# 本地开发环境配置
|
||||
|
||||
# 后端端口
|
||||
VITE_API_PORT=4501
|
||||
|
||||
# 留空,使用本地代理模式
|
||||
VITE_SERVER_URL=
|
||||
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# 前端环境变量配置模板
|
||||
# 实际配置请使用 .env.development(本地)或 .env.production(远程)
|
||||
|
||||
# 后端端口
|
||||
VITE_API_PORT=4501
|
||||
|
||||
# 远程服务器地址(为空时使用本地代理模式)
|
||||
VITE_SERVER_URL=http://43.156.91.115:4501
|
||||
7
.env.production
Normal file
7
.env.production
Normal file
@@ -0,0 +1,7 @@
|
||||
# 远程生产环境配置
|
||||
|
||||
# 后端端口
|
||||
VITE_API_PORT=4501
|
||||
|
||||
# 远程服务器地址(直接连接,不走代理)
|
||||
VITE_SERVER_URL=http://43.156.91.115:4501
|
||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
138
CHANGELOG.md
Normal file
138
CHANGELOG.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 修改记录
|
||||
|
||||
## 2026-06-10
|
||||
|
||||
### 1. 优化图片上传性能
|
||||
**文件**: `server/src/routes/settings.js`
|
||||
|
||||
**问题**: 批量上传图片到 RUSTFS 时串行执行,每张图片需等待上一张完成,导致上传时间过长。
|
||||
|
||||
**解决方案**: 将 `for...of` 串行上传改为 `Promise.all` 并行上传。
|
||||
|
||||
**改动内容**:
|
||||
- 将批量上传接口 `/upload-images` 的串行逻辑改为并行
|
||||
- 使用 `Promise.all()` 同时上传所有图片
|
||||
|
||||
---
|
||||
|
||||
### 2. 新增服务端配置页面
|
||||
**目标**: 在前端设置界面添加配置页面,支持动态修改 .env 配置并重启后端服务。
|
||||
|
||||
#### 后端新增文件
|
||||
|
||||
**`server/src/config/envManager.js`**
|
||||
- `readEnv()` - 读取并解析 .env 文件
|
||||
- `writeEnv(config)` - 将配置对象写入 .env 文件
|
||||
- `getSafeConfig()` - 返回安全展示的配置(密码用 `******` 替代)
|
||||
|
||||
**`server/src/routes/config.js`**
|
||||
- `GET /api/config` - 获取当前配置(密码隐藏)
|
||||
- `PUT /api/config` - 更新配置并写入 .env 文件
|
||||
- `POST /api/config/restart` - 优雅重启后端服务
|
||||
|
||||
#### 后端修改文件
|
||||
|
||||
**`server/src/index.js`**
|
||||
- 注册新路由 `app.use('/api/config', configRouter)`
|
||||
|
||||
#### 前端新增文件
|
||||
|
||||
**`src/views/ServerConfigView.vue`**
|
||||
- 配置分组显示:数据库、RUSTFS、服务器
|
||||
- 密码字段显示为 `******`,修改时留空则不修改原密码
|
||||
- 保存按钮 - 调用 API 保存配置到 .env
|
||||
- 重启按钮 - 调用 API 优雅重启服务(需 PM2 等进程管理器)
|
||||
|
||||
#### 前端修改文件
|
||||
|
||||
**`src/router/index.js`**
|
||||
- 添加路由 `/server-config` -> ServerConfigView(懒加载)
|
||||
|
||||
**`src/i18n/index.js`**
|
||||
- 添加 `serverConfig` 中英文翻译
|
||||
|
||||
**`src/components/SiteHeader.vue`**
|
||||
- 导航栏添加"服务端配置"链接
|
||||
|
||||
---
|
||||
|
||||
### 使用说明
|
||||
|
||||
1. **启动后端**(需使用 PM2 管理进程):
|
||||
```bash
|
||||
pm2 start npm -- run dev
|
||||
```
|
||||
|
||||
2. **访问配置页面**: 启动后端后访问 `/server-config`
|
||||
|
||||
3. **保存配置**: 点击"保存配置"按钮,配置会写入 `.env` 文件
|
||||
|
||||
4. **重启服务**: 点击"重启服务"按钮,后端会优雅退出(`process.exit(0)`),PM2 会自动重启
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 密码字段显示为 `******`,如需修改请输入新密码,留空则保持原密码不变
|
||||
- 重启功能需要配合 PM2 等进程管理器使用,否则服务将无法自动恢复
|
||||
|
||||
---
|
||||
|
||||
### 3. 新增文章编辑功能
|
||||
|
||||
**目标**: 提供完整的文章新建和编辑功能。
|
||||
|
||||
#### 前端新增文件
|
||||
|
||||
**`src/views/PostEditorView.vue`**
|
||||
- 新建文章页面 (`/post/new`)
|
||||
- 编辑文章页面 (`/post/edit/:id`)
|
||||
- 包含标题、日期、摘要、正文等字段
|
||||
- 保存后自动返回文章列表
|
||||
|
||||
#### 前端修改文件
|
||||
|
||||
**`src/views/HomeView.vue`**
|
||||
- 页面头部添加"新建文章"按钮
|
||||
|
||||
**`src/views/PostDetail.vue`**
|
||||
- 添加"编辑"和"删除"按钮
|
||||
- 支持从文章详情页直接编辑或删除
|
||||
|
||||
**`src/router/index.js`**
|
||||
- 添加路由 `/post/new` -> PostEditorView
|
||||
- 添加路由 `/post/edit/:id` -> PostEditorView
|
||||
|
||||
**`src/i18n/index.js`**
|
||||
- 添加 `home.newPost` 翻译
|
||||
- 添加 `postEditor` 完整翻译(中英文)
|
||||
|
||||
---
|
||||
|
||||
### 4. 后端定时任务调度器
|
||||
|
||||
**目标**: 将定时任务调度逻辑从前端移到后端,确保即使没有人打开页面,任务也会按计划执行。
|
||||
|
||||
#### 新增文件
|
||||
|
||||
**`server/src/config/cronScheduler.js`**
|
||||
- `start()` - 启动调度器,每 10 秒检查一次到期任务
|
||||
- `stop()` - 停止调度器
|
||||
- `checkAndRunTasks()` - 检查并执行到期的任务
|
||||
- `executeTask(task)` - 执行单个定时任务
|
||||
|
||||
#### 后端修改文件
|
||||
|
||||
**`server/src/index.js`**
|
||||
- 导入 `cronScheduler` 模块
|
||||
- 在服务器启动时调用 `cronScheduler.start()`
|
||||
|
||||
#### 功能说明
|
||||
|
||||
- 调度器每 10 秒检查一次所有已启用的定时任务
|
||||
- 当任务的 `nextRun` 时间已到,立即执行任务
|
||||
- 执行后自动计算下一次执行时间并更新数据库
|
||||
- 任务在后端运行,不依赖前端页面打开
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 定时任务由后端调度,前端 CronView 仍可手动执行和查看任务状态
|
||||
- 后端调度需在服务器运行期间持续工作(由 PM2 等进程管理器保持)
|
||||
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目命令
|
||||
|
||||
```sh
|
||||
npm install # 安装依赖
|
||||
npm run dev # 启动开发服务器(热重载)
|
||||
npm run build # 构建生产版本
|
||||
npm run preview # 预览生产构建(端口 5050)
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
- **Vue 3** 单页应用,使用组合式 API(`<script setup>`)
|
||||
- **Pinia** 状态管理(`src/stores/`)
|
||||
- **Vue Router 4** 路由(`src/router/index.js`),懒加载视图
|
||||
- **Vite** 构建工具,`@` 别名指向 `src/`
|
||||
- **国际化**:简单的 i18n 实现(`src/i18n/index.js` + `src/composables/useI18n.js`)
|
||||
|
||||
## 页面结构
|
||||
|
||||
| 路由 | 组件 | 说明 |
|
||||
|------|------|------|
|
||||
| `/` | HomeView + PostList | 文章列表 |
|
||||
| `/post/:id` | PostDetail | 文章详情 |
|
||||
| `/about` | AboutView | 关于页面 |
|
||||
| `/cron` | CronView | 定时任务系统 |
|
||||
| `/settings` | SettingsView | 设置(背景、语言、图标) |
|
||||
|
||||
## 核心文件
|
||||
|
||||
- `src/App.vue` - 根组件,包含背景层和路由出口
|
||||
- `src/stores/settings.js` - 用户设置(背景、语言、网站图标、已上传资源库)
|
||||
- `src/stores/posts.js` - 文章数据
|
||||
- `src/components/SiteHeader.vue` - 顶部导航栏(国际化的站点名称和导航链接)
|
||||
- `src/i18n/index.js` - 翻译文本(中英文)
|
||||
|
||||
## 设置系统
|
||||
|
||||
设置存储在 localStorage `site-settings` 中,包含:
|
||||
- `bgType`: 'color' | 'image'
|
||||
- `bgColor`: 背景颜色
|
||||
- `bgImage`: 背景图片URL(支持base64本地图片)
|
||||
- `bgOpacity`: 背景透明度 0-1
|
||||
- `language`: 'zh' | 'en'
|
||||
- `favicon`: 网站图标(base64)
|
||||
- `uploadedImages`: 已上传的背景图片库
|
||||
- `uploadedIcons`: 已上传的图标库
|
||||
|
||||
背景通过 `App.vue` 中的固定定位背景层渲染,透明度独立于内容层。
|
||||
|
||||
## 定时任务系统
|
||||
|
||||
`CronView.vue` 实现简单的定时任务系统:
|
||||
- Cron表达式格式:`分 时 日 月 周`
|
||||
- 任务保存在 localStorage `cron-tasks` 中
|
||||
- 每 10 秒检查一次是否需要执行
|
||||
- 支持 GET/POST/PUT/PATCH/DELETE 请求
|
||||
- 使用 `useI18n` composable 实现国际化
|
||||
201
DEPLOY.md
Normal file
201
DEPLOY.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# JNote-UI 打包部署文档
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
jnote-ui/
|
||||
├── src/ # 前端源代码
|
||||
├── dist/ # 前端构建产物
|
||||
├── server/ # 后端服务
|
||||
│ ├── src/
|
||||
│ │ ├── index.js # 后端入口
|
||||
│ │ ├── config/ # 配置文件
|
||||
│ │ └── routes/ # 路由
|
||||
│ ├── build.js # 打包脚本
|
||||
│ ├── package.js # 分发包打包脚本
|
||||
│ ├── start.js # 启动脚本
|
||||
│ ├── .env # 环境配置
|
||||
│ └── package.json
|
||||
└── package.json # 前端依赖
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一、打包前端
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建完成后会生成 `dist/` 目录。
|
||||
|
||||
---
|
||||
|
||||
## 二、打包后端
|
||||
|
||||
### 方式一:快速打包(生成可执行文件)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 打包 Linux 和 Windows 两个版本
|
||||
node package.js
|
||||
```
|
||||
|
||||
打包完成后会生成 `server/release/` 目录:
|
||||
|
||||
```
|
||||
release/
|
||||
├── README.txt
|
||||
├── linux/
|
||||
│ ├── jnote-api # Linux 可执行文件
|
||||
│ ├── start.js # 启动脚本
|
||||
│ └── .env # 环境配置
|
||||
└── windows/
|
||||
├── jnote-api.exe # Windows 可执行文件
|
||||
├── start.js # 启动脚本
|
||||
└── .env # 环境配置
|
||||
```
|
||||
|
||||
### 方式二:仅打包 Linux 版本
|
||||
|
||||
```bash
|
||||
cd server
|
||||
node build.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、部署后端
|
||||
|
||||
### Linux 服务器部署
|
||||
|
||||
将 `release/linux/` 目录上传到服务器:
|
||||
|
||||
```bash
|
||||
# 上传
|
||||
scp -r release/linux/ user@your-server:/path/to/jnote/
|
||||
|
||||
# SSH 登录后
|
||||
cd /path/to/jnote
|
||||
chmod +x jnote-api start.js
|
||||
./start.js
|
||||
```
|
||||
|
||||
### Windows 服务器部署
|
||||
|
||||
将 `release/windows/` 目录上传到服务器:
|
||||
|
||||
```powershell
|
||||
cd windows
|
||||
node start.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、配置 Nginx
|
||||
|
||||
后端 API 默认运行在 `http://localhost:3000`,前端静态文件通过 Nginx 提供。
|
||||
|
||||
### Nginx 配置示例
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name 你的域名或IP;
|
||||
|
||||
# 前端静态文件
|
||||
root /path/to/jnote-ui/dist;
|
||||
index index.html;
|
||||
|
||||
# 前端路由(SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、常用命令汇总
|
||||
|
||||
```bash
|
||||
# 1. 构建前端
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# 2. 打包后端(Windows/Linux 双版本)
|
||||
cd server
|
||||
npm install
|
||||
node package.js
|
||||
|
||||
# 3. 上传 release/linux/ 到服务器后启动
|
||||
chmod +x jnote-api start.js
|
||||
./start.js
|
||||
|
||||
# 4. PM2 管理(可选)
|
||||
pm2 start jnote-api --name jnote-api
|
||||
pm2 save
|
||||
pm2 startup
|
||||
|
||||
# 5. PM2 常用命令
|
||||
pm2 status # 查看状态
|
||||
pm2 logs jnote-api # 查看日志
|
||||
pm2 restart jnote-api # 重启
|
||||
pm2 stop jnote-api # 停止
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、端口配置说明
|
||||
|
||||
### 后端端口
|
||||
|
||||
在 `server/.env` 中配置:
|
||||
```env
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
### 前端代理端口
|
||||
|
||||
在 `jnote-ui/.env` 中配置(与 server/.env 的 PORT 保持一致):
|
||||
```env
|
||||
VITE_API_PORT=3000
|
||||
```
|
||||
|
||||
如果后端端口改变,必须同时修改这两个文件。
|
||||
|
||||
---
|
||||
|
||||
## 七、更新部署
|
||||
|
||||
```bash
|
||||
# 1. 拉取最新代码
|
||||
git pull
|
||||
|
||||
# 2. 重新构建前端
|
||||
npm run build
|
||||
|
||||
# 3. 重新打包后端
|
||||
cd server
|
||||
node package.js
|
||||
|
||||
# 4. 上传新的 release/ 目录到服务器
|
||||
|
||||
# 5. 重启后端
|
||||
pm2 restart jnote-api
|
||||
```
|
||||
164
README.md
Normal file
164
README.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Jnote-ui
|
||||
|
||||
博客前端 + 后端完整项目
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
jnote-ui/
|
||||
├── server/ # Node.js 后端
|
||||
│ ├── src/
|
||||
│ │ ├── index.js # 入口
|
||||
│ │ ├── config/
|
||||
│ │ │ ├── cronScheduler.js # 定时任务调度器
|
||||
│ │ │ ├── database.js # 数据库连接
|
||||
│ │ │ ├── envManager.js # .env 文件管理
|
||||
│ │ │ ├── memoryStore.js # 内存后备存储
|
||||
│ │ │ ├── rustfs.js # RUSTFS 对象存储
|
||||
│ │ │ └── schema.js # 数据库表结构
|
||||
│ │ └── routes/
|
||||
│ │ ├── about.js # 关于内容
|
||||
│ │ ├── config.js # 服务端配置
|
||||
│ │ ├── cron.js # 定时任务
|
||||
│ │ ├── posts.js # 文章 CRUD
|
||||
│ │ └── settings.js # 设置
|
||||
│ ├── .env # 环境配置
|
||||
│ └── package.json
|
||||
│
|
||||
└── src/ # Vue 3 前端
|
||||
├── components/ # 组件
|
||||
├── composables/ # 组合式函数
|
||||
├── i18n/ # 国际化
|
||||
├── services/
|
||||
│ └── api.js # API 调用层
|
||||
├── stores/ # Pinia 状态管理
|
||||
├── views/ # 页面视图
|
||||
├── router/ # 路由配置
|
||||
└── App.vue # 根组件
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 后端启动(开发)
|
||||
|
||||
```sh
|
||||
cd server
|
||||
npm install
|
||||
|
||||
# 编辑 .env 填入数据库连接信息(可选,不填则使用内存存储)
|
||||
# DB_HOST=localhost
|
||||
# DB_PORT=3306
|
||||
# DB_USER=root
|
||||
# DB_PASSWORD=your_password
|
||||
# DB_NAME=jnote
|
||||
|
||||
# 启动后端(开发模式热重载)
|
||||
npm run dev
|
||||
|
||||
# 或生产模式
|
||||
npm start
|
||||
```
|
||||
|
||||
后端运行在 http://localhost:3000
|
||||
|
||||
### 2. 前端启动
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端运行在 http://localhost:5173,会自动代理 `/api` 请求到后端
|
||||
|
||||
### 3. 服务器部署(一键打包)
|
||||
|
||||
在 Windows 上运行打包命令,生成 Linux 和 Windows 两个版本:
|
||||
|
||||
```sh
|
||||
cd server
|
||||
npm install
|
||||
node package.js
|
||||
```
|
||||
|
||||
打包完成后 `release/` 目录包含:
|
||||
- `linux/` - Linux 服务器部署包(jnote-api + start.js + .env)
|
||||
- `windows/` - Windows 服务器部署包
|
||||
|
||||
上传到服务器后直接运行 `node start.js` 即可启动。
|
||||
|
||||
## 功能模块
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 路由 | 页面 | 说明 |
|
||||
|------|------|------|
|
||||
| `/` | HomeView | 文章列表 |
|
||||
| `/post/:id` | PostDetail | 文章详情(Markdown 渲染) |
|
||||
| `/post/new` | PostEditorView | 新建文章 |
|
||||
| `/post/edit/:id` | PostEditorView | 编辑文章 |
|
||||
| `/about` | AboutView | 关于页面 |
|
||||
| `/cron` | CronView | 定时任务系统 |
|
||||
| `/settings` | SettingsView | 设置(背景、语言、图标) |
|
||||
| `/server-config` | ServerConfigView | 服务端配置 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
**前端**: Vue 3 + Vite + Pinia + Vue Router + marked
|
||||
**后端**: Express + mysql2 + dotenv
|
||||
**存储**: MariaDB + RUSTFS 对象存储
|
||||
|
||||
## 环境变量
|
||||
|
||||
### 后端 (`server/.env`)
|
||||
|
||||
```env
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://localhost:9001
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=your_key
|
||||
RUSTFS_SECRET_KEY=your_secret
|
||||
|
||||
# 服务器配置
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
### 前端 (`.env` 或 `.env.local`)
|
||||
|
||||
```env
|
||||
VITE_API_URL=/api # API 地址,默认为 /api(通过 vite 代理)
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/posts` | GET/POST | 文章列表/创建 |
|
||||
| `/api/posts/:id` | GET/PUT/DELETE | 文章详情/更新/删除 |
|
||||
| `/api/about` | GET/PUT | 关于内容 |
|
||||
| `/api/settings` | GET/PUT | 设置 |
|
||||
| `/api/settings/upload-image` | POST | 上传单张图片到 RUSTFS |
|
||||
| `/api/settings/upload-images` | POST | 批量上传图片到 RUSTFS |
|
||||
| `/api/cron-tasks` | GET/POST | 定时任务列表/创建 |
|
||||
| `/api/cron-tasks/:id` | PUT/DELETE | 更新/删除任务 |
|
||||
| `/api/cron-tasks/:id/run` | POST | 立即执行任务 |
|
||||
| `/api/config` | GET/PUT | 服务端配置 |
|
||||
| `/api/config/restart` | POST | 重启后端服务 |
|
||||
| `/api/health` | GET | 健康检查 |
|
||||
|
||||
## 主要特性
|
||||
|
||||
- **文章管理**: 创建、编辑、删除文章,支持 Markdown
|
||||
- **定时任务**: Cron 表达式调度,支持 GET/POST/PUT/PATCH/DELETE 请求
|
||||
- **自定义背景**: 纯色或图片背景,可调节透明度
|
||||
- **多语言**: 中英文切换
|
||||
- **自定义图标**: 上传本地图片作为网站图标
|
||||
- **图片存储**: 上传图片到 RUSTFS 对象存储
|
||||
- **服务端配置**: Web 界面修改数据库和存储配置
|
||||
- **数据预热**: 启动时缓存常用数据,提升响应速度
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>我的编程学习记录</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1393
package-lock.json
generated
Normal file
1393
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "jnote-ui",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:prod": "vite build --mode production",
|
||||
"preview": "vite preview --port 5050"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^18.0.5",
|
||||
"pinia": "^2.0.4",
|
||||
"vue": "^3.2.22",
|
||||
"vue-router": "^4.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^1.10.0",
|
||||
"vite": "^2.6.14"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
17
server/.env
Normal file
17
server/.env
Normal file
@@ -0,0 +1,17 @@
|
||||
# 数据库配置
|
||||
DB_HOST=43.156.91.115
|
||||
DB_PORT=53306
|
||||
DB_USER=root
|
||||
# DB_PASSWORD=mysql_sX4mrh
|
||||
DB_PASSWORD=mariadb_hJbME5
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://43.156.91.115:9001
|
||||
RUSTFS_REGION=us-east-1
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=4501
|
||||
76
server/README.md
Normal file
76
server/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Jnote Server
|
||||
|
||||
博客后端 API 服务
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `.env` 文件,填入数据库连接信息:
|
||||
|
||||
```
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=jnote
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
如果不配置数据库,将使用内存存储作为后备方案。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# 开发模式(热重载)
|
||||
npm run dev
|
||||
|
||||
# 生产模式
|
||||
npm start
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
||||
### 文章
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/posts` | GET | 获取所有文章 |
|
||||
| `/api/posts/:id` | GET | 获取单篇文章 |
|
||||
| `/api/posts` | POST | 创建文章 |
|
||||
| `/api/posts/:id` | PUT | 更新文章 |
|
||||
| `/api/posts/:id` | DELETE | 删除文章 |
|
||||
|
||||
### 关于
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/about` | GET | 获取关于内容 |
|
||||
| `/api/about` | PUT | 更新关于内容 |
|
||||
|
||||
### 设置
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/settings` | GET | 获取设置 |
|
||||
| `/api/settings` | PUT | 更新设置 |
|
||||
|
||||
### 定时任务
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/cron-tasks` | GET | 获取所有任务 |
|
||||
| `/api/cron-tasks` | POST | 创建任务 |
|
||||
| `/api/cron-tasks/:id` | PUT | 更新任务 |
|
||||
| `/api/cron-tasks/:id` | DELETE | 删除任务 |
|
||||
| `/api/cron-tasks/:id/run` | POST | 立即执行任务 |
|
||||
|
||||
### 健康检查
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/health` | GET | 服务健康状态 |
|
||||
89
server/build.js
Normal file
89
server/build.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* JNote 后端打包脚本
|
||||
* 支持 Node.js 16.x
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const serverDir = __dirname;
|
||||
|
||||
// Node 16.x -> pkg 5.8.1
|
||||
const PKG_VERSION = '5.8.1';
|
||||
const TARGET_NODE_VERSION = 'node16';
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[Build] ${msg}`);
|
||||
}
|
||||
|
||||
function run(cmd) {
|
||||
log(`执行: ${cmd}`);
|
||||
execSync(cmd, { cwd: serverDir, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
function checkNodeVersion() {
|
||||
const version = execSync('node --version', { encoding: 'utf8' }).trim();
|
||||
const match = version.match(/^v(\d+)\./);
|
||||
if (match) {
|
||||
return parseInt(match[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('=========================================');
|
||||
console.log(' JNote 后端打包工具');
|
||||
console.log(` 当前平台: ${isWindows ? 'Windows' : 'Linux'}`);
|
||||
console.log('=========================================');
|
||||
|
||||
// 检查 Node.js 环境
|
||||
const nodeMajor = checkNodeVersion();
|
||||
if (!nodeMajor) {
|
||||
console.error('❌ 错误: 未找到 Node.js');
|
||||
process.exit(1);
|
||||
}
|
||||
log(`✓ Node.js 版本: v${nodeMajor}.x`);
|
||||
|
||||
// 安装依赖
|
||||
console.log('');
|
||||
log('📦 安装依赖...');
|
||||
run('npm install');
|
||||
|
||||
// 安装 pkg
|
||||
try {
|
||||
execSync('pkg --version', { encoding: 'utf8', stdio: 'ignore' });
|
||||
log('✓ pkg 已安装');
|
||||
} catch {
|
||||
console.log('');
|
||||
log(`📦 安装 pkg ${PKG_VERSION}...`);
|
||||
run(`npm install -g pkg@${PKG_VERSION}`);
|
||||
}
|
||||
|
||||
// 创建 dist 目录
|
||||
const distDir = path.join(serverDir, 'dist');
|
||||
if (!fs.existsSync(distDir)) {
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 打包 Linux 版本
|
||||
console.log('');
|
||||
log(`🔨 打包 Linux 版本 (${TARGET_NODE_VERSION}-linux-x64)...`);
|
||||
run(`pkg src/index.js --targets ${TARGET_NODE_VERSION}-linux-x64 --output dist/jnote-api`);
|
||||
|
||||
console.log('');
|
||||
console.log('=========================================');
|
||||
console.log(' 打包完成!');
|
||||
console.log('=========================================');
|
||||
console.log('');
|
||||
console.log('输出文件:');
|
||||
console.log(' - dist/jnote-api (Linux 可执行文件)');
|
||||
console.log('');
|
||||
console.log('下一步:');
|
||||
console.log(' 1. 将 dist/ 目录下的文件上传到服务器');
|
||||
console.log(' 2. 修改 .env 配置数据库等信息');
|
||||
console.log(' 3. 运行: chmod +x jnote-api && ./jnote-api');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main();
|
||||
5732
server/package-lock.json
generated
Normal file
5732
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
193
server/package.js
Normal file
193
server/package.js
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* JNote 后端分发包打包脚本
|
||||
* 在 Windows 上运行,打包 Linux 和 Windows 两个版本
|
||||
* 优化:Linux 生成 sh 脚本,Windows 生成 bat 脚本,原生直接执行
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const serverDir = __dirname;
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[Package] ${msg}`);
|
||||
}
|
||||
|
||||
function run(cmd) {
|
||||
log(`执行: ${cmd}`);
|
||||
execSync(cmd, { cwd: serverDir, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('=========================================');
|
||||
console.log(' JNote 后端分发包打包工具');
|
||||
console.log(` 当前平台: ${isWindows ? 'Windows' : 'Linux'}`);
|
||||
console.log('=========================================');
|
||||
|
||||
// 检查 Node.js 环境
|
||||
try {
|
||||
const nodeVersion = execSync('node --version', { encoding: 'utf8' }).trim();
|
||||
log(`✓ Node.js 版本: ${nodeVersion}`);
|
||||
} catch {
|
||||
console.error('❌ 错误: 未找到 Node.js');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 安装依赖
|
||||
console.log('');
|
||||
log('📦 安装依赖...');
|
||||
run('npm install');
|
||||
|
||||
// 安装 pkg (如果未安装)
|
||||
try {
|
||||
execSync('pkg --version', { encoding: 'utf8', stdio: 'ignore' });
|
||||
log('✓ pkg 已安装');
|
||||
} catch {
|
||||
console.log('');
|
||||
log('📦 安装 pkg...');
|
||||
run('npm install -g pkg');
|
||||
}
|
||||
|
||||
// 创建输出目录
|
||||
const distDir = path.join(serverDir, 'dist');
|
||||
const releaseDir = path.join(serverDir, 'release');
|
||||
|
||||
// 清空并重建目录
|
||||
if (fs.existsSync(distDir)) fs.rmSync(distDir, { recursive: true, force: true });
|
||||
if (fs.existsSync(releaseDir)) fs.rmSync(releaseDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.mkdirSync(releaseDir, { recursive: true });
|
||||
|
||||
const releaseLinux = path.join(releaseDir, 'linux');
|
||||
const releaseWindows = path.join(releaseDir, 'windows');
|
||||
fs.mkdirSync(releaseLinux, { recursive: true });
|
||||
fs.mkdirSync(releaseWindows, { recursive: true });
|
||||
|
||||
// 打包 Linux 版本(适配你的 Node16)
|
||||
console.log('');
|
||||
log('🔨 打包 Linux 版本...');
|
||||
run('pkg src/index.js --targets node16-linux-x64 --output dist/jnote-api');
|
||||
|
||||
// 打包 Windows 版本(适配你的 Node16)
|
||||
console.log('');
|
||||
log('🔨 打包 Windows 版本...');
|
||||
run('pkg src/index.js --targets node16-win-x64 --output dist/jnote-api.exe');
|
||||
|
||||
// ===================== 核心优化 =====================
|
||||
// 1. 复制 Linux 可执行文件 + 生成 .sh 启动脚本
|
||||
const linuxExe = path.join(distDir, 'jnote-api');
|
||||
if (fs.existsSync(linuxExe)) {
|
||||
const targetLinuxExe = path.join(releaseLinux, 'jnote-api');
|
||||
fs.copyFileSync(linuxExe, targetLinuxExe);
|
||||
fs.chmodSync(targetLinuxExe, 0o755);
|
||||
|
||||
// 生成 Linux 原生启动脚本 start.sh
|
||||
const shScript = `#!/bin/bash
|
||||
# JNote 后端服务启动脚本
|
||||
cd "$(dirname "$0")"
|
||||
./jnote-api
|
||||
`;
|
||||
const shPath = path.join(releaseLinux, 'start.sh');
|
||||
fs.writeFileSync(shPath, shScript, 'utf8');
|
||||
fs.chmodSync(shPath, 0o755); // 赋可执行权限
|
||||
log('✓ linux/jnote-api + start.sh (可直接执行)');
|
||||
}
|
||||
|
||||
// 2. 复制 Windows 可执行文件 + 生成 .bat 启动脚本
|
||||
const winExe = path.join(distDir, 'jnote-api.exe');
|
||||
if (fs.existsSync(winExe)) {
|
||||
fs.copyFileSync(winExe, path.join(releaseWindows, 'jnote-api.exe'));
|
||||
|
||||
// 生成 Windows 原生启动脚本 start.bat
|
||||
const batScript = `@echo off
|
||||
:: JNote 后端服务启动脚本
|
||||
cd /d "%~dp0"
|
||||
jnote-api.exe
|
||||
pause
|
||||
`;
|
||||
const batPath = path.join(releaseWindows, 'start.bat');
|
||||
// ✅ 修复:Node16 兼容 UTF-8 编码
|
||||
fs.writeFileSync(batPath, batScript, 'utf8');
|
||||
log('✓ windows/jnote-api.exe + start.bat (双击运行)');
|
||||
}
|
||||
// ====================================================
|
||||
|
||||
// 复制环境配置
|
||||
const envSrc = path.join(serverDir, '.env');
|
||||
const defaultEnv = `# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://localhost:9001
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=3000
|
||||
`;
|
||||
|
||||
if (fs.existsSync(envSrc)) {
|
||||
fs.copyFileSync(envSrc, path.join(releaseLinux, '.env'));
|
||||
fs.copyFileSync(envSrc, path.join(releaseLinux, '.env.example'));
|
||||
fs.copyFileSync(envSrc, path.join(releaseWindows, '.env'));
|
||||
fs.copyFileSync(envSrc, path.join(releaseWindows, '.env.example'));
|
||||
} else {
|
||||
fs.writeFileSync(path.join(releaseLinux, '.env'), defaultEnv, 'utf8');
|
||||
fs.writeFileSync(path.join(releaseWindows, '.env'), defaultEnv, 'utf8');
|
||||
}
|
||||
log('✓ .env 配置文件生成完成');
|
||||
|
||||
// 创建 README
|
||||
const readme = `JNote 后端服务部署说明
|
||||
=====================
|
||||
|
||||
目录结构:
|
||||
├── linux/ # Linux 服务器部署包
|
||||
│ ├── jnote-api # Linux 可执行二进制文件
|
||||
│ ├── start.sh # Linux 原生启动脚本
|
||||
│ └── .env # 环境配置
|
||||
│
|
||||
└── windows/ # Windows 服务器部署包
|
||||
├── jnote-api.exe # Windows 可执行程序
|
||||
├── start.bat # Windows 原生启动脚本
|
||||
└── .env # 环境配置
|
||||
|
||||
部署步骤:
|
||||
|
||||
【Linux 服务器】
|
||||
1. 上传 linux/ 目录到服务器
|
||||
2. 修改 .env 中的数据库配置
|
||||
3. 直接运行: ./start.sh
|
||||
|
||||
【Windows 服务器】
|
||||
1. 上传 windows/ 目录到服务器
|
||||
2. 修改 .env 中的数据库配置
|
||||
3. 双击运行 start.bat 即可
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(releaseDir, 'README.txt'), readme, 'utf8');
|
||||
log('✓ README.txt 生成完成');
|
||||
|
||||
// 清理临时文件
|
||||
console.log('');
|
||||
log('🧹 清理临时文件...');
|
||||
try { fs.unlinkSync(linuxExe); } catch {}
|
||||
try { fs.unlinkSync(winExe); } catch {}
|
||||
try { fs.rmSync(distDir, { recursive: true, force: true }); } catch {}
|
||||
|
||||
console.log('');
|
||||
console.log('=========================================');
|
||||
console.log(' 打包完成!');
|
||||
console.log('=========================================');
|
||||
console.log(`分发包目录: ${releaseDir}`);
|
||||
}
|
||||
|
||||
main();
|
||||
24
server/package.json
Normal file
24
server/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "jnote-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Jnote-ui backend API server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"build": "node build.js",
|
||||
"package": "node package.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1064.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1064.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"mysql2": "^3.22.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2",
|
||||
"pkg": "5.8.1"
|
||||
}
|
||||
}
|
||||
25
server/release/README.txt
Normal file
25
server/release/README.txt
Normal file
@@ -0,0 +1,25 @@
|
||||
JNote 后端服务部署说明
|
||||
=====================
|
||||
|
||||
目录结构:
|
||||
├── linux/ # Linux 服务器部署包
|
||||
│ ├── jnote-api # Linux 可执行二进制文件
|
||||
│ ├── start.sh # Linux 原生启动脚本
|
||||
│ └── .env # 环境配置
|
||||
│
|
||||
└── windows/ # Windows 服务器部署包
|
||||
├── jnote-api.exe # Windows 可执行程序
|
||||
├── start.bat # Windows 原生启动脚本
|
||||
└── .env # 环境配置
|
||||
|
||||
部署步骤:
|
||||
|
||||
【Linux 服务器】
|
||||
1. 上传 linux/ 目录到服务器
|
||||
2. 修改 .env 中的数据库配置
|
||||
3. 直接运行: ./start.sh
|
||||
|
||||
【Windows 服务器】
|
||||
1. 上传 windows/ 目录到服务器
|
||||
2. 修改 .env 中的数据库配置
|
||||
3. 双击运行 start.bat 即可
|
||||
17
server/release/linux/.env
Normal file
17
server/release/linux/.env
Normal file
@@ -0,0 +1,17 @@
|
||||
# 数据库配置
|
||||
DB_HOST=43.156.91.115
|
||||
DB_PORT=53306
|
||||
DB_USER=root
|
||||
# DB_PASSWORD=mysql_sX4mrh
|
||||
DB_PASSWORD=mariadb_hJbME5
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://43.156.91.115:9001
|
||||
RUSTFS_REGION=us-east-1
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=4501
|
||||
17
server/release/linux/.env.example
Normal file
17
server/release/linux/.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# 数据库配置
|
||||
DB_HOST=43.156.91.115
|
||||
DB_PORT=53306
|
||||
DB_USER=root
|
||||
# DB_PASSWORD=mysql_sX4mrh
|
||||
DB_PASSWORD=mariadb_hJbME5
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://43.156.91.115:9001
|
||||
RUSTFS_REGION=us-east-1
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=4501
|
||||
BIN
server/release/linux/jnote-api
Normal file
BIN
server/release/linux/jnote-api
Normal file
Binary file not shown.
4
server/release/linux/start.sh
Normal file
4
server/release/linux/start.sh
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# JNote 后端服务启动脚本
|
||||
cd "$(dirname "$0")"
|
||||
./jnote-api
|
||||
17
server/release/windows/.env
Normal file
17
server/release/windows/.env
Normal file
@@ -0,0 +1,17 @@
|
||||
# 数据库配置
|
||||
DB_HOST=43.156.91.115
|
||||
DB_PORT=53306
|
||||
DB_USER=root
|
||||
# DB_PASSWORD=mysql_sX4mrh
|
||||
DB_PASSWORD=mariadb_hJbME5
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://43.156.91.115:9001
|
||||
RUSTFS_REGION=us-east-1
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=4501
|
||||
17
server/release/windows/.env.example
Normal file
17
server/release/windows/.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# 数据库配置
|
||||
DB_HOST=43.156.91.115
|
||||
DB_PORT=53306
|
||||
DB_USER=root
|
||||
# DB_PASSWORD=mysql_sX4mrh
|
||||
DB_PASSWORD=mariadb_hJbME5
|
||||
DB_NAME=jnote
|
||||
|
||||
# RUSTFS 对象存储配置
|
||||
RUSTFS_ENDPOINT=http://43.156.91.115:9001
|
||||
RUSTFS_REGION=us-east-1
|
||||
RUSTFS_BUCKET=setting
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin
|
||||
RUSTFS_SECRET_KEY=rustfsadmin
|
||||
|
||||
# 服务器配置
|
||||
PORT=4501
|
||||
BIN
server/release/windows/jnote-api.exe
Normal file
BIN
server/release/windows/jnote-api.exe
Normal file
Binary file not shown.
5
server/release/windows/start.bat
Normal file
5
server/release/windows/start.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
:: JNote 后端服务启动脚本
|
||||
cd /d "%~dp0"
|
||||
jnote-api.exe
|
||||
pause
|
||||
166
server/src/config/cronScheduler.js
Normal file
166
server/src/config/cronScheduler.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 定时任务调度器
|
||||
* 后端运行,周期性检查并执行到期的任务
|
||||
*/
|
||||
const { getPool } = require('./database');
|
||||
const memoryStore = require('./memoryStore');
|
||||
|
||||
let schedulerInterval = null;
|
||||
let isRunning = false;
|
||||
|
||||
function parseHeaders(headers) {
|
||||
if (!headers) return {};
|
||||
if (typeof headers === 'object') return headers;
|
||||
try {
|
||||
return JSON.parse(headers);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function calculateNextRun(cron) {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length < 5) return null;
|
||||
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
|
||||
const [minute, hour, day, month, weekDay] = parts;
|
||||
|
||||
// 设置分钟
|
||||
if (minute === '*') {
|
||||
next.setMinutes(now.getMinutes() + 1, 0, 0);
|
||||
} else if (minute.includes('/')) {
|
||||
const step = parseInt(minute.split('/')[1]);
|
||||
next.setMinutes(Math.ceil(now.getMinutes() / step) * step, 0, 0);
|
||||
} else {
|
||||
next.setMinutes(parseInt(minute), 0, 0);
|
||||
if (next <= now) {
|
||||
next.setHours(next.getHours() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置小时
|
||||
if (hour !== '*') {
|
||||
if (hour.includes('/')) {
|
||||
const step = parseInt(hour.split('/')[1]);
|
||||
next.setHours(Math.ceil(now.getHours() / step) * step, 0, 0, 0);
|
||||
} else {
|
||||
next.setHours(parseInt(hour), 0, 0, 0);
|
||||
}
|
||||
if (next <= now) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
async function executeTask(task) {
|
||||
console.log(`[CronScheduler] 执行任务: ${task.name} (${task.id})`);
|
||||
|
||||
const options = {
|
||||
method: task.method || 'GET',
|
||||
headers: parseHeaders(task.headers)
|
||||
};
|
||||
|
||||
if (task.body && ['POST', 'PUT', 'PATCH'].includes(task.method)) {
|
||||
options.body = task.body;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(task.url, options);
|
||||
const now = new Date();
|
||||
const nextRun = calculateNextRun(task.cron);
|
||||
|
||||
console.log(`[CronScheduler] 任务 ${task.name} 执行完成: ${response.status}`);
|
||||
|
||||
// 更新数据库
|
||||
const pool = getPool();
|
||||
if (pool) {
|
||||
await pool.execute(
|
||||
'UPDATE cron_tasks SET last_run = ?, next_run = ?, run_count = run_count + 1 WHERE id = ?',
|
||||
[now, nextRun, task.id]
|
||||
);
|
||||
} else {
|
||||
// 更新内存存储
|
||||
const idx = memoryStore.cronTasks.findIndex(t => t.id === task.id);
|
||||
if (idx !== -1) {
|
||||
memoryStore.cronTasks[idx].lastRun = now.toLocaleString('zh-CN');
|
||||
memoryStore.cronTasks[idx].nextRun = nextRun;
|
||||
memoryStore.cronTasks[idx].runCount = (memoryStore.cronTasks[idx].runCount || 0) + 1;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[CronScheduler] 任务 ${task.name} 执行失败:`, error.message);
|
||||
|
||||
const now = new Date();
|
||||
const nextRun = calculateNextRun(task.cron);
|
||||
|
||||
const pool = getPool();
|
||||
if (pool) {
|
||||
await pool.execute(
|
||||
'UPDATE cron_tasks SET last_run = ?, next_run = ?, run_count = run_count + 1 WHERE id = ?',
|
||||
[now, nextRun, task.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAndRunTasks() {
|
||||
if (isRunning) return;
|
||||
isRunning = true;
|
||||
|
||||
try {
|
||||
const pool = getPool();
|
||||
let tasks = [];
|
||||
|
||||
if (pool) {
|
||||
const [rows] = await pool.execute('SELECT * FROM cron_tasks WHERE enabled = 1');
|
||||
tasks = rows;
|
||||
} else {
|
||||
tasks = memoryStore.cronTasks.filter(t => t.enabled);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.next_run && !task.nextRun) continue;
|
||||
|
||||
const nextRun = new Date(task.next_run || task.nextRun);
|
||||
if (now >= nextRun) {
|
||||
executeTask(task);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CronScheduler] 检查任务失败:', error.message);
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (schedulerInterval) {
|
||||
console.log('[CronScheduler] 已启动');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[CronScheduler] 启动定时任务调度器 (每10秒检查一次)');
|
||||
schedulerInterval = setInterval(checkAndRunTasks, 10000);
|
||||
|
||||
// 立即执行一次检查
|
||||
checkAndRunTasks();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (schedulerInterval) {
|
||||
clearInterval(schedulerInterval);
|
||||
schedulerInterval = null;
|
||||
console.log('[CronScheduler] 已停止');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
start,
|
||||
stop
|
||||
};
|
||||
207
server/src/config/database.js
Normal file
207
server/src/config/database.js
Normal file
@@ -0,0 +1,207 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const { DB_NAME, TABLE_DEFINITIONS, DEFAULT_DATA } = require('./schema');
|
||||
const memoryStore = require('./memoryStore');
|
||||
|
||||
let pool = null;
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
enableKeepAlive: true,
|
||||
keepAliveInitialDelay: 0
|
||||
};
|
||||
|
||||
async function initDatabase() {
|
||||
const dbHost = process.env.DB_HOST;
|
||||
const dbPassword = process.env.DB_PASSWORD;
|
||||
|
||||
if (!dbHost || !dbPassword) {
|
||||
console.log('⚠️ 数据库未配置,部分功能将使用内存存储');
|
||||
console.log(' DB_HOST:', dbHost || '未设置');
|
||||
console.log(' DB_PASSWORD:', dbPassword ? '已设置' : '未设置');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: 连接数据库服务器(不指定数据库)
|
||||
const initConfig = { ...config };
|
||||
delete initConfig.database;
|
||||
const initConnection = await mysql.createConnection(initConfig);
|
||||
console.log('🔌 已连接数据库服务器');
|
||||
|
||||
// Step 2: 创建数据库(如果不存在)
|
||||
await initConnection.query(
|
||||
`CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`
|
||||
);
|
||||
console.log(`📦 数据库 ${DB_NAME} ${await databaseExists(initConnection) ? '已存在' : '已创建'}`);
|
||||
|
||||
// Step 3: 选择数据库
|
||||
await initConnection.query(`USE \`${DB_NAME}\``);
|
||||
|
||||
// Step 4: 创建所有表
|
||||
for (const table of TABLE_DEFINITIONS) {
|
||||
await initConnection.query(table.sql);
|
||||
console.log(`📋 ${table.name} 表 ${await tableExists(initConnection, table.name) ? '已存在' : '已创建'}`);
|
||||
}
|
||||
|
||||
// Step 5: 初始化默认数据
|
||||
await ensureDefaultData(initConnection);
|
||||
|
||||
await initConnection.end();
|
||||
|
||||
// Step 6: 创建连接池
|
||||
const poolConfig = { ...config, database: DB_NAME };
|
||||
pool = mysql.createPool(poolConfig);
|
||||
|
||||
// 测试连接池
|
||||
const connection = await pool.getConnection();
|
||||
console.log('✅ 数据库连接池创建成功');
|
||||
connection.release();
|
||||
|
||||
// 预热缓存(不阻塞启动)
|
||||
warmUpCache().catch(err => console.error('预热失败:', err.message));
|
||||
|
||||
return pool;
|
||||
} catch (error) {
|
||||
console.error('❌ 数据库初始化失败:', error.message);
|
||||
console.log('⚠️ 将使用内存存储作为后备');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查数据库是否存在
|
||||
async function databaseExists(connection) {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ?`,
|
||||
[DB_NAME]
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// 检查表是否存在
|
||||
async function tableExists(connection, tableName) {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
|
||||
[DB_NAME, tableName]
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// 确保默认数据存在
|
||||
async function ensureDefaultData(connection) {
|
||||
try {
|
||||
// 检查 settings 是否存在
|
||||
const [settingsRows] = await connection.query('SELECT id FROM settings WHERE id = 1');
|
||||
if (settingsRows.length === 0) {
|
||||
await connection.query(
|
||||
'INSERT INTO settings (id, bg_type, bg_color, bg_opacity, language) VALUES (1, ?, ?, ?, ?)',
|
||||
[DEFAULT_DATA.settings.bg_type, DEFAULT_DATA.settings.bg_color, DEFAULT_DATA.settings.bg_opacity, DEFAULT_DATA.settings.language]
|
||||
);
|
||||
console.log(' settings 默认数据已创建');
|
||||
}
|
||||
|
||||
// 检查 about 是否存在
|
||||
const [aboutRows] = await connection.query('SELECT id FROM about WHERE id = 1');
|
||||
if (aboutRows.length === 0) {
|
||||
await connection.query(
|
||||
'INSERT INTO about (id, title, intro, blog, tech, features, contact, filing_number) VALUES (1, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
DEFAULT_DATA.about.title,
|
||||
DEFAULT_DATA.about.intro,
|
||||
DEFAULT_DATA.about.blog,
|
||||
DEFAULT_DATA.about.tech,
|
||||
DEFAULT_DATA.about.features,
|
||||
DEFAULT_DATA.about.contact,
|
||||
DEFAULT_DATA.about.filing_number
|
||||
]
|
||||
);
|
||||
console.log(' about 默认数据已创建');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ER_DUP_ENTRY') {
|
||||
console.error(' 默认数据初始化失败:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 数据预热 - 启动时加载常用数据到内存
|
||||
async function warmUpCache() {
|
||||
if (!pool) return;
|
||||
|
||||
try {
|
||||
const [posts] = await pool.execute('SELECT * FROM posts ORDER BY date DESC');
|
||||
const [settings] = await pool.execute('SELECT * FROM settings WHERE id = 1');
|
||||
const [about] = await pool.execute('SELECT * FROM about WHERE id = 1');
|
||||
|
||||
memoryStore.posts = posts;
|
||||
if (settings.length > 0) {
|
||||
memoryStore.settings = formatSettings(settings[0]);
|
||||
}
|
||||
if (about.length > 0) {
|
||||
memoryStore.about = formatAbout(about[0]);
|
||||
}
|
||||
|
||||
console.log('✅ 数据预热完成');
|
||||
} catch (error) {
|
||||
console.error('数据预热失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSettings(row) {
|
||||
if (!row) return memoryStore.settings;
|
||||
|
||||
return {
|
||||
bgType: row.bg_type,
|
||||
bgColor: row.bg_color,
|
||||
bgImage: row.bg_image,
|
||||
bgOpacity: row.bg_opacity,
|
||||
language: row.language,
|
||||
favicon: row.favicon,
|
||||
uploadedImages: parseJSON(row.uploaded_images, []),
|
||||
uploadedIcons: parseJSON(row.uploaded_icons, [])
|
||||
};
|
||||
}
|
||||
|
||||
function formatAbout(row) {
|
||||
if (!row) return memoryStore.about;
|
||||
const about = { ...row };
|
||||
if (about.tech && typeof about.tech === 'string') {
|
||||
about.techList = about.tech.split(',').map(t => t.trim());
|
||||
}
|
||||
if (about.features && typeof about.features === 'string') {
|
||||
try {
|
||||
about.featuresList = JSON.parse(about.features);
|
||||
} catch {
|
||||
about.featuresList = [];
|
||||
}
|
||||
}
|
||||
if (about.contact && typeof about.contact === 'string') {
|
||||
try {
|
||||
about.contactObj = JSON.parse(about.contact);
|
||||
} catch {
|
||||
about.contactObj = {};
|
||||
}
|
||||
}
|
||||
return about;
|
||||
}
|
||||
|
||||
function parseJSON(str, defaultValue) {
|
||||
if (!str) return defaultValue;
|
||||
if (typeof str === 'object') return str;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function getPool() {
|
||||
return pool;
|
||||
}
|
||||
|
||||
module.exports = { initDatabase, getPool };
|
||||
110
server/src/config/envManager.js
Normal file
110
server/src/config/envManager.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* .env 文件管理器
|
||||
* 读取和写入环境配置
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ENV_PATH = path.join(__dirname, '../../.env');
|
||||
|
||||
// 配置字段定义
|
||||
const CONFIG_FIELDS = {
|
||||
db: ['DB_HOST', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'],
|
||||
rustfs: ['RUSTFS_ENDPOINT', 'RUSTFS_REGION', 'RUSTFS_BUCKET', 'RUSTFS_ACCESS_KEY', 'RUSTFS_SECRET_KEY'],
|
||||
server: ['PORT']
|
||||
};
|
||||
|
||||
// 读取 .env 文件
|
||||
function readEnv() {
|
||||
try {
|
||||
if (!fs.existsSync(ENV_PATH)) {
|
||||
return {};
|
||||
}
|
||||
const content = fs.readFileSync(ENV_PATH, 'utf-8');
|
||||
const result = {};
|
||||
|
||||
content.split('\n').forEach(line => {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith('#')) return;
|
||||
|
||||
const idx = line.indexOf('=');
|
||||
if (idx > 0) {
|
||||
const key = line.substring(0, idx).trim();
|
||||
const value = line.substring(idx + 1).trim();
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('读取 .env 失败:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// 写入 .env 文件
|
||||
function writeEnv(config) {
|
||||
try {
|
||||
const lines = [];
|
||||
|
||||
// 数据库配置
|
||||
lines.push('# 数据库配置');
|
||||
lines.push(`DB_HOST=${config.DB_HOST || 'localhost'}`);
|
||||
lines.push(`DB_PORT=${config.DB_PORT || 3306}`);
|
||||
lines.push(`DB_USER=${config.DB_USER || 'root'}`);
|
||||
lines.push(`DB_PASSWORD=${config.DB_PASSWORD || ''}`);
|
||||
lines.push(`DB_NAME=${config.DB_NAME || 'jnote'}`);
|
||||
lines.push('');
|
||||
|
||||
// RUSTFS 配置
|
||||
lines.push('# RUSTFS 对象存储配置');
|
||||
lines.push(`RUSTFS_ENDPOINT=${config.RUSTFS_ENDPOINT || 'http://43.156.91.115:9001'}`);
|
||||
lines.push(`RUSTFS_REGION=${config.RUSTFS_REGION || 'us-east-1'}`);
|
||||
lines.push(`RUSTFS_BUCKET=${config.RUSTFS_BUCKET || 'setting'}`);
|
||||
lines.push(`RUSTFS_ACCESS_KEY=${config.RUSTFS_ACCESS_KEY || 'rustfsadmin'}`);
|
||||
lines.push(`RUSTFS_SECRET_KEY=${config.RUSTFS_SECRET_KEY || 'rustfsadmin'}`);
|
||||
lines.push('');
|
||||
|
||||
// 服务器配置
|
||||
lines.push('# 服务器配置');
|
||||
lines.push(`PORT=${config.PORT || 3000}`);
|
||||
|
||||
fs.writeFileSync(ENV_PATH, lines.join('\n'), 'utf-8');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('写入 .env 失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取安全配置(密码隐藏)
|
||||
function getSafeConfig() {
|
||||
const config = readEnv();
|
||||
|
||||
return {
|
||||
db: {
|
||||
host: config.DB_HOST || 'localhost',
|
||||
port: config.DB_PORT || '3306',
|
||||
user: config.DB_USER || 'root',
|
||||
password: config.DB_PASSWORD ? '******' : '',
|
||||
name: config.DB_NAME || 'jnote'
|
||||
},
|
||||
rustfs: {
|
||||
endpoint: config.RUSTFS_ENDPOINT || 'http://43.156.91.115:9001',
|
||||
region: config.RUSTFS_REGION || 'us-east-1',
|
||||
bucket: config.RUSTFS_BUCKET || 'setting',
|
||||
accessKey: config.RUSTFS_ACCESS_KEY || 'rustfsadmin',
|
||||
secretKey: config.RUSTFS_SECRET_KEY ? '******' : ''
|
||||
},
|
||||
server: {
|
||||
port: config.PORT || '3000'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readEnv,
|
||||
writeEnv,
|
||||
getSafeConfig,
|
||||
CONFIG_FIELDS
|
||||
};
|
||||
50
server/src/config/memoryStore.js
Normal file
50
server/src/config/memoryStore.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// 内存存储后备方案(数据库未配置时使用)
|
||||
const memoryStore = {
|
||||
posts: [
|
||||
{ id: 1, title: '欢迎来到我的编程学习记录', date: '2026-06-01', excerpt: '这是我的第一篇文章,欢迎大家来访!', content: '这是我的第一篇文章,欢迎大家来访!在这里我会分享一些技术心得和生活感悟。希望大家喜欢这里的内容,有任何问题欢迎留言交流。' },
|
||||
{ id: 2, title: 'Vue 3 组合式 API 入门', date: '2026-06-02', excerpt: '探索 Vue 3 的组合式 API,感受更好的代码组织方式。', content: 'Vue 3 引入了组合式 API,这是一种全新的代码组织方式。通过 setup 函数和响应式 API,我们可以更灵活地组织组件逻辑,提高代码的可复用性和可维护性。\n\n主要特点:\n1. 更好的逻辑复用\n2. 更灵活的代码组织\n3. 更好的 TypeScript 支持' },
|
||||
{ id: 3, title: '静态网站生成器选型', date: '2026-06-03', excerpt: '对比常见的静态网站生成器,帮助你选择合适的工具。', content: '选择一个合适的静态网站生成器需要考虑多个因素:\n\n1. Hexo - 简单易用,主题丰富\n2. Jekyll - GitHub Pages 原生支持,社区活跃\n3. Hugo - 构建速度极快\n4. VuePress / VitePress - 基于 Vue,适合技术文档\n\n本博客使用 Vue 3 + Vite 构建,兼具现代特性和开发体验。' },
|
||||
{ id: 4, title: '优雅地使用 Pinia 管理状态', date: '2026-06-04', excerpt: 'Pinia 是 Vue 3 推荐的状态管理方案,本篇介绍其基本用法。', content: 'Pinia 是 Vue 官方推荐的新一代状态管理库,相比 Vuex 更加轻量和直观。\n\n核心概念:\n- Store - 存储状态的地方\n- Getters - 类似 computed 的状态计算\n- Actions - 处理异步逻辑或修改状态' },
|
||||
{ id: 5, title: 'CSS Grid 布局实战技巧', date: '2026-06-05', excerpt: '深入探索 CSS Grid,让页面布局变得轻而易举。', content: 'CSS Grid 是现代 CSS 布局的重要组成部分,比 Flexbox 更适合二维布局场景。\n\n基础用法:\n.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }\n\n常用场景:\n1. 相册网格 - 自动填充列数\n2. 圣杯布局 - 经典三栏布局\n3. 响应式卡片 - 自动换行适配' },
|
||||
{ id: 6, title: '用 Git 工作流提升团队协作效率', date: '2026-06-06', excerpt: 'GitFlow vs trunk-based development,选对工作流事半功倍。', content: '良好的 Git 工作流能让团队协作更加顺畅。常见的工作流模型:\n\n1. GitFlow - 分支模型清晰,适合发布周期稳定的项目\n2. trunk-based development - 主干开发,快速迭代\n3. fork workflow - 开源项目首选,隔离性强\n\n小团队推荐使用简化 GitFlow:main + develop + feature 分支。' },
|
||||
{ id: 7, title: 'JavaScript 事件循环机制详解', date: '2026-06-07', excerpt: '理解 Event Loop、宏任务与微任务,告别异步编程困惑。', content: '事件循环是 JavaScript 异步编程的核心机制。\n\n执行顺序:\n1. 同步代码优先执行\n2. 微任务(Promise、MutationObserver)\n3. 宏任务(setTimeout、setInterval、setImmediate)\n\n示例:\nconsole.log(\'1\');\nsetTimeout(() => console.log(\'2\'), 0);\nPromise.resolve().then(() => console.log(\'3\'));\nconsole.log(\'4\');\n\n输出顺序:1, 4, 3, 2' },
|
||||
{ id: 8, title: '我的开发环境配置分享', date: '2026-06-08', excerpt: 'VS Code + Zsh + Tmux,打造高效开发终端。', content: '工欲善其事,必先利其器。分享我的开发环境配置:\n\n编辑器:\n- VS Code + Vim 插件\n- Tokyo Night 主题\n- Fira Code 字体 + 连字\n\n终端:\n- iTerm2 (macOS) / Windows Terminal\n- Oh My Zsh\n- Tmux 会话管理\n\n版本管理:\n- Git + delta 美化 diff\n- lazygit 终端 UI' },
|
||||
{ id: 9, title: '从零搭建一个 CLI 工具', date: '2026-06-09', excerpt: '使用 Node.js 和 commander.js 快速构建命令行工具。', content: '命令行工具是提升开发效率的利器。使用 Node.js 可以快速构建:\n\n初始化项目:\nnpm init -y\nnpm install commander inquirer\n\n核心代码:\nconst { program } = require(\'commander\');\nprogram.version(\'1.0.0\');\nprogram.option(\'-n, --name <name>\', \'项目名称\');\nprogram.parse(process.argv);\n\n发布到 npm:\nnpm publish --access public' },
|
||||
{ id: 10, title: '为什么我喜欢深夜编程', date: '2026-06-10', excerpt: '安静、专注、不被打扰,深夜是程序员的黄金时段。', content: '深夜编程的独特魅力:\n\n1. 绝对安静 - 无人打扰,思路连贯\n2. 效率翻倍 - 深度工作状态\n3. 问题解决 - 复杂 bug 往往在这时候被攻破\n\n当然,也要适度。注意休息,保护眼睛,第二天才能持续输出。\n\n推荐配合:黑咖啡 + 轻音乐 + 舒适的降噪耳机。' }
|
||||
],
|
||||
|
||||
about: {
|
||||
id: 1,
|
||||
title: '关于我',
|
||||
intro: '你好!我是一名热爱技术开发的开发者。',
|
||||
blog: '这个博客用于分享我在学习和工作中的一些心得体会。',
|
||||
tech: 'Vue / Vue 3, JavaScript / TypeScript, Node.js, Vite, Pinia, Vue Router',
|
||||
features: JSON.stringify([
|
||||
'定时任务系统 - 支持 Cron表达式的自动化任务管理',
|
||||
'自定义背景 - 支持纯色和图片背景,可调节透明度',
|
||||
'多语言支持 - 中英文切换',
|
||||
'自定义图标 - 上传本地图片作为网站图标'
|
||||
]),
|
||||
contact: JSON.stringify({
|
||||
email: 'example@email.com',
|
||||
github: 'github.com/yourusername'
|
||||
}),
|
||||
filing_number: '桂ICP备2022004108号-1'
|
||||
},
|
||||
|
||||
settings: {
|
||||
id: 1,
|
||||
bg_type: 'color',
|
||||
bg_color: '#f5f5f5',
|
||||
bg_image: '',
|
||||
bg_opacity: 1.00,
|
||||
language: 'zh',
|
||||
favicon: '',
|
||||
uploaded_images: JSON.stringify([]),
|
||||
uploaded_icons: JSON.stringify([])
|
||||
},
|
||||
|
||||
cronTasks: []
|
||||
};
|
||||
|
||||
module.exports = memoryStore;
|
||||
235
server/src/config/rustfs.js
Normal file
235
server/src/config/rustfs.js
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* RUSTFS 对象存储配置
|
||||
* 直接使用 HTTP API 避免 AWS SDK 兼容性问题
|
||||
*/
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const config = {
|
||||
endpoint: process.env.RUSTFS_ENDPOINT || 'http://43.156.91.115:9001',
|
||||
bucket: process.env.RUSTFS_BUCKET || 'setting',
|
||||
accessKey: process.env.RUSTFS_ACCESS_KEY || 'rustfsadmin',
|
||||
secretKey: process.env.RUSTFS_SECRET_KEY || 'rustfsadmin'
|
||||
};
|
||||
|
||||
// 生成 AWS Signature V4
|
||||
function createSignature(method, path, headers, query = '') {
|
||||
const date = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '');
|
||||
const dateShort = date.substring(0, 8);
|
||||
|
||||
const payloadHash = crypto.createHash('sha256').update('').digest('hex');
|
||||
const canonicalHeaders = Object.entries(headers)
|
||||
.map(([k, v]) => `${k.toLowerCase()}:${v}`)
|
||||
.join('\n');
|
||||
const signedHeaders = Object.keys(headers).map(k => k.toLowerCase()).join(';');
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
canonicalHeaders,
|
||||
'',
|
||||
signedHeaders,
|
||||
payloadHash
|
||||
].join('\n');
|
||||
|
||||
const credentialScope = `${dateShort}/us-east-1/s3/aws4_request`;
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
date,
|
||||
credentialScope,
|
||||
crypto.createHash('sha256').update(canonicalRequest).digest('hex')
|
||||
].join('\n');
|
||||
|
||||
const kDate = crypto.createHmac('sha256', `AWS4${config.secretKey}`).update(dateShort).digest();
|
||||
const kRegion = crypto.createHmac('sha256', kDate).update('us-east-1').digest();
|
||||
const kService = crypto.createHmac('sha256', kRegion).update('s3').digest();
|
||||
const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest();
|
||||
const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
||||
|
||||
return {
|
||||
authorization: `AWS4-HMAC-SHA256 Credential=${config.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
||||
'x-amz-date': date,
|
||||
'x-amz-content-sha256': payloadHash
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(method, path, body = null, contentType = 'application/octet-stream') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(config.endpoint);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const httpModule = isHttps ? https : http;
|
||||
|
||||
const query = '';
|
||||
const headers = {
|
||||
'host': url.host,
|
||||
'content-type': contentType,
|
||||
'x-amz-date': new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '').substring(0, 8) + 'T000000Z'
|
||||
};
|
||||
|
||||
if (body) {
|
||||
headers['content-length'] = Buffer.byteLength(body);
|
||||
}
|
||||
|
||||
// 简化认证:使用 Access Key 作为 Authorization header
|
||||
headers['Authorization'] = `AWS ${config.accessKey}:${crypto.createHash('sha256').update(config.secretKey).digest('hex')}`;
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 9001),
|
||||
path: `/${config.bucket}${path}`,
|
||||
method: method,
|
||||
headers: headers
|
||||
};
|
||||
|
||||
const req = httpModule.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(data);
|
||||
} else {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
if (body) {
|
||||
req.write(body);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到 RUSTFS
|
||||
*/
|
||||
async function uploadFile(fileData, key, contentType = 'image/jpeg') {
|
||||
// 解析 endpoint
|
||||
const endpoint = config.endpoint;
|
||||
const url = new URL(endpoint);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const httpModule = isHttps ? https : http;
|
||||
|
||||
const date = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '').substring(0, 8);
|
||||
const datetime = date + 'T000000Z';
|
||||
|
||||
// 创建签名字符串
|
||||
const credential = `${config.accessKey}/${date}/us-east-1/s3/aws4_request`;
|
||||
const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date';
|
||||
const contentHash = crypto.createHash('sha256').update(fileData).digest('hex');
|
||||
|
||||
const canonicalHeaders = [
|
||||
`content-type:${contentType}`,
|
||||
`host:${url.host}`,
|
||||
`x-amz-content-sha256:${contentHash}`,
|
||||
`x-amz-date:${datetime}`
|
||||
].join('\n');
|
||||
|
||||
const canonicalRequest = [
|
||||
'PUT',
|
||||
`/${config.bucket}/${key}`,
|
||||
'',
|
||||
canonicalHeaders,
|
||||
'',
|
||||
signedHeaders,
|
||||
contentHash
|
||||
].join('\n');
|
||||
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
datetime,
|
||||
`${date}/us-east-1/s3/aws4_request`,
|
||||
crypto.createHash('sha256').update(canonicalRequest).digest('hex')
|
||||
].join('\n');
|
||||
|
||||
// 计算签名
|
||||
const k1 = crypto.createHmac('sha256', 'AWS4' + config.secretKey).update(date).digest();
|
||||
const k2 = crypto.createHmac('sha256', k1).update('us-east-1').digest();
|
||||
const k3 = crypto.createHmac('sha256', k2).update('s3').digest();
|
||||
const k4 = crypto.createHmac('sha256', k3).update('aws4_request').digest();
|
||||
const signature = crypto.createHmac('sha256', k4).update(stringToSign).digest('hex');
|
||||
|
||||
const authorization = `AWS4-HMAC-SHA256 Credential=${credential}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 9001),
|
||||
path: `/${config.bucket}/${key}`,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Host': url.host,
|
||||
'X-Amz-Content-Sha256': contentHash,
|
||||
'X-Amz-Date': datetime,
|
||||
'Authorization': authorization,
|
||||
'Content-Length': Buffer.byteLength(fileData)
|
||||
}
|
||||
};
|
||||
|
||||
const req = httpModule.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(`${endpoint}/${config.bucket}/${key}`);
|
||||
} else {
|
||||
reject(new Error(`Upload failed: HTTP ${res.statusCode} - ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(fileData);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 RUSTFS 中的文件
|
||||
*/
|
||||
async function deleteFile(key) {
|
||||
const endpoint = config.endpoint;
|
||||
const url = new URL(endpoint);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const httpModule = isHttps ? https : http;
|
||||
|
||||
const datetime = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '').substring(0, 8) + 'T000000Z';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 9001),
|
||||
path: `/${config.bucket}/${key}`,
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Host': url.host,
|
||||
'X-Amz-Date': datetime,
|
||||
'Authorization': `AWS ${config.accessKey}:${crypto.createHash('sha256').update(config.secretKey).digest('hex')}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = httpModule.request(options, (res) => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => reject(new Error(`Delete failed: HTTP ${res.statusCode}`)));
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
config
|
||||
};
|
||||
108
server/src/config/schema.js
Normal file
108
server/src/config/schema.js
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 数据库表结构定义
|
||||
* 包含所有表的 CREATE TABLE 语句
|
||||
*/
|
||||
|
||||
const DB_NAME = process.env.DB_NAME || 'jnote';
|
||||
|
||||
// posts 表 - content 改为 MEDIUMTEXT 支持更大内容
|
||||
const CREATE_POSTS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
excerpt TEXT,
|
||||
content MEDIUMTEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_posts_date (date DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`;
|
||||
|
||||
// about 表 - features 和 contact 改为 JSON 类型
|
||||
const CREATE_ABOUT_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS about (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
title VARCHAR(255) DEFAULT '关于我',
|
||||
intro TEXT,
|
||||
blog TEXT,
|
||||
tech TEXT,
|
||||
features JSON,
|
||||
contact JSON,
|
||||
filing_number VARCHAR(100),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`;
|
||||
|
||||
// settings 表 - uploaded_images/icons 改为 JSON 类型,bg_image/favicon 改为更节省空间的 TEXT
|
||||
const CREATE_SETTINGS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
bg_type VARCHAR(20) DEFAULT 'color',
|
||||
bg_color VARCHAR(20) DEFAULT '#f5f5f5',
|
||||
bg_image TEXT,
|
||||
bg_opacity DECIMAL(3,2) DEFAULT 1.00,
|
||||
language VARCHAR(10) DEFAULT 'zh',
|
||||
favicon TEXT,
|
||||
uploaded_images JSON,
|
||||
uploaded_icons JSON,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`;
|
||||
|
||||
// cron_tasks 表
|
||||
const CREATE_CRON_TASKS_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS cron_tasks (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
cron VARCHAR(50) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
method VARCHAR(20) DEFAULT 'GET',
|
||||
headers TEXT,
|
||||
body TEXT,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
last_run DATETIME,
|
||||
next_run DATETIME,
|
||||
run_count INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`;
|
||||
|
||||
// 所有表创建的执行顺序(按依赖关系)
|
||||
const TABLE_DEFINITIONS = [
|
||||
{ name: 'posts', sql: CREATE_POSTS_TABLE },
|
||||
{ name: 'about', sql: CREATE_ABOUT_TABLE },
|
||||
{ name: 'settings', sql: CREATE_SETTINGS_TABLE },
|
||||
{ name: 'cron_tasks', sql: CREATE_CRON_TASKS_TABLE }
|
||||
];
|
||||
|
||||
// 默认数据
|
||||
const DEFAULT_DATA = {
|
||||
about: {
|
||||
title: '关于我',
|
||||
intro: '你好!我是一名热爱技术开发的开发者。',
|
||||
blog: '这个博客用于分享我在学习和工作中的一些心得体会。',
|
||||
tech: 'Vue / Vue 3, JavaScript / TypeScript, Node.js, Vite, Pinia, Vue Router',
|
||||
features: JSON.stringify([
|
||||
'定时任务系统 - 支持 Cron表达式的自动化任务管理',
|
||||
'自定义背景 - 支持纯色和图片背景,可调节透明度',
|
||||
'多语言支持 - 中英文切换',
|
||||
'自定义图标 - 上传本地图片作为网站图标'
|
||||
]),
|
||||
contact: JSON.stringify({ email: 'example@email.com', github: 'github.com/yourusername' }),
|
||||
filing_number: '桂ICP备2022004108号-1'
|
||||
},
|
||||
settings: {
|
||||
bg_type: 'color',
|
||||
bg_color: '#f5f5f5',
|
||||
bg_opacity: 1.00,
|
||||
language: 'zh'
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
DB_NAME,
|
||||
TABLE_DEFINITIONS,
|
||||
DEFAULT_DATA
|
||||
};
|
||||
52
server/src/index.js
Normal file
52
server/src/index.js
Normal file
@@ -0,0 +1,52 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const { initDatabase } = require('./config/database');
|
||||
const cronScheduler = require('./config/cronScheduler');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// 中间件
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// 路由
|
||||
const postsRouter = require('./routes/posts');
|
||||
const aboutRouter = require('./routes/about');
|
||||
const settingsRouter = require('./routes/settings');
|
||||
const cronRouter = require('./routes/cron');
|
||||
const configRouter = require('./routes/config');
|
||||
|
||||
app.use('/api/posts', postsRouter);
|
||||
app.use('/api/about', aboutRouter);
|
||||
app.use('/api/settings', settingsRouter);
|
||||
app.use('/api/cron-tasks', cronRouter);
|
||||
app.use('/api/config', configRouter);
|
||||
|
||||
// 健康检查
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
async function start() {
|
||||
await initDatabase();
|
||||
|
||||
// 启动定时任务调度器
|
||||
cronScheduler.start();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 服务器运行在 http://localhost:${PORT}`);
|
||||
console.log(`📖 API 文档: http://localhost:${PORT}/api/health`);
|
||||
});
|
||||
}
|
||||
|
||||
start();
|
||||
96
server/src/routes/about.js
Normal file
96
server/src/routes/about.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getPool } = require('../config/database');
|
||||
const memoryStore = require('../config/memoryStore');
|
||||
|
||||
// 获取关于内容 - 优先使用预热缓存
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
return res.json(memoryStore.about);
|
||||
}
|
||||
|
||||
// 优先返回缓存数据
|
||||
if (memoryStore.about && memoryStore.about.id === 1) {
|
||||
return res.json(memoryStore.about);
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM about WHERE id = 1');
|
||||
if (rows.length === 0) {
|
||||
return res.json(memoryStore.about);
|
||||
}
|
||||
|
||||
const about = formatAbout(rows[0]);
|
||||
memoryStore.about = about;
|
||||
res.json(about);
|
||||
} catch (error) {
|
||||
console.error('获取关于内容失败:', error);
|
||||
res.json(memoryStore.about);
|
||||
}
|
||||
});
|
||||
|
||||
// 格式化 about 数据 - MySQL JSON 类型直接返回对象
|
||||
function formatAbout(row) {
|
||||
if (!row) return memoryStore.about;
|
||||
const about = { ...row };
|
||||
if (about.tech && typeof about.tech === 'string') {
|
||||
about.techList = about.tech.split(',').map(t => t.trim());
|
||||
}
|
||||
// MySQL JSON 类型直接返回 JS 对象,不需要 JSON.parse
|
||||
if (about.features && typeof about.features === 'string') {
|
||||
try {
|
||||
about.featuresList = JSON.parse(about.features);
|
||||
} catch {
|
||||
about.featuresList = Array.isArray(about.features) ? about.features : [];
|
||||
}
|
||||
} else if (Array.isArray(about.features)) {
|
||||
about.featuresList = about.features;
|
||||
}
|
||||
if (about.contact && typeof about.contact === 'string') {
|
||||
try {
|
||||
about.contactObj = JSON.parse(about.contact);
|
||||
} catch {
|
||||
about.contactObj = typeof about.contact === 'object' ? about.contact : {};
|
||||
}
|
||||
} else if (typeof about.contact === 'object') {
|
||||
about.contactObj = about.contact;
|
||||
}
|
||||
return about;
|
||||
}
|
||||
|
||||
// 更新关于内容
|
||||
router.put('/', async (req, res) => {
|
||||
try {
|
||||
const { title, intro, blog, tech, features, contact, filing_number } = req.body;
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
Object.assign(memoryStore.about, { title, intro, blog, tech, features, contact, filing_number });
|
||||
return res.json(memoryStore.about);
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
`UPDATE about SET title = ?, intro = ?, blog = ?, tech = ?, features = ?, contact = ?, filing_number = ? WHERE id = 1`,
|
||||
[
|
||||
title || '关于我',
|
||||
intro || '',
|
||||
tech || '',
|
||||
typeof features === 'object' ? JSON.stringify(features) : features || '[]',
|
||||
typeof contact === 'object' ? JSON.stringify(contact) : contact || '{}',
|
||||
filing_number || ''
|
||||
]
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM about WHERE id = 1');
|
||||
const updated = formatAbout(rows[0]);
|
||||
memoryStore.about = updated;
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('更新关于内容失败:', error);
|
||||
res.status(500).json({ error: '更新关于内容失败' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
73
server/src/routes/config.js
Normal file
73
server/src/routes/config.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const envManager = require('../config/envManager');
|
||||
|
||||
// 获取当前配置(密码隐藏)
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const config = envManager.getSafeConfig();
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error);
|
||||
res.status(500).json({ error: '获取配置失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新配置
|
||||
router.put('/', (req, res) => {
|
||||
try {
|
||||
const { db, rustfs, server } = req.body;
|
||||
|
||||
const config = {
|
||||
// 数据库配置
|
||||
DB_HOST: db?.host || 'localhost',
|
||||
DB_PORT: db?.port || '3306',
|
||||
DB_USER: db?.user || 'root',
|
||||
DB_PASSWORD: db?.password || '',
|
||||
DB_NAME: db?.name || 'jnote',
|
||||
// RUSTFS 配置
|
||||
RUSTFS_ENDPOINT: rustfs?.endpoint || 'http://43.156.91.115:9001',
|
||||
RUSTFS_REGION: rustfs?.region || 'us-east-1',
|
||||
RUSTFS_BUCKET: rustfs?.bucket || 'setting',
|
||||
RUSTFS_ACCESS_KEY: rustfs?.accessKey || 'rustfsadmin',
|
||||
RUSTFS_SECRET_KEY: rustfs?.secretKey || 'rustfsadmin',
|
||||
// 服务器配置
|
||||
PORT: server?.port || '3000'
|
||||
};
|
||||
|
||||
// 如果密码字段为 ******,说明没改,从原文件读取
|
||||
if (db?.password === '******') {
|
||||
const oldConfig = envManager.readEnv();
|
||||
config.DB_PASSWORD = oldConfig.DB_PASSWORD || '';
|
||||
}
|
||||
if (rustfs?.secretKey === '******') {
|
||||
const oldConfig = envManager.readEnv();
|
||||
config.RUSTFS_SECRET_KEY = oldConfig.RUSTFS_SECRET_KEY || '';
|
||||
}
|
||||
|
||||
const success = envManager.writeEnv(config);
|
||||
if (!success) {
|
||||
return res.status(500).json({ error: '保存配置失败' });
|
||||
}
|
||||
|
||||
res.json(envManager.getSafeConfig());
|
||||
} catch (error) {
|
||||
console.error('更新配置失败:', error);
|
||||
res.status(500).json({ error: '更新配置失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 重启服务
|
||||
router.post('/restart', (req, res) => {
|
||||
console.log('收到重启信号,准备关闭服务...');
|
||||
|
||||
// 优雅退出,让 PM2 或其他进程管理器自动重启
|
||||
res.json({ message: '服务即将重启', delay: 1000 });
|
||||
|
||||
// 延迟一点再退出,让响应先发出去
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
249
server/src/routes/cron.js
Normal file
249
server/src/routes/cron.js
Normal file
@@ -0,0 +1,249 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getPool } = require('../config/database');
|
||||
const memoryStore = require('../config/memoryStore');
|
||||
|
||||
// 获取所有定时任务
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
return res.json(memoryStore.cronTasks);
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM cron_tasks ORDER BY created_at DESC');
|
||||
res.json(rows.map(formatCronTask));
|
||||
} catch (error) {
|
||||
console.error('获取定时任务失败:', error);
|
||||
res.json(memoryStore.cronTasks);
|
||||
}
|
||||
});
|
||||
|
||||
// 创建定时任务
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, cron, url, method, headers, body, enabled } = req.body;
|
||||
|
||||
if (!name || !cron || !url) {
|
||||
return res.status(400).json({ error: '任务名称、Cron表达式和URL是必填项' });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const id = Date.now().toString();
|
||||
const nextRun = calculateNextRun(cron);
|
||||
|
||||
if (!pool) {
|
||||
const newTask = {
|
||||
id,
|
||||
name,
|
||||
cron,
|
||||
url,
|
||||
method: method || 'GET',
|
||||
headers: headers || '',
|
||||
body: body || '',
|
||||
enabled: enabled !== false,
|
||||
lastRun: null,
|
||||
nextRun,
|
||||
runCount: 0
|
||||
};
|
||||
memoryStore.cronTasks.push(newTask);
|
||||
return res.status(201).json(newTask);
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
`INSERT INTO cron_tasks (id, name, cron, url, method, headers, body, enabled, next_run, run_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`,
|
||||
[id, name, cron, url, method || 'GET', headers || '', body || '', enabled !== false ? 1 : 0, nextRun]
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM cron_tasks WHERE id = ?', [id]);
|
||||
res.status(201).json(formatCronTask(rows[0]));
|
||||
} catch (error) {
|
||||
console.error('创建定时任务失败:', error);
|
||||
res.status(500).json({ error: '创建定时任务失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新定时任务
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, cron, url, method, headers, body, enabled } = req.body;
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
const index = memoryStore.cronTasks.findIndex(t => t.id === req.params.id);
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
const nextRun = calculateNextRun(cron);
|
||||
memoryStore.cronTasks[index] = {
|
||||
...memoryStore.cronTasks[index],
|
||||
name, cron, url, method, headers, body, enabled, nextRun
|
||||
};
|
||||
return res.json(memoryStore.cronTasks[index]);
|
||||
}
|
||||
|
||||
const nextRun = calculateNextRun(cron);
|
||||
await pool.execute(
|
||||
`UPDATE cron_tasks SET name = ?, cron = ?, url = ?, method = ?, headers = ?, body = ?, enabled = ?, next_run = ? WHERE id = ?`,
|
||||
[name, cron, url, method || 'GET', headers || '', body || '', enabled ? 1 : 0, nextRun, req.params.id]
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM cron_tasks WHERE id = ?', [req.params.id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
res.json(formatCronTask(rows[0]));
|
||||
} catch (error) {
|
||||
console.error('更新定时任务失败:', error);
|
||||
res.status(500).json({ error: '更新定时任务失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除定时任务
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
const index = memoryStore.cronTasks.findIndex(t => t.id === req.params.id);
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
memoryStore.cronTasks.splice(index, 1);
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
const [result] = await pool.execute('DELETE FROM cron_tasks WHERE id = ?', [req.params.id]);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('删除定时任务失败:', error);
|
||||
res.status(500).json({ error: '删除定时任务失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 立即执行任务
|
||||
router.post('/:id/run', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
let task;
|
||||
if (!pool) {
|
||||
task = memoryStore.cronTasks.find(t => t.id === req.params.id);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
} else {
|
||||
const [rows] = await pool.execute('SELECT * FROM cron_tasks WHERE id = ?', [req.params.id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '任务未找到' });
|
||||
}
|
||||
task = formatCronTask(rows[0]);
|
||||
}
|
||||
|
||||
// 执行 HTTP 请求
|
||||
const options = {
|
||||
method: task.method || 'GET',
|
||||
headers: parseHeaders(task.headers)
|
||||
};
|
||||
|
||||
if (task.body && ['POST', 'PUT', 'PATCH'].includes(task.method)) {
|
||||
options.body = task.body;
|
||||
}
|
||||
|
||||
const response = await fetch(task.url, options);
|
||||
const now = new Date();
|
||||
const nextRun = calculateNextRun(task.cron);
|
||||
|
||||
if (!pool) {
|
||||
task.lastRun = now.toLocaleString('zh-CN');
|
||||
task.runCount = (task.runCount || 0) + 1;
|
||||
task.nextRun = nextRun;
|
||||
return res.json({
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
lastRun: task.lastRun,
|
||||
nextRun: task.nextRun,
|
||||
runCount: task.runCount
|
||||
});
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
'UPDATE cron_tasks SET last_run = ?, next_run = ?, run_count = run_count + 1 WHERE id = ?',
|
||||
[now, nextRun, req.params.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
lastRun: now.toLocaleString('zh-CN'),
|
||||
nextRun,
|
||||
runCount: task.runCount + 1
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('执行任务失败:', error);
|
||||
res.status(500).json({ error: '执行任务失败: ' + error.message });
|
||||
}
|
||||
});
|
||||
|
||||
function formatCronTask(row) {
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
cron: row.cron,
|
||||
url: row.url,
|
||||
method: row.method,
|
||||
headers: row.headers,
|
||||
body: row.body,
|
||||
enabled: Boolean(row.enabled),
|
||||
lastRun: row.last_run ? row.last_run.toLocaleString('zh-CN') : null,
|
||||
nextRun: row.next_run ? row.next_run.toLocaleString('zh-CN') : null,
|
||||
runCount: row.run_count || 0
|
||||
};
|
||||
}
|
||||
|
||||
function parseHeaders(headers) {
|
||||
if (!headers) return {};
|
||||
if (typeof headers === 'object') return headers;
|
||||
try {
|
||||
return JSON.parse(headers);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function calculateNextRun(cron) {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length < 5) return null;
|
||||
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
|
||||
const minute = parts[0];
|
||||
const hour = parts[1];
|
||||
|
||||
if (minute === '*') {
|
||||
next.setMinutes(now.getMinutes() + 1);
|
||||
} else if (minute.includes('/')) {
|
||||
const step = parseInt(minute.split('/')[1]);
|
||||
next.setMinutes(Math.ceil(now.getMinutes() / step) * step);
|
||||
} else {
|
||||
next.setMinutes(parseInt(minute));
|
||||
if (next <= now) next.setHours(next.getHours() + 1);
|
||||
}
|
||||
|
||||
if (hour !== '*') {
|
||||
next.setHours(parseInt(hour), 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
188
server/src/routes/posts.js
Normal file
188
server/src/routes/posts.js
Normal file
@@ -0,0 +1,188 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getPool } = require('../config/database');
|
||||
const memoryStore = require('../config/memoryStore');
|
||||
|
||||
// 格式化日期为 YYYY-MM-DD HH:mm:ss
|
||||
function formatDate(date) {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
if (isNaN(d.getTime())) return date;
|
||||
const pad = n => n.toString().padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
// 获取所有文章 - 优先使用预热缓存
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
return res.json(memoryStore.posts);
|
||||
}
|
||||
|
||||
// 优先返回缓存数据(已预热)
|
||||
if (memoryStore.posts && memoryStore.posts.length > 0) {
|
||||
return res.json(memoryStore.posts.map(p => ({ ...p, date: formatDate(p.date) })));
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM posts ORDER BY date DESC');
|
||||
const formatted = rows.map(r => ({ ...r, date: formatDate(r.date) }));
|
||||
// 更新缓存
|
||||
memoryStore.posts = rows;
|
||||
res.json(formatted);
|
||||
} catch (error) {
|
||||
console.error('获取文章列表失败:', error);
|
||||
res.json(memoryStore.posts || []);
|
||||
}
|
||||
});
|
||||
|
||||
// 获取单篇文章
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!pool) {
|
||||
const post = memoryStore.posts.find(p => p.id === id);
|
||||
return post ? res.json({ ...post, date: formatDate(post.date) }) : res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
|
||||
// 先在缓存中查找
|
||||
if (memoryStore.posts && memoryStore.posts.length > 0) {
|
||||
const cached = memoryStore.posts.find(p => p.id === id);
|
||||
if (cached) {
|
||||
return res.json({ ...cached, date: formatDate(cached.date) });
|
||||
}
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM posts WHERE id = ?', [id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
res.json({ ...rows[0], date: formatDate(rows[0].date) });
|
||||
} catch (error) {
|
||||
console.error('获取文章失败:', error);
|
||||
const post = memoryStore.posts.find(p => p.id === Number(req.params.id));
|
||||
post ? res.json({ ...post, date: formatDate(post.date) }) : res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建文章
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { title, date, excerpt, content } = req.body;
|
||||
|
||||
if (!title || !date) {
|
||||
return res.status(400).json({ error: '标题和日期是必填项' });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
const newPost = {
|
||||
id: memoryStore.posts.length + 1,
|
||||
title,
|
||||
date,
|
||||
excerpt: excerpt || '',
|
||||
content: content || ''
|
||||
};
|
||||
memoryStore.posts.unshift(newPost);
|
||||
return res.status(201).json({ ...newPost, date: formatDate(newPost.date) });
|
||||
}
|
||||
|
||||
const [result] = await pool.execute(
|
||||
'INSERT INTO posts (title, date, excerpt, content) VALUES (?, ?, ?, ?)',
|
||||
[title, date, excerpt || '', content || '']
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM posts WHERE id = ?', [result.insertId]);
|
||||
const newPost = rows[0];
|
||||
|
||||
// 同步更新缓存
|
||||
if (memoryStore.posts) {
|
||||
memoryStore.posts.unshift(newPost);
|
||||
}
|
||||
|
||||
res.status(201).json({ ...newPost, date: formatDate(newPost.date) });
|
||||
} catch (error) {
|
||||
console.error('创建文章失败:', error);
|
||||
res.status(500).json({ error: '创建文章失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新文章
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { title, date, excerpt, content } = req.body;
|
||||
const pool = getPool();
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!pool) {
|
||||
const index = memoryStore.posts.findIndex(p => p.id === id);
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
memoryStore.posts[index] = { ...memoryStore.posts[index], title, date, excerpt, content };
|
||||
return res.json({ ...memoryStore.posts[index], date: formatDate(memoryStore.posts[index].date) });
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
'UPDATE posts SET title = ?, date = ?, excerpt = ?, content = ? WHERE id = ?',
|
||||
[title, date, excerpt || '', content || '', id]
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM posts WHERE id = ?', [id]);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
const updated = rows[0];
|
||||
|
||||
// 同步更新缓存
|
||||
if (memoryStore.posts) {
|
||||
const index = memoryStore.posts.findIndex(p => p.id === id);
|
||||
if (index !== -1) {
|
||||
memoryStore.posts[index] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ...updated, date: formatDate(updated.date) });
|
||||
} catch (error) {
|
||||
console.error('更新文章失败:', error);
|
||||
res.status(500).json({ error: '更新文章失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除文章
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const id = Number(req.params.id);
|
||||
|
||||
if (!pool) {
|
||||
const index = memoryStore.posts.findIndex(p => p.id === id);
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
memoryStore.posts.splice(index, 1);
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
const [result] = await pool.execute('DELETE FROM posts WHERE id = ?', [id]);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ error: '文章未找到' });
|
||||
}
|
||||
|
||||
// 同步更新缓存
|
||||
if (memoryStore.posts) {
|
||||
memoryStore.posts = memoryStore.posts.filter(p => p.id !== id);
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('删除文章失败:', error);
|
||||
res.status(500).json({ error: '删除文章失败' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
216
server/src/routes/settings.js
Normal file
216
server/src/routes/settings.js
Normal file
@@ -0,0 +1,216 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getPool } = require('../config/database');
|
||||
const memoryStore = require('../config/memoryStore');
|
||||
const rustfs = require('../config/rustfs');
|
||||
|
||||
// 生成唯一文件名
|
||||
function generateKey(prefix, id) {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `${prefix}/${timestamp}-${random}-${id}`;
|
||||
}
|
||||
|
||||
// base64 数据解码为 Buffer
|
||||
function decodeBase64(dataUrl) {
|
||||
if (!dataUrl || !dataUrl.startsWith('data:')) {
|
||||
return null;
|
||||
}
|
||||
const matches = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) return null;
|
||||
return {
|
||||
mimeType: matches[1],
|
||||
data: Buffer.from(matches[2], 'base64')
|
||||
};
|
||||
}
|
||||
|
||||
// 获取设置 - 优先使用预热缓存
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
return res.json(memoryStore.settings);
|
||||
}
|
||||
|
||||
// 优先返回缓存数据
|
||||
if (memoryStore.settings && memoryStore.settings.bgType) {
|
||||
return res.json(memoryStore.settings);
|
||||
}
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM settings WHERE id = 1');
|
||||
if (rows.length === 0) {
|
||||
return res.json(memoryStore.settings);
|
||||
}
|
||||
|
||||
const formatted = formatSettings(rows[0]);
|
||||
memoryStore.settings = formatted;
|
||||
res.json(formatted);
|
||||
} catch (error) {
|
||||
console.error('获取设置失败:', error.message);
|
||||
res.json(memoryStore.settings);
|
||||
}
|
||||
});
|
||||
|
||||
// 上传单张图片到 RUSTFS
|
||||
router.post('/upload-image', async (req, res) => {
|
||||
try {
|
||||
const { imageData, type } = req.body; // type: 'background' | 'icon' | 'gallery'
|
||||
|
||||
if (!imageData || !imageData.startsWith('data:')) {
|
||||
return res.status(400).json({ error: '无效的图片数据' });
|
||||
}
|
||||
|
||||
const decoded = decodeBase64(imageData);
|
||||
if (!decoded) {
|
||||
return res.status(400).json({ error: '图片解码失败' });
|
||||
}
|
||||
|
||||
const id = Date.now().toString();
|
||||
const prefix = type || 'images';
|
||||
const key = generateKey(prefix, id);
|
||||
const url = await rustfs.uploadFile(decoded.data, key, decoded.mimeType);
|
||||
|
||||
console.log(`✅ 图片上传成功: ${url}`);
|
||||
|
||||
res.json({ url, id });
|
||||
} catch (error) {
|
||||
console.error('图片上传失败:', error);
|
||||
res.status(500).json({ error: '图片上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 批量上传图片(并行上传到 RUSTFS)
|
||||
router.post('/upload-images', async (req, res) => {
|
||||
try {
|
||||
const { images, type } = req.body; // images: [{id, data}]
|
||||
|
||||
if (!Array.isArray(images) || images.length === 0) {
|
||||
return res.json({ images: [] });
|
||||
}
|
||||
|
||||
const prefix = type || 'images';
|
||||
|
||||
// 并行上传所有图片
|
||||
const uploadPromises = images.map(async (img) => {
|
||||
try {
|
||||
const decoded = decodeBase64(img.data);
|
||||
if (!decoded) {
|
||||
return { id: img.id, url: null, error: '解码失败' };
|
||||
}
|
||||
|
||||
const key = generateKey(prefix, img.id);
|
||||
const url = await rustfs.uploadFile(decoded.data, key, decoded.mimeType);
|
||||
return { id: img.id, url };
|
||||
} catch (err) {
|
||||
console.error(`图片 ${img.id} 上传失败:`, err);
|
||||
return { id: img.id, url: null, error: err.message };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(uploadPromises);
|
||||
res.json({ images: results });
|
||||
} catch (error) {
|
||||
console.error('批量图片上传失败:', error);
|
||||
res.status(500).json({ error: '图片上传失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新设置
|
||||
router.put('/', async (req, res) => {
|
||||
try {
|
||||
// 兼容 camelCase 和 snake_case
|
||||
const {
|
||||
bg_type, bgType,
|
||||
bg_color, bgColor,
|
||||
bg_image, bgImage,
|
||||
bg_opacity, bgOpacity,
|
||||
language,
|
||||
favicon,
|
||||
uploaded_images, uploadedImages,
|
||||
uploaded_icons, uploadedIcons
|
||||
} = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
if (!pool) {
|
||||
Object.assign(memoryStore.settings, {
|
||||
bg_type: bg_type || bgType,
|
||||
bg_color: bg_color || bgColor,
|
||||
bg_image: bg_image || bgImage,
|
||||
bg_opacity: bg_opacity ?? bgOpacity,
|
||||
language,
|
||||
favicon,
|
||||
uploaded_images: uploaded_images || uploadedImages,
|
||||
uploaded_icons: uploaded_icons || uploadedIcons
|
||||
});
|
||||
return res.json(memoryStore.settings);
|
||||
}
|
||||
|
||||
await pool.execute(
|
||||
`UPDATE settings SET
|
||||
bg_type = ?, bg_color = ?, bg_image = ?, bg_opacity = ?,
|
||||
language = ?, favicon = ?, uploaded_images = ?, uploaded_icons = ?
|
||||
WHERE id = 1`,
|
||||
[
|
||||
bg_type || bgType || 'color',
|
||||
bg_color || bgColor || '#f5f5f5',
|
||||
bg_image || bgImage || '',
|
||||
bg_opacity ?? bgOpacity ?? 1.00,
|
||||
language || 'zh',
|
||||
favicon || '',
|
||||
prepareJSON(uploaded_images || uploadedImages),
|
||||
prepareJSON(uploaded_icons || uploadedIcons)
|
||||
]
|
||||
);
|
||||
|
||||
const [rows] = await pool.execute('SELECT * FROM settings WHERE id = 1');
|
||||
const formatted = formatSettings(rows[0]);
|
||||
memoryStore.settings = formatted;
|
||||
res.json(formatted);
|
||||
} catch (error) {
|
||||
console.error('更新设置失败:', error);
|
||||
res.status(500).json({ error: '更新设置失败' });
|
||||
}
|
||||
});
|
||||
|
||||
function formatSettings(row) {
|
||||
if (!row) return memoryStore.settings;
|
||||
|
||||
return {
|
||||
bgType: row.bg_type,
|
||||
bgColor: row.bg_color,
|
||||
bgImage: row.bg_image,
|
||||
bgOpacity: row.bg_opacity,
|
||||
language: row.language,
|
||||
favicon: row.favicon,
|
||||
// uploadedImages 和 uploadedIcons 现在存储的是 {id, url} 数组
|
||||
uploadedImages: parseJSON(row.uploaded_images, []),
|
||||
uploadedIcons: parseJSON(row.uploaded_icons, [])
|
||||
};
|
||||
}
|
||||
|
||||
function parseJSON(str, defaultValue) {
|
||||
if (!str) return defaultValue;
|
||||
if (typeof str === 'object') return str;
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareJSON(value) {
|
||||
if (!value) return '[]';
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return value;
|
||||
} catch {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
49
server/src/scripts/addIndexes.js
Normal file
49
server/src/scripts/addIndexes.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 数据库索引迁移脚本
|
||||
* 为已存在的表添加索引,提升查询性能
|
||||
* 运行方式: node src/scripts/addIndexes.js
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'jnote'
|
||||
};
|
||||
|
||||
async function migrate() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 正在连接数据库...');
|
||||
connection = await mysql.createConnection(config);
|
||||
console.log('✅ 数据库连接成功');
|
||||
|
||||
// 为 posts 表的 date 字段添加索引
|
||||
console.log('📝 正在为 posts.date 添加索引...');
|
||||
try {
|
||||
await connection.query('CREATE INDEX idx_posts_date ON posts (date DESC)');
|
||||
console.log('✅ idx_posts_date 索引创建成功');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_DUP_KEYNAME') {
|
||||
console.log('ℹ️ idx_posts_date 索引已存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🎉 索引迁移完成!');
|
||||
} catch (error) {
|
||||
console.error('❌ 索引迁移失败:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
74
server/src/scripts/checkDb.js
Normal file
74
server/src/scripts/checkDb.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 数据库诊断脚本
|
||||
* 检查数据库中的实际数据
|
||||
* 运行方式: node src/scripts/checkDb.js
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'jnote'
|
||||
};
|
||||
|
||||
async function check() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 正在连接数据库...\n');
|
||||
connection = await mysql.createConnection(config);
|
||||
console.log('✅ 数据库连接成功\n');
|
||||
|
||||
// 检查表结构
|
||||
console.log('📋 表结构检查:');
|
||||
const [tables] = await connection.query('SHOW TABLES');
|
||||
console.log('现有表:', tables.map(t => Object.values(t)[0]).join(', '));
|
||||
|
||||
// 检查 settings 表结构和数据
|
||||
console.log('\n📊 settings 表数据:');
|
||||
try {
|
||||
const [settingsRows] = await connection.query('SELECT * FROM settings WHERE id = 1');
|
||||
if (settingsRows.length === 0) {
|
||||
console.log('settings 表为空或无数据');
|
||||
} else {
|
||||
const row = settingsRows[0];
|
||||
console.log(' id:', row.id);
|
||||
console.log(' bg_type:', row.bg_type);
|
||||
console.log(' bg_color:', row.bg_color);
|
||||
console.log(' bg_image:', row.bg_image ? `[base64, 长度: ${row.bg_image.length}]` : '(空)');
|
||||
console.log(' bg_opacity:', row.bg_opacity);
|
||||
console.log(' language:', row.language);
|
||||
console.log(' favicon:', row.favicon ? `[base64, 长度: ${row.favicon.length}]` : '(空)');
|
||||
console.log(' uploaded_images:', row.uploaded_images);
|
||||
console.log(' uploaded_icons:', row.uploaded_icons);
|
||||
console.log(' uploaded_images 类型:', typeof row.uploaded_images);
|
||||
console.log(' uploaded_icons 类型:', typeof row.uploaded_icons);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('settings 表查询失败:', error.message);
|
||||
}
|
||||
|
||||
// 检查 posts 数量
|
||||
console.log('\n📝 posts 数量:');
|
||||
try {
|
||||
const [postCount] = await connection.query('SELECT COUNT(*) as count FROM posts');
|
||||
console.log('文章总数:', postCount[0].count);
|
||||
} catch (error) {
|
||||
console.log('posts 表查询失败:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n✅ 诊断完成');
|
||||
} catch (error) {
|
||||
console.error('❌ 诊断失败:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check();
|
||||
165
server/src/scripts/initDb.js
Normal file
165
server/src/scripts/initDb.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 数据库初始化脚本
|
||||
* 运行方式: node src/scripts/initDb.js
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
charset: 'utf8mb4'
|
||||
};
|
||||
|
||||
const DB_NAME = process.env.DB_NAME || 'jnote';
|
||||
|
||||
async function initDatabase() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 正在连接数据库...');
|
||||
connection = await mysql.createConnection(config);
|
||||
console.log('✅ 数据库连接成功');
|
||||
|
||||
// 创建数据库
|
||||
console.log(`📦 正在创建数据库 ${DB_NAME}...`);
|
||||
await connection.query(`CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||
console.log(`✅ 数据库 ${DB_NAME} 创建成功`);
|
||||
|
||||
// 使用数据库
|
||||
await connection.query(`USE \`${DB_NAME}\``);
|
||||
|
||||
// 创建 posts 表 - content 改为 MEDIUMTEXT
|
||||
console.log('📝 正在创建 posts 表...');
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
excerpt TEXT,
|
||||
content MEDIUMTEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_posts_date (date DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
console.log('✅ posts 表创建成功');
|
||||
|
||||
// 创建 about 表 - 使用 JSON 类型
|
||||
console.log('📋 正在创建 about 表...');
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS about (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
title VARCHAR(255) DEFAULT '关于我',
|
||||
intro TEXT,
|
||||
blog TEXT,
|
||||
tech TEXT,
|
||||
features JSON,
|
||||
contact JSON,
|
||||
filing_number VARCHAR(100),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
console.log('✅ about 表创建成功');
|
||||
|
||||
// 创建 settings 表 - 使用 JSON 类型存储图片列表
|
||||
console.log('⚙️ 正在创建 settings 表...');
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
bg_type VARCHAR(20) DEFAULT 'color',
|
||||
bg_color VARCHAR(20) DEFAULT '#f5f5f5',
|
||||
bg_image TEXT,
|
||||
bg_opacity DECIMAL(3,2) DEFAULT 1.00,
|
||||
language VARCHAR(10) DEFAULT 'zh',
|
||||
favicon TEXT,
|
||||
uploaded_images JSON,
|
||||
uploaded_icons JSON,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
console.log('✅ settings 表创建成功');
|
||||
|
||||
// 创建 cron_tasks 表
|
||||
console.log('⏰ 正在创建 cron_tasks 表...');
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS cron_tasks (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
cron VARCHAR(50) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
method VARCHAR(20) DEFAULT 'GET',
|
||||
headers TEXT,
|
||||
body TEXT,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
last_run DATETIME,
|
||||
next_run DATETIME,
|
||||
run_count INT DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
console.log('✅ cron_tasks 表创建成功');
|
||||
|
||||
// 初始化默认数据
|
||||
console.log('📄 正在初始化默认数据...');
|
||||
|
||||
// 初始化 about 默认数据
|
||||
await connection.query(`
|
||||
INSERT INTO about (id, title, intro, blog, tech, features, contact, filing_number)
|
||||
VALUES (1, '关于我', '你好!我是一名热爱技术开发的开发者。', '这个博客用于分享我在学习和工作中的一些心得体会。',
|
||||
'Vue / Vue 3, JavaScript / TypeScript, Node.js, Vite, Pinia, Vue Router',
|
||||
'["定时任务系统 - 支持 Cron表达式的自动化任务管理", "自定义背景 - 支持纯色和图片背景,可调节透明度", "多语言支持 - 中英文切换", "自定义图标 - 上传本地图片作为网站图标"]',
|
||||
'{"email": "example@email.com", "github": "github.com/yourusername"}',
|
||||
'桂ICP备2022004108号-1')
|
||||
ON DUPLICATE KEY UPDATE id=id
|
||||
`);
|
||||
console.log('✅ about 默认数据初始化成功');
|
||||
|
||||
// 初始化 settings 默认数据
|
||||
await connection.query(`
|
||||
INSERT INTO settings (id, bg_type, bg_color, bg_opacity, language)
|
||||
VALUES (1, 'color', '#f5f5f5', 1.00, 'zh')
|
||||
ON DUPLICATE KEY UPDATE id=id
|
||||
`);
|
||||
console.log('✅ settings 默认数据初始化成功');
|
||||
|
||||
// 初始化示例文章
|
||||
const samplePosts = [
|
||||
['欢迎来到我的编程学习记录', '2026-06-01', '这是我的第一篇文章,欢迎大家来访!', '这是我的第一篇文章,欢迎大家来访!在这里我会分享一些技术心得和生活感悟。希望大家喜欢这里的内容,有任何问题欢迎留言交流。'],
|
||||
['Vue 3 组合式 API 入门', '2026-06-02', '探索 Vue 3 的组合式 API,感受更好的代码组织方式。', 'Vue 3 引入了组合式 API,这是一种全新的代码组织方式。通过 setup 函数和响应式 API,我们可以更灵活地组织组件逻辑,提高代码的可复用性和可维护性。\n\n主要特点:\n1. 更好的逻辑复用\n2. 更灵活的代码组织\n3. 更好的 TypeScript 支持'],
|
||||
['静态网站生成器选型', '2026-06-03', '对比常见的静态网站生成器,帮助你选择合适的工具。', '选择一个合适的静态网站生成器需要考虑多个因素:\n\n1. Hexo - 简单易用,主题丰富\n2. Jekyll - GitHub Pages 原生支持,社区活跃\n3. Hugo - 构建速度极快\n4. VuePress / VitePress - 基于 Vue,适合技术文档\n\n本博客使用 Vue 3 + Vite 构建,兼具现代特性和开发体验。'],
|
||||
['优雅地使用 Pinia 管理状态', '2026-06-04', 'Pinia 是 Vue 3 推荐的状态管理方案,本篇介绍其基本用法。', 'Pinia 是 Vue 官方推荐的新一代状态管理库,相比 Vuex 更加轻量和直观。\n\n核心概念:\n- Store - 存储状态的地方\n- Getters - 类似 computed 的状态计算\n- Actions - 处理异步逻辑或修改状态'],
|
||||
['CSS Grid 布局实战技巧', '2026-06-05', '深入探索 CSS Grid,让页面布局变得轻而易举。', 'CSS Grid 是现代 CSS 布局的重要组成部分,比 Flexbox 更适合二维布局场景。\n\n基础用法:\n.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }\n\n常用场景:\n1. 相册网格 - 自动填充列数\n2. 圣杯布局 - 经典三栏布局\n3. 响应式卡片 - 自动换行适配'],
|
||||
['用 Git 工作流提升团队协作效率', '2026-06-06', 'GitFlow vs trunk-based development,选对工作流事半功倍。', '良好的 Git 工作流能让团队协作更加顺畅。常见的工作流模型:\n\n1. GitFlow - 分支模型清晰,适合发布周期稳定的项目\n2. trunk-based development - 主干开发,快速迭代\n3. fork workflow - 开源项目首选,隔离性强\n\n小团队推荐使用简化 GitFlow:main + develop + feature 分支。'],
|
||||
['JavaScript 事件循环机制详解', '2026-06-07', '理解 Event Loop、宏任务与微任务,告别异步编程困惑。', '事件循环是 JavaScript 异步编程的核心机制。\n\n执行顺序:\n1. 同步代码优先执行\n2. 微任务(Promise、MutationObserver)\n3. 宏任务(setTimeout、setInterval、setImmediate)\n\n示例:\nconsole.log(\'1\');\nsetTimeout(() => console.log(\'2\'), 0);\nPromise.resolve().then(() => console.log(\'3\'));\nconsole.log(\'4\');\n\n输出顺序:1, 4, 3, 2'],
|
||||
['我的开发环境配置分享', '2026-06-08', 'VS Code + Zsh + Tmux,打造高效开发终端。', '工欲善其事,必先利其器。分享我的开发环境配置:\n\n编辑器:\n- VS Code + Vim 插件\n- Tokyo Night 主题\n- Fira Code 字体 + 连字\n\n终端:\n- iTerm2 (macOS) / Windows Terminal\n- Oh My Zsh\n- Tmux 会话管理\n\n版本管理:\n- Git + delta 美化 diff\n- lazygit 终端 UI'],
|
||||
['从零搭建一个 CLI 工具', '2026-06-09', '使用 Node.js 和 commander.js 快速构建命令行工具。', '命令行工具是提升开发效率的利器。使用 Node.js 可以快速构建:\n\n初始化项目:\nnpm init -y\nnpm install commander inquirer\n\n核心代码:\nconst { program } = require(\'commander\');\nprogram.version(\'1.0.0\');\nprogram.option(\'-n, --name <name>\', \'项目名称\');\nprogram.parse(process.argv);\n\n发布到 npm:\nnpm publish --access public'],
|
||||
['为什么我喜欢深夜编程', '2026-06-10', '安静、专注、不被打扰,深夜是程序员的黄金时段。', '深夜编程的独特魅力:\n\n1. 绝对安静 - 无人打扰,思路连贯\n2. 效率翻倍 - 深度工作状态\n3. 问题解决 - 复杂 bug 往往在这时候被攻破\n\n当然,也要适度。注意休息,保护眼睛,第二天才能持续输出。\n\n推荐配合:黑咖啡 + 轻音乐 + 舒适的降噪耳机。']
|
||||
];
|
||||
|
||||
for (const post of samplePosts) {
|
||||
await connection.query(
|
||||
'INSERT INTO posts (title, date, excerpt, content) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE title=title',
|
||||
post
|
||||
);
|
||||
}
|
||||
console.log('✅ 示例文章初始化成功(共10篇)');
|
||||
|
||||
console.log('\n🎉 数据库初始化完成!');
|
||||
console.log(`📖 数据库名: ${DB_NAME}`);
|
||||
console.log('📋 数据表: posts, about, settings, cron_tasks');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 数据库初始化失败:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initDatabase();
|
||||
49
server/src/scripts/migrateColumns.js
Normal file
49
server/src/scripts/migrateColumns.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 数据库字段扩展脚本
|
||||
* 将 TEXT 改为 LONGTEXT 以支持大图片
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'jnote'
|
||||
};
|
||||
|
||||
async function migrate() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 正在连接数据库...');
|
||||
connection = await mysql.createConnection(config);
|
||||
console.log('✅ 数据库连接成功');
|
||||
|
||||
console.log('📝 正在修改 settings 表字段...');
|
||||
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN bg_image LONGTEXT');
|
||||
console.log('✅ bg_image 改为 LONGTEXT');
|
||||
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN favicon LONGTEXT');
|
||||
console.log('✅ favicon 改为 LONGTEXT');
|
||||
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN uploaded_images LONGTEXT');
|
||||
console.log('✅ uploaded_images 改为 LONGTEXT');
|
||||
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN uploaded_icons LONGTEXT');
|
||||
console.log('✅ uploaded_icons 改为 LONGTEXT');
|
||||
|
||||
console.log('\n🎉 字段扩展完成!');
|
||||
} catch (error) {
|
||||
console.error('❌ 扩展失败:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
115
server/src/scripts/migrateStorage.js
Normal file
115
server/src/scripts/migrateStorage.js
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 数据存储结构迁移脚本
|
||||
* 将 posts.content 升级为 MEDIUMTEXT,about 和 settings 的 JSON 字段类型优化
|
||||
* 运行方式: node src/scripts/migrateStorage.js
|
||||
*/
|
||||
require('dotenv').config();
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'jnote'
|
||||
};
|
||||
|
||||
async function migrate() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 正在连接数据库...');
|
||||
connection = await mysql.createConnection(config);
|
||||
console.log('✅ 数据库连接成功');
|
||||
|
||||
// 1. 将 posts.content 升级为 MEDIUMTEXT
|
||||
console.log('📝 正在升级 posts.content 为 MEDIUMTEXT...');
|
||||
try {
|
||||
await connection.query('ALTER TABLE posts MODIFY COLUMN content MEDIUMTEXT');
|
||||
console.log('✅ posts.content 已升级为 MEDIUMTEXT');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_BAD_FIELD_ERROR') {
|
||||
console.log('ℹ️ posts.content 已是 MEDIUMTEXT 或不存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 将 about.features 改为 JSON 类型
|
||||
console.log('📋 正在将 about.features 迁移为 JSON 类型...');
|
||||
try {
|
||||
await connection.query('ALTER TABLE about MODIFY COLUMN features JSON');
|
||||
console.log('✅ about.features 已改为 JSON 类型');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_BAD_FIELD_ERROR') {
|
||||
console.log('ℹ️ about.features 字段不存在,跳过');
|
||||
} else {
|
||||
console.log('⚠️ about.features 迁移失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 将 about.contact 改为 JSON 类型
|
||||
console.log('📋 正在将 about.contact 迁移为 JSON 类型...');
|
||||
try {
|
||||
await connection.query('ALTER TABLE about MODIFY COLUMN contact JSON');
|
||||
console.log('✅ about.contact 已改为 JSON 类型');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_BAD_FIELD_ERROR') {
|
||||
console.log('ℹ️ about.contact 字段不存在,跳过');
|
||||
} else {
|
||||
console.log('⚠️ about.contact 迁移失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 将 settings.uploaded_images 改为 JSON 类型
|
||||
console.log('⚙️ 正在将 settings.uploaded_images 迁移为 JSON 类型...');
|
||||
try {
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN uploaded_images JSON');
|
||||
console.log('✅ settings.uploaded_images 已改为 JSON 类型');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_BAD_FIELD_ERROR') {
|
||||
console.log('ℹ️ settings.uploaded_images 字段不存在,跳过');
|
||||
} else {
|
||||
console.log('⚠️ settings.uploaded_images 迁移失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 将 settings.uploaded_icons 改为 JSON 类型
|
||||
console.log('⚙️ 正在将 settings.uploaded_icons 迁移为 JSON 类型...');
|
||||
try {
|
||||
await connection.query('ALTER TABLE settings MODIFY COLUMN uploaded_icons JSON');
|
||||
console.log('✅ settings.uploaded_icons 已改为 JSON 类型');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_BAD_FIELD_ERROR') {
|
||||
console.log('ℹ️ settings.uploaded_icons 字段不存在,跳过');
|
||||
} else {
|
||||
console.log('⚠️ settings.uploaded_icons 迁移失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 添加 posts.date 索引
|
||||
console.log('📊 正在添加 posts.date 索引...');
|
||||
try {
|
||||
await connection.query('CREATE INDEX idx_posts_date ON posts (date DESC)');
|
||||
console.log('✅ idx_posts_date 索引创建成功');
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_DUP_KEYNAME') {
|
||||
console.log('ℹ️ idx_posts_date 索引已存在,跳过');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🎉 存储结构迁移完成!');
|
||||
console.log('\n建议:考虑将图片等大文件存储到文件服务器,而非数据库。');
|
||||
} catch (error) {
|
||||
console.error('❌ 迁移失败:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
134
server/start.js
Normal file
134
server/start.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* JNote 后端启动脚本
|
||||
* 跨平台兼容 Windows 和 Linux
|
||||
*/
|
||||
|
||||
const { spawn, execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const scriptDir = __dirname;
|
||||
process.chdir(scriptDir);
|
||||
|
||||
const APP_NAME = 'jnote-api';
|
||||
const isWindows = os.platform() === 'win32';
|
||||
|
||||
function log(msg) {
|
||||
console.log(`[Start] ${msg}`);
|
||||
}
|
||||
|
||||
function loadEnv() {
|
||||
const envFile = path.join(scriptDir, '.env');
|
||||
if (!fs.existsSync(envFile)) {
|
||||
log('⚠️ 未找到 .env 文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(envFile, 'utf8');
|
||||
content.split('\n').forEach(line => {
|
||||
line = line.trim();
|
||||
if (!line || line.startsWith('#')) return;
|
||||
const idx = line.indexOf('=');
|
||||
if (idx > 0) {
|
||||
const key = line.substring(0, idx).trim();
|
||||
let value = line.substring(idx + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getExePath() {
|
||||
const possibleNames = isWindows
|
||||
? ['jnote-api.exe', 'jnote-api']
|
||||
: ['jnote-api', 'jnote-api.exe'];
|
||||
|
||||
for (const name of possibleNames) {
|
||||
const p = path.join(scriptDir, name);
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function startWithPM2(exePath) {
|
||||
log('🚀 使用 PM2 启动...');
|
||||
|
||||
try {
|
||||
execSync('pm2 --version', { stdio: 'ignore' });
|
||||
} catch {
|
||||
log('📦 安装 PM2...');
|
||||
execSync('npm install -g pm2', { cwd: scriptDir, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`pm2 stop ${APP_NAME}`, { stdio: 'ignore' });
|
||||
execSync(`pm2 delete ${APP_NAME}`, { stdio: 'ignore' });
|
||||
} catch {}
|
||||
|
||||
spawn('pm2', ['start', exePath, '--name', APP_NAME], {
|
||||
cwd: scriptDir,
|
||||
stdio: 'inherit',
|
||||
shell: true
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
try { execSync('pm2 save', { stdio: 'ignore' }); } catch {}
|
||||
}, 2000);
|
||||
|
||||
log('✅ 服务已启动');
|
||||
log(' 状态: pm2 status');
|
||||
log(' 日志: pm2 logs jnote-api');
|
||||
}
|
||||
|
||||
function startDirect(exePath) {
|
||||
log('🚀 直接启动服务...');
|
||||
log(` 端口: ${process.env.PORT || 3000}`);
|
||||
log('');
|
||||
|
||||
if (!isWindows) {
|
||||
fs.chmodSync(exePath, 0o755);
|
||||
}
|
||||
|
||||
spawn(exePath, [], {
|
||||
cwd: scriptDir,
|
||||
stdio: 'inherit',
|
||||
env: process.env
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('=========================================');
|
||||
console.log(' JNote 后端启动脚本');
|
||||
console.log(` 平台: ${isWindows ? 'Windows' : 'Linux'}`);
|
||||
console.log('=========================================');
|
||||
|
||||
loadEnv();
|
||||
|
||||
const exe = getExePath();
|
||||
if (!exe) {
|
||||
console.error('❌ 错误: 未找到 jnote-api 可执行文件');
|
||||
console.error(` 请确保可执行文件与本脚本在同一目录下: ${scriptDir}`);
|
||||
console.error('');
|
||||
console.error(' Linux 应有: jnote-api');
|
||||
console.error(' Windows 应有: jnote-api.exe');
|
||||
console.error('');
|
||||
console.error(' 如果还没有打包,请先运行: node build.js');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log(`✓ 找到: ${path.basename(exe)}`);
|
||||
|
||||
// 优先使用 PM2
|
||||
try {
|
||||
execSync('pm2 --version', { stdio: 'ignore' });
|
||||
startWithPM2(exe);
|
||||
} catch {
|
||||
startDirect(exe);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
85
src/App.vue
Normal file
85
src/App.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import SiteHeader from '@/components/SiteHeader.vue'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { usePostsStore } from '@/stores/posts'
|
||||
|
||||
const settings = useSettingsStore()
|
||||
const postsStore = usePostsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
// 并行加载初始数据
|
||||
await Promise.all([
|
||||
settings.loadSettings(),
|
||||
postsStore.fetchPosts()
|
||||
])
|
||||
})
|
||||
|
||||
const bgStyle = computed(() => {
|
||||
if (settings.bgType === 'color') {
|
||||
return {
|
||||
backgroundColor: settings.bgColor,
|
||||
opacity: settings.bgOpacity
|
||||
}
|
||||
} else if (settings.bgType === 'image' && settings.bgImage) {
|
||||
return {
|
||||
backgroundImage: `url(${settings.bgImage})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundAttachment: 'fixed',
|
||||
opacity: settings.bgOpacity
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-layer" :style="bgStyle"></div>
|
||||
<SiteHeader />
|
||||
<div class="content">
|
||||
<router-view />
|
||||
</div>
|
||||
<footer class="site-footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">桂ICP备2022004108号-1</a>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import '@/assets/base.css';
|
||||
|
||||
.bg-layer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.site-footer a {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-footer a:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
80
src/assets/base.css
Normal file
80
src/assets/base.css
Normal file
@@ -0,0 +1,80 @@
|
||||
/* 颜色调色板 */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* 语义化颜色变量 */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--color-primary: #42b883;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
|
||||
--color-primary: #42b883;
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition: color 0.5s, background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
1
src/assets/logo.svg
Normal file
1
src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69" xmlns:v="https://vecta.io/nano"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 308 B |
74
src/components/PostList.vue
Normal file
74
src/components/PostList.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { usePostsStore } from '@/stores/posts'
|
||||
|
||||
const postsStore = usePostsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="post-list">
|
||||
<div v-if="postsStore.posts.length === 0" class="empty-state">
|
||||
<p>暂无文章</p>
|
||||
</div>
|
||||
<article v-for="post in postsStore.posts" :key="post.id" class="post-card">
|
||||
<RouterLink :to="`/post/${post.id}`" class="post-link">
|
||||
<h2 class="post-title">{{ post.title }}</h2>
|
||||
<p class="post-date">{{ post.date }}</p>
|
||||
<p class="post-excerpt">{{ post.excerpt }}</p>
|
||||
</RouterLink>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.post-list {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.post-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.post-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.post-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.post-link:hover .post-title {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.post-date {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
62
src/components/SiteHeader.vue
Normal file
62
src/components/SiteHeader.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="site-header">
|
||||
<div class="header-content">
|
||||
<RouterLink to="/" class="site-name">{{ t('home.title') }}</RouterLink>
|
||||
<nav class="main-nav">
|
||||
<RouterLink to="/">{{ t('nav.articles') }}</RouterLink>
|
||||
<RouterLink to="/about">{{ t('nav.about') }}</RouterLink>
|
||||
<RouterLink to="/cron">{{ t('nav.cron') }}</RouterLink>
|
||||
<RouterLink to="/settings">{{ t('nav.settings') }}</RouterLink>
|
||||
<RouterLink to="/server-config">{{ t('serverConfig.title') }}</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.site-header {
|
||||
background: var(--color-background);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.main-nav a:hover,
|
||||
.main-nav a.router-link-exact-active {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
15
src/composables/useI18n.js
Normal file
15
src/composables/useI18n.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { computed } from 'vue'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { t as translate } from '@/i18n'
|
||||
|
||||
export function useI18n() {
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const locale = computed(() => settings.language)
|
||||
|
||||
function t(key) {
|
||||
return translate(key, locale.value)
|
||||
}
|
||||
|
||||
return { t, locale }
|
||||
}
|
||||
269
src/i18n/index.js
Normal file
269
src/i18n/index.js
Normal file
@@ -0,0 +1,269 @@
|
||||
export const messages = {
|
||||
zh: {
|
||||
nav: {
|
||||
articles: '文章',
|
||||
about: '关于',
|
||||
cron: '定时任务',
|
||||
settings: '设置'
|
||||
},
|
||||
home: {
|
||||
title: '我的编程学习记录',
|
||||
empty: '暂无文章',
|
||||
loading: '加载中...',
|
||||
newPost: '新建文章'
|
||||
},
|
||||
about: {
|
||||
title: '关于我',
|
||||
intro: '你好!我是一名热爱技术开发的开发者。',
|
||||
blog: '这个博客用于分享我在学习和工作中的一些心得体会。',
|
||||
tech: '技术栈',
|
||||
features: '功能特点',
|
||||
feature1: '定时任务系统 - 支持 Cron表达式的自动化任务管理',
|
||||
feature2: '自定义背景 - 支持纯色和图片背景,可调节透明度',
|
||||
feature3: '多语言支持 - 中英文切换',
|
||||
feature4: '自定义图标 - 上传本地图片作为网站图标',
|
||||
contact: '联系方式',
|
||||
contactDesc: '如果你有任何问题或建议,欢迎通过以下方式联系我:',
|
||||
filingNumber: '桂ICP备2022004108号-1'
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
language: '语言设置',
|
||||
interfaceLang: '界面语言',
|
||||
background: '背景设置',
|
||||
bgType: '背景类型',
|
||||
solid: '纯色',
|
||||
image: '图片',
|
||||
bgColor: '背景颜色',
|
||||
bgImage: '背景图片',
|
||||
opacity: '背景透明度',
|
||||
preview: '预览',
|
||||
uploadImage: '上传本地图片',
|
||||
uploading: '上传中...',
|
||||
localImage: '本地图片',
|
||||
clear: '清除',
|
||||
imageUrlPlaceholder: '或输入图片URL',
|
||||
saved: '已保存',
|
||||
siteIcon: '网站图标',
|
||||
uploadIcon: '上传图标',
|
||||
defaultIcon: '恢复默认',
|
||||
myIcons: '我的图标库',
|
||||
myImages: '我的图片库'
|
||||
},
|
||||
serverConfig: {
|
||||
title: '服务端配置',
|
||||
database: '数据库配置',
|
||||
rustfs: 'RUSTFS 对象存储',
|
||||
server: '服务器配置',
|
||||
dbHost: '主机',
|
||||
dbPort: '端口',
|
||||
dbUser: '用户名',
|
||||
dbPassword: '密码',
|
||||
dbName: '数据库名',
|
||||
rustfsEndpoint: '端点地址',
|
||||
rustfsRegion: '区域',
|
||||
rustfsBucket: '存储桶',
|
||||
rustfsAccessKey: '访问密钥',
|
||||
rustfsSecretKey: '密钥',
|
||||
serverPort: '端口',
|
||||
passwordPlaceholder: '留空则不修改',
|
||||
save: '保存配置',
|
||||
saving: '保存中...',
|
||||
saved: '配置已保存',
|
||||
saveFailed: '保存失败',
|
||||
restart: '重启服务',
|
||||
restarting: '重启中...',
|
||||
restartFailed: '重启失败',
|
||||
confirmRestart: '确定要重启后端服务吗?',
|
||||
loading: '加载中...'
|
||||
},
|
||||
cron: {
|
||||
title: '定时任务处理系统',
|
||||
newTask: '新建任务',
|
||||
editTask: '编辑任务',
|
||||
usage: '使用说明',
|
||||
cronFormat: 'Cron表达式格式:分 时 日 月 周,例如*/5 * * * * 表示每5分钟执行',
|
||||
supportedSymbols: '支持的符号:*任意值,*/n每n个时间单位,,列表,-范围',
|
||||
headersFormat: '请求头格式:JSON对象,例如 {"Content-Type": "application/json"}',
|
||||
empty: '暂无定时任务,点击"新建任务"添加第一个任务',
|
||||
disabled: '已禁用',
|
||||
running: '执行中',
|
||||
success: '成功',
|
||||
error: '失败',
|
||||
nextRun: '下次执行',
|
||||
lastRun: '上次执行',
|
||||
runCount: '累计执行',
|
||||
executing: '执行中...',
|
||||
runNow: '立即执行',
|
||||
disable: '禁用',
|
||||
enable: '启用',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
taskName: '任务名称',
|
||||
taskNamePlaceholder: '例如:每隔5分钟同步数据',
|
||||
cronExpression: 'Cron表达式',
|
||||
cronHint: '分 时 日 月 周',
|
||||
requestMethod: '请求方法',
|
||||
requestURL: '请求URL',
|
||||
requestHeaders: '请求头',
|
||||
requestBody: '请求体',
|
||||
enableTask: '启用任务',
|
||||
cancel: '取消',
|
||||
save: '保存'
|
||||
},
|
||||
postEditor: {
|
||||
newTitle: '新建文章',
|
||||
editTitle: '编辑文章',
|
||||
title: '标题',
|
||||
date: '日期',
|
||||
excerpt: '摘要',
|
||||
excerptPlaceholder: '文章摘要(可选)',
|
||||
content: '正文',
|
||||
contentPlaceholder: '文章正文内容...',
|
||||
save: '保存',
|
||||
saving: '保存中...',
|
||||
cancel: '取消',
|
||||
titleRequired: '标题和日期是必填项',
|
||||
saveFailed: '保存失败',
|
||||
loadFailed: '加载文章失败'
|
||||
}
|
||||
},
|
||||
en: {
|
||||
nav: {
|
||||
articles: 'Articles',
|
||||
about: 'About',
|
||||
cron: 'Cron Jobs',
|
||||
settings: 'Settings'
|
||||
},
|
||||
home: {
|
||||
title: 'My Programming Learning Blog',
|
||||
empty: 'No articles yet',
|
||||
loading: 'Loading...',
|
||||
newPost: 'New Post'
|
||||
},
|
||||
about: {
|
||||
title: 'About Me',
|
||||
intro: 'Hello! I am a developer passionate about technology.',
|
||||
blog: 'This blog is for sharing my insights and experiences in learning and work.',
|
||||
tech: 'Tech Stack',
|
||||
features: 'Features',
|
||||
feature1: 'Cron Job System - Automated task management with Cron expressions',
|
||||
feature2: 'Custom Background - Solid color and image backgrounds with adjustable opacity',
|
||||
feature3: 'Multi-language Support - Chinese and English switching',
|
||||
feature4: 'Custom Icon - Upload local images as website icon',
|
||||
contact: 'Contact',
|
||||
contactDesc: 'If you have any questions or suggestions, feel free to contact me:',
|
||||
filingNumber: '桂ICP备2022004108号-1'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
language: 'Language Settings',
|
||||
interfaceLang: 'Interface Language',
|
||||
background: 'Background Settings',
|
||||
bgType: 'Background Type',
|
||||
solid: 'Solid Color',
|
||||
image: 'Image',
|
||||
bgColor: 'Background Color',
|
||||
bgImage: 'Background Image',
|
||||
opacity: 'Background Opacity',
|
||||
preview: 'Preview',
|
||||
uploadImage: 'Upload Local Image',
|
||||
uploading: 'Uploading...',
|
||||
localImage: 'Local Image',
|
||||
clear: 'Clear',
|
||||
imageUrlPlaceholder: 'or enter image URL',
|
||||
saved: 'Saved',
|
||||
siteIcon: 'Site Icon',
|
||||
uploadIcon: 'Upload Icon',
|
||||
defaultIcon: 'Reset Default',
|
||||
myIcons: 'My Icons',
|
||||
myImages: 'My Images'
|
||||
},
|
||||
serverConfig: {
|
||||
title: 'Server Config',
|
||||
database: 'Database',
|
||||
rustfs: 'RUSTFS Object Storage',
|
||||
server: 'Server',
|
||||
dbHost: 'Host',
|
||||
dbPort: 'Port',
|
||||
dbUser: 'User',
|
||||
dbPassword: 'Password',
|
||||
dbName: 'Database',
|
||||
rustfsEndpoint: 'Endpoint',
|
||||
rustfsRegion: 'Region',
|
||||
rustfsBucket: 'Bucket',
|
||||
rustfsAccessKey: 'Access Key',
|
||||
rustfsSecretKey: 'Secret Key',
|
||||
serverPort: 'Port',
|
||||
passwordPlaceholder: 'Leave empty to keep unchanged',
|
||||
save: 'Save Config',
|
||||
saving: 'Saving...',
|
||||
saved: 'Config saved',
|
||||
saveFailed: 'Save failed',
|
||||
restart: 'Restart Server',
|
||||
restarting: 'Restarting...',
|
||||
restartFailed: 'Restart failed',
|
||||
confirmRestart: 'Are you sure you want to restart the server?',
|
||||
loading: 'Loading...'
|
||||
},
|
||||
cron: {
|
||||
title: 'Cron Job System',
|
||||
newTask: 'New Task',
|
||||
editTask: 'Edit Task',
|
||||
usage: 'Usage Instructions',
|
||||
cronFormat: 'Cron format: min hour day month week, e.g. */5 * * * * means every 5 minutes',
|
||||
supportedSymbols: 'Supported symbols: * any value, */n every n units, , list, - range',
|
||||
headersFormat: 'Headers format: JSON object, e.g. {"Content-Type": "application/json"}',
|
||||
empty: 'No cron tasks yet. Click "New Task" to add your first task.',
|
||||
disabled: 'Disabled',
|
||||
running: 'Running',
|
||||
success: 'Success',
|
||||
error: 'Failed',
|
||||
nextRun: 'Next Run',
|
||||
lastRun: 'Last Run',
|
||||
runCount: 'Run Count',
|
||||
executing: 'Executing...',
|
||||
runNow: 'Run Now',
|
||||
disable: 'Disable',
|
||||
enable: 'Enable',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
taskName: 'Task Name',
|
||||
taskNamePlaceholder: 'e.g., Sync data every 5 minutes',
|
||||
cronExpression: 'Cron Expression',
|
||||
cronHint: 'min hour day month week',
|
||||
requestMethod: 'Request Method',
|
||||
requestURL: 'Request URL',
|
||||
requestHeaders: 'Request Headers',
|
||||
requestBody: 'Request Body',
|
||||
enableTask: 'Enable Task',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save'
|
||||
},
|
||||
postEditor: {
|
||||
newTitle: 'New Post',
|
||||
editTitle: 'Edit Post',
|
||||
title: 'Title',
|
||||
date: 'Date',
|
||||
excerpt: 'Excerpt',
|
||||
excerptPlaceholder: 'Post excerpt (optional)',
|
||||
content: 'Content',
|
||||
contentPlaceholder: 'Post content...',
|
||||
save: 'Save',
|
||||
saving: 'Saving...',
|
||||
cancel: 'Cancel',
|
||||
titleRequired: 'Title and date are required',
|
||||
saveFailed: 'Save failed',
|
||||
loadFailed: 'Failed to load post'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function t(key, locale = 'zh') {
|
||||
const keys = key.split('.')
|
||||
let value = messages[locale]
|
||||
for (const k of keys) {
|
||||
value = value?.[k]
|
||||
}
|
||||
return value || key
|
||||
}
|
||||
12
src/main.js
Normal file
12
src/main.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
51
src/router/index.js
Normal file
51
src/router/index.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
import PostDetail from '@/views/PostDetail.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView
|
||||
},
|
||||
{
|
||||
path: '/post/:id',
|
||||
name: 'post',
|
||||
component: PostDetail
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
component: () => import('@/views/AboutView.vue')
|
||||
},
|
||||
{
|
||||
path: '/cron',
|
||||
name: 'cron',
|
||||
component: () => import('@/views/CronView.vue')
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/SettingsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/server-config',
|
||||
name: 'server-config',
|
||||
component: () => import('@/views/ServerConfigView.vue')
|
||||
},
|
||||
{
|
||||
path: '/post/new',
|
||||
name: 'post-new',
|
||||
component: () => import('@/views/PostEditorView.vue')
|
||||
},
|
||||
{
|
||||
path: '/post/edit/:id',
|
||||
name: 'post-edit',
|
||||
component: () => import('@/views/PostEditorView.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
69
src/services/api.js
Normal file
69
src/services/api.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// API 服务层
|
||||
// 开发环境通过 vite.config.js 代理到后端
|
||||
// 生产环境可配置 VITE_API_URL 环境变量
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
async function request(endpoint, options = {}) {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '请求失败');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`API Error [${endpoint}]:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// 文章
|
||||
getPosts: () => request('/posts'),
|
||||
getPost: (id) => request(`/posts/${id}`),
|
||||
createPost: (data) => request('/posts', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updatePost: (id, data) => request(`/posts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deletePost: (id) => request(`/posts/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// 关于
|
||||
getAbout: () => request('/about'),
|
||||
updateAbout: (data) => request('/about', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
|
||||
// 设置
|
||||
getSettings: () => request('/settings'),
|
||||
updateSettings: (data) => request('/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||
|
||||
// 图片上传到 RUSTFS
|
||||
uploadImage: (imageData, type) => request('/settings/upload-image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ imageData, type })
|
||||
}),
|
||||
uploadImages: (images, type) => request('/settings/upload-images', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ images, type })
|
||||
}),
|
||||
|
||||
// 定时任务
|
||||
getCronTasks: () => request('/cron-tasks'),
|
||||
createCronTask: (data) => request('/cron-tasks', { method: 'POST', body: JSON.stringify(data) }),
|
||||
updateCronTask: (id, data) => request(`/cron-tasks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteCronTask: (id) => request(`/cron-tasks/${id}`, { method: 'DELETE' }),
|
||||
runCronTask: (id) => request(`/cron-tasks/${id}/run`, { method: 'POST' }),
|
||||
|
||||
// 健康检查
|
||||
health: () => request('/health')
|
||||
};
|
||||
|
||||
export default api;
|
||||
61
src/stores/about.js
Normal file
61
src/stores/about.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
|
||||
export const useAboutStore = defineStore('about', () => {
|
||||
const title = ref('')
|
||||
const intro = ref('')
|
||||
const blog = ref('')
|
||||
const tech = ref('')
|
||||
const features = ref([])
|
||||
const contact = ref({})
|
||||
const filingNumber = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadAbout() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.getAbout()
|
||||
title.value = data.title || ''
|
||||
intro.value = data.intro || ''
|
||||
blog.value = data.blog || ''
|
||||
tech.value = data.tech || ''
|
||||
features.value = data.featuresList || []
|
||||
contact.value = data.contactObj || {}
|
||||
filingNumber.value = data.filing_number || ''
|
||||
} catch (error) {
|
||||
console.error('加载关于内容失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAbout(data) {
|
||||
try {
|
||||
await api.updateAbout(data)
|
||||
title.value = data.title || title.value
|
||||
intro.value = data.intro ?? intro.value
|
||||
blog.value = data.blog ?? blog.value
|
||||
tech.value = data.tech ?? tech.value
|
||||
features.value = data.features ?? features.value
|
||||
contact.value = data.contact ?? contact.value
|
||||
filingNumber.value = data.filing_number ?? filingNumber.value
|
||||
} catch (error) {
|
||||
console.error('保存关于内容失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
intro,
|
||||
blog,
|
||||
tech,
|
||||
features,
|
||||
contact,
|
||||
filingNumber,
|
||||
loading,
|
||||
loadAbout,
|
||||
saveAbout
|
||||
}
|
||||
})
|
||||
189
src/stores/cron.js
Normal file
189
src/stores/cron.js
Normal file
@@ -0,0 +1,189 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
|
||||
export const useCronStore = defineStore('cron', () => {
|
||||
const tasks = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const runningTasks = computed(() => {
|
||||
return tasks.value.filter(t => taskStatus.value[t.id] === 'running')
|
||||
})
|
||||
|
||||
const taskStatus = ref({})
|
||||
|
||||
async function loadTasks() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
tasks.value = await api.getCronTasks()
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
console.error('加载定时任务失败:', err)
|
||||
// 降级到 localStorage
|
||||
const saved = localStorage.getItem('cron-tasks')
|
||||
if (saved) {
|
||||
try {
|
||||
tasks.value = JSON.parse(saved)
|
||||
} catch {
|
||||
tasks.value = []
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTasks() {
|
||||
try {
|
||||
// 同步到服务器
|
||||
for (const task of tasks.value) {
|
||||
await api.updateCronTask(task.id, {
|
||||
name: task.name,
|
||||
cron: task.cron,
|
||||
url: task.url,
|
||||
method: task.method,
|
||||
headers: task.headers,
|
||||
body: task.body,
|
||||
enabled: task.enabled
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('同步到服务器失败,保留在本地:', err.message)
|
||||
// 备用 localStorage
|
||||
try {
|
||||
localStorage.setItem('cron-tasks', JSON.stringify(tasks.value))
|
||||
} catch (e) {
|
||||
console.warn('localStorage 保存失败:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask(data) {
|
||||
try {
|
||||
const newTask = await api.createCronTask(data)
|
||||
tasks.value.unshift(newTask)
|
||||
return newTask
|
||||
} catch (err) {
|
||||
// 后备方案:本地创建
|
||||
const localTask = {
|
||||
...data,
|
||||
id: Date.now().toString(),
|
||||
lastRun: null,
|
||||
nextRun: calculateNextRun(data.cron),
|
||||
runCount: 0
|
||||
}
|
||||
tasks.value.unshift(localTask)
|
||||
localStorage.setItem('cron-tasks', JSON.stringify(tasks.value))
|
||||
return localTask
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTask(id, data) {
|
||||
try {
|
||||
const updated = await api.updateCronTask(id, data)
|
||||
const index = tasks.value.findIndex(t => t.id === id)
|
||||
if (index !== -1) {
|
||||
tasks.value[index] = updated
|
||||
}
|
||||
return updated
|
||||
} catch (err) {
|
||||
const index = tasks.value.findIndex(t => t.id === id)
|
||||
if (index !== -1) {
|
||||
tasks.value[index] = { ...tasks.value[index], ...data }
|
||||
}
|
||||
localStorage.setItem('cron-tasks', JSON.stringify(tasks.value))
|
||||
return tasks.value[index]
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
try {
|
||||
await api.deleteCronTask(id)
|
||||
tasks.value = tasks.value.filter(t => t.id !== id)
|
||||
} catch (err) {
|
||||
tasks.value = tasks.value.filter(t => t.id !== id)
|
||||
localStorage.setItem('cron-tasks', JSON.stringify(tasks.value))
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTask(id) {
|
||||
const task = tasks.value.find(t => t.id === id)
|
||||
if (task) {
|
||||
task.enabled = !task.enabled
|
||||
await updateTask(id, { enabled: task.enabled })
|
||||
}
|
||||
}
|
||||
|
||||
async function executeTask(id) {
|
||||
if (taskStatus.value[id] === 'running') return
|
||||
|
||||
taskStatus.value[id] = 'running'
|
||||
|
||||
try {
|
||||
const result = await api.runCronTask(id)
|
||||
const task = tasks.value.find(t => t.id === id)
|
||||
if (task) {
|
||||
task.lastRun = result.lastRun
|
||||
task.nextRun = result.nextRun
|
||||
task.runCount = result.runCount
|
||||
}
|
||||
taskStatus.value[id] = result.success ? 'success' : 'error'
|
||||
|
||||
setTimeout(() => {
|
||||
taskStatus.value[id] = null
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
taskStatus.value[id] = 'error'
|
||||
console.error('执行任务失败:', error)
|
||||
|
||||
setTimeout(() => {
|
||||
taskStatus.value[id] = null
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
function calculateNextRun(cron) {
|
||||
const parts = cron.trim().split(/\s+/)
|
||||
if (parts.length < 5) return null
|
||||
|
||||
const now = new Date()
|
||||
const next = new Date(now)
|
||||
|
||||
const minute = parts[0]
|
||||
const hour = parts[1]
|
||||
|
||||
if (minute === '*') {
|
||||
next.setMinutes(now.getMinutes() + 1)
|
||||
} else if (minute.includes('/')) {
|
||||
const step = parseInt(minute.split('/')[1])
|
||||
next.setMinutes(Math.ceil(now.getMinutes() / step) * step)
|
||||
} else {
|
||||
next.setMinutes(parseInt(minute))
|
||||
if (next <= now) next.setHours(next.getHours() + 1)
|
||||
}
|
||||
|
||||
if (hour !== '*') {
|
||||
next.setHours(parseInt(hour), 0, 0, 0)
|
||||
if (next <= now) next.setDate(next.getDate() + 1)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
loading,
|
||||
error,
|
||||
taskStatus,
|
||||
runningTasks,
|
||||
loadTasks,
|
||||
createTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
toggleTask,
|
||||
executeTask,
|
||||
calculateNextRun
|
||||
}
|
||||
})
|
||||
66
src/stores/posts.js
Normal file
66
src/stores/posts.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { api } from '@/services/api'
|
||||
|
||||
export const usePostsStore = defineStore('posts', {
|
||||
state: () => ({
|
||||
posts: [],
|
||||
loading: false,
|
||||
error: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getPostById: (state) => (id) => {
|
||||
return state.posts.find(post => post.id === Number(id))
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchPosts() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
this.posts = await api.getPosts()
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
console.error('获取文章失败:', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async createPost(data) {
|
||||
try {
|
||||
const newPost = await api.createPost(data)
|
||||
this.posts.unshift(newPost)
|
||||
return newPost
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async updatePost(id, data) {
|
||||
try {
|
||||
const updated = await api.updatePost(id, data)
|
||||
const index = this.posts.findIndex(p => p.id === Number(id))
|
||||
if (index !== -1) {
|
||||
this.posts[index] = updated
|
||||
}
|
||||
return updated
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async deletePost(id) {
|
||||
try {
|
||||
await api.deletePost(id)
|
||||
this.posts = this.posts.filter(p => p.id !== Number(id))
|
||||
} catch (error) {
|
||||
this.error = error.message
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
263
src/stores/settings.js
Normal file
263
src/stores/settings.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const bgType = ref('color')
|
||||
const bgColor = ref('#f5f5f5')
|
||||
const bgImage = ref('')
|
||||
const bgOpacity = ref(1)
|
||||
const language = ref('zh')
|
||||
const favicon = ref('')
|
||||
// 现在存储 {id, url} 而不是 {id, data: base64}
|
||||
const uploadedImages = ref([])
|
||||
const uploadedIcons = ref([])
|
||||
const loading = ref(false)
|
||||
let initialized = false
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await api.getSettings()
|
||||
bgType.value = data.bgType || 'color'
|
||||
bgColor.value = data.bgColor || '#f5f5f5'
|
||||
bgImage.value = data.bgImage || ''
|
||||
bgOpacity.value = data.bgOpacity ?? 1
|
||||
language.value = data.language || 'zh'
|
||||
favicon.value = data.favicon || ''
|
||||
uploadedImages.value = data.uploadedImages || []
|
||||
uploadedIcons.value = data.uploadedIcons || []
|
||||
applyFavicon(favicon.value)
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
console.error('加载设置失败:', error)
|
||||
initialized = true
|
||||
// 降级到 localStorage
|
||||
const saved = localStorage.getItem('site-settings')
|
||||
if (saved) {
|
||||
try {
|
||||
const settings = JSON.parse(saved)
|
||||
bgType.value = settings.bgType || 'color'
|
||||
bgColor.value = settings.bgColor || '#f5f5f5'
|
||||
bgImage.value = settings.bgImage || ''
|
||||
bgOpacity.value = settings.bgOpacity || 1
|
||||
language.value = settings.language || 'zh'
|
||||
favicon.value = settings.favicon || ''
|
||||
uploadedImages.value = settings.uploadedImages || []
|
||||
uploadedIcons.value = settings.uploadedIcons || []
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
if (!initialized) {
|
||||
console.warn('saveSettings 跳过:还未初始化')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.updateSettings({
|
||||
bgType: bgType.value,
|
||||
bgColor: bgColor.value,
|
||||
bgImage: bgImage.value,
|
||||
bgOpacity: bgOpacity.value,
|
||||
language: language.value,
|
||||
favicon: favicon.value,
|
||||
uploadedImages: uploadedImages.value,
|
||||
uploadedIcons: uploadedIcons.value
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('保存到服务器失败:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传图片到 RUSTFS,返回 {id, url}
|
||||
async function uploadImageToRustfs(imageData, type = 'background') {
|
||||
try {
|
||||
const result = await api.uploadImage(imageData, type)
|
||||
return { id: result.id, url: result.url }
|
||||
} catch (error) {
|
||||
console.error('图片上传失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 批量上传图片到 RUSTFS
|
||||
async function uploadImagesToRustfs(imageDataList, type = 'background') {
|
||||
try {
|
||||
const images = imageDataList.map((data, index) => ({
|
||||
id: `${Date.now()}-${index}`,
|
||||
data
|
||||
}))
|
||||
const result = await api.uploadImages(images, type)
|
||||
return result.images
|
||||
} catch (error) {
|
||||
console.error('批量图片上传失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 添加单张图片(上传到 RUSTFS)
|
||||
async function addUploadedImage(imageData) {
|
||||
const result = await uploadImageToRustfs(imageData, 'background')
|
||||
uploadedImages.value.push(result)
|
||||
await saveSettings()
|
||||
return result.id
|
||||
}
|
||||
|
||||
// 批量添加图片(不上传到服务器)
|
||||
function addUploadedImagesBatchLocal(imageDataList) {
|
||||
const baseId = Date.now()
|
||||
const tempImages = imageDataList.map((imageData, index) => ({
|
||||
id: `${baseId}-${index}`,
|
||||
data: imageData
|
||||
}))
|
||||
return tempImages
|
||||
}
|
||||
|
||||
// 批量上传图片
|
||||
async function addUploadedImagesBatch(imageDataList) {
|
||||
const uploaded = await uploadImagesToRustfs(imageDataList, 'background')
|
||||
for (const img of uploaded) {
|
||||
if (img.url) {
|
||||
uploadedImages.value.push({ id: img.id, url: img.url })
|
||||
}
|
||||
}
|
||||
await saveSettings()
|
||||
}
|
||||
|
||||
// 设置背景图片(使用 URL)
|
||||
async function setBgImage(value) {
|
||||
bgImage.value = value
|
||||
await saveSettings()
|
||||
}
|
||||
|
||||
// 设置背景图片(上传 base64 后设置)
|
||||
async function setBgImageFromData(imageData) {
|
||||
const result = await uploadImageToRustfs(imageData, 'background')
|
||||
bgImage.value = result.url
|
||||
await saveSettings()
|
||||
}
|
||||
|
||||
function setBgType(value) {
|
||||
bgType.value = value
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
function setBgColor(value) {
|
||||
bgColor.value = value
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
function setBgOpacity(value) {
|
||||
bgOpacity.value = value
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
function setLanguage(value) {
|
||||
language.value = value
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
// 设置图标(上传到 RUSTFS)
|
||||
async function setFaviconFromData(imageData) {
|
||||
const result = await uploadImageToRustfs(imageData, 'icon')
|
||||
favicon.value = result.url
|
||||
applyFavicon(result.url)
|
||||
await saveSettings()
|
||||
}
|
||||
|
||||
// 设置图标(使用已有 URL)
|
||||
async function setFavicon(value) {
|
||||
favicon.value = value
|
||||
applyFavicon(value)
|
||||
await saveSettings()
|
||||
}
|
||||
|
||||
// 添加图标到图标库(上传到 RUSTFS)
|
||||
async function addUploadedIcon(imageData) {
|
||||
const result = await uploadImageToRustfs(imageData, 'icon')
|
||||
uploadedIcons.value.push(result)
|
||||
await saveSettings()
|
||||
return result.id
|
||||
}
|
||||
|
||||
// 批量添加图标
|
||||
async function addUploadedIconsBatch(imageDataList) {
|
||||
const uploaded = await uploadImagesToRustfs(imageDataList, 'icon')
|
||||
for (const icon of uploaded) {
|
||||
if (icon.url) {
|
||||
uploadedIcons.value.push({ id: icon.id, url: icon.url })
|
||||
}
|
||||
}
|
||||
await saveSettings()
|
||||
}
|
||||
|
||||
function removeUploadedImage(id) {
|
||||
uploadedImages.value = uploadedImages.value.filter(img => img.id !== id)
|
||||
// bgImage 现在是 URL,检查是否是被删除图片的 URL
|
||||
const img = uploadedImages.value.find(img => img.url === bgImage.value)
|
||||
if (!img && bgImage.value) {
|
||||
// 当前背景图片被删除了,重置为默认
|
||||
bgImage.value = ''
|
||||
}
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
function removeUploadedIcon(id) {
|
||||
uploadedIcons.value = uploadedIcons.value.filter(icon => icon.id !== id)
|
||||
// favicon 现在是 URL,检查是否是被删除图标的 URL
|
||||
const icon = uploadedIcons.value.find(icon => icon.url === favicon.value)
|
||||
if (!icon && favicon.value) {
|
||||
favicon.value = ''
|
||||
applyFavicon('')
|
||||
}
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
function applyFavicon(value) {
|
||||
const link = document.querySelector("link[rel='icon']")
|
||||
if (link) {
|
||||
if (value) {
|
||||
link.href = value
|
||||
} else {
|
||||
link.href = '/favicon.ico'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bgType,
|
||||
bgColor,
|
||||
bgImage,
|
||||
bgOpacity,
|
||||
language,
|
||||
favicon,
|
||||
uploadedImages,
|
||||
uploadedIcons,
|
||||
loading,
|
||||
initialized,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
setBgImage,
|
||||
setBgImageFromData,
|
||||
setBgType,
|
||||
setBgColor,
|
||||
setBgOpacity,
|
||||
setLanguage,
|
||||
setFavicon,
|
||||
setFaviconFromData,
|
||||
addUploadedImage,
|
||||
addUploadedImagesBatch,
|
||||
addUploadedImagesBatchLocal,
|
||||
removeUploadedImage,
|
||||
addUploadedIcon,
|
||||
addUploadedIconsBatch,
|
||||
removeUploadedIcon,
|
||||
applyFavicon
|
||||
}
|
||||
})
|
||||
93
src/views/AboutView.vue
Normal file
93
src/views/AboutView.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useAboutStore } from '@/stores/about'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const aboutStore = useAboutStore()
|
||||
|
||||
onMounted(() => {
|
||||
aboutStore.loadAbout()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="about-page">
|
||||
<div class="about-header">
|
||||
<h1>{{ aboutStore.title || t('about.title') }}</h1>
|
||||
</div>
|
||||
<div class="about-content">
|
||||
<p>{{ aboutStore.intro || t('about.intro') }}</p>
|
||||
<p>{{ aboutStore.blog || t('about.blog') }}</p>
|
||||
<h2>{{ t('about.tech') }}</h2>
|
||||
<ul>
|
||||
<li v-for="(item, index) in (aboutStore.tech ? aboutStore.tech.split(',') : [])" :key="index">
|
||||
{{ item.trim() }}
|
||||
</li>
|
||||
<li v-if="!aboutStore.tech">Vue / Vue 3</li>
|
||||
<li v-if="!aboutStore.tech">JavaScript / TypeScript</li>
|
||||
<li v-if="!aboutStore.tech">Node.js</li>
|
||||
<li v-if="!aboutStore.tech">Vite</li>
|
||||
<li v-if="!aboutStore.tech">Pinia</li>
|
||||
<li v-if="!aboutStore.tech">Vue Router</li>
|
||||
</ul>
|
||||
<h2>{{ t('about.features') }}</h2>
|
||||
<ul>
|
||||
<li v-for="(feature, index) in (aboutStore.features.length ? aboutStore.features : [t('about.feature1'), t('about.feature2'), t('about.feature3'), t('about.feature4')])" :key="index">
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
<h2>{{ t('about.contact') }}</h2>
|
||||
<p>{{ t('about.contactDesc') }}</p>
|
||||
<ul>
|
||||
<li v-if="aboutStore.contact.email">Email: {{ aboutStore.contact.email }}</li>
|
||||
<li v-if="aboutStore.contact.github">GitHub: {{ aboutStore.contact.github }}</li>
|
||||
<li v-if="!aboutStore.contact.email">Email: example@email.com</li>
|
||||
<li v-if="!aboutStore.contact.github">GitHub: github.com/yourusername</li>
|
||||
</ul>
|
||||
<div class="filing-number">
|
||||
{{ aboutStore.filingNumber || t('about.filingNumber') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.about-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.about-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.about-content h2 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.about-content p {
|
||||
line-height: 1.8;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.about-content ul {
|
||||
list-style: disc;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.about-content li {
|
||||
line-height: 1.8;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.filing-number {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
591
src/views/CronView.vue
Normal file
591
src/views/CronView.vue
Normal file
@@ -0,0 +1,591 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useCronStore } from '@/stores/cron'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const cronStore = useCronStore()
|
||||
|
||||
const showModal = ref(false)
|
||||
const editingTask = ref(null)
|
||||
const form = ref({
|
||||
name: '',
|
||||
cron: '',
|
||||
url: '',
|
||||
method: 'GET',
|
||||
headers: '',
|
||||
body: '',
|
||||
enabled: true
|
||||
})
|
||||
|
||||
let schedulerTimer = null
|
||||
|
||||
onMounted(() => {
|
||||
cronStore.loadTasks()
|
||||
startScheduler()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopScheduler()
|
||||
})
|
||||
|
||||
function openModal(task = null) {
|
||||
if (task) {
|
||||
editingTask.value = task.id
|
||||
form.value = { ...task }
|
||||
} else {
|
||||
editingTask.value = null
|
||||
form.value = {
|
||||
name: '',
|
||||
cron: '',
|
||||
url: '',
|
||||
method: 'GET',
|
||||
headers: '',
|
||||
body: '',
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editingTask.value = null
|
||||
}
|
||||
|
||||
async function saveTask() {
|
||||
if (!form.value.name || !form.value.cron || !form.value.url) {
|
||||
alert('请填写必填项:任务名称、Cron表达式、请求URL')
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateCron(form.value.cron)) {
|
||||
alert('Cron表达式格式不正确')
|
||||
return
|
||||
}
|
||||
|
||||
if (editingTask.value) {
|
||||
await cronStore.updateTask(editingTask.value, form.value)
|
||||
} else {
|
||||
await cronStore.createTask(form.value)
|
||||
}
|
||||
|
||||
closeModal()
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
if (confirm('确定要删除这个任务吗?')) {
|
||||
await cronStore.deleteTask(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTask(id) {
|
||||
await cronStore.toggleTask(id)
|
||||
}
|
||||
|
||||
function validateCron(cron) {
|
||||
const parts = cron.trim().split(/\s+/)
|
||||
if (parts.length < 5 || parts.length > 6) return false
|
||||
return parts.every(part => /^[^\s]+$/.test(part) && part !== '')
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString(locale.value === 'en' ? 'en-US' : 'zh-CN')
|
||||
}
|
||||
|
||||
async function executeTask(task) {
|
||||
await cronStore.executeTask(task.id)
|
||||
}
|
||||
|
||||
function startScheduler() {
|
||||
schedulerTimer = setInterval(() => {
|
||||
const now = new Date()
|
||||
cronStore.tasks.forEach(task => {
|
||||
if (!task.enabled || !task.nextRun) return
|
||||
|
||||
const nextRun = new Date(task.nextRun)
|
||||
if (now >= nextRun) {
|
||||
cronStore.executeTask(task.id)
|
||||
}
|
||||
})
|
||||
}, 10000)
|
||||
}
|
||||
|
||||
function stopScheduler() {
|
||||
if (schedulerTimer) {
|
||||
clearInterval(schedulerTimer)
|
||||
schedulerTimer = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-page">
|
||||
<div class="cron-header">
|
||||
<h1>{{ t('cron.title') }}</h1>
|
||||
<button class="btn-primary" @click="openModal()">+ {{ t('cron.newTask') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="cron-tips">
|
||||
<h3>{{ t('cron.usage') }}</h3>
|
||||
<ul>
|
||||
<li>{{ t('cron.cronFormat') }}</li>
|
||||
<li>{{ t('cron.supportedSymbols') }}</li>
|
||||
<li>{{ t('cron.headersFormat') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="task-list">
|
||||
<div v-if="cronStore.tasks.length === 0" class="empty-state">
|
||||
<p>{{ t('cron.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-for="task in cronStore.tasks" :key="task.id" class="task-card">
|
||||
<div class="task-main">
|
||||
<div class="task-info">
|
||||
<div class="task-name">
|
||||
{{ task.name }}
|
||||
<span v-if="!task.enabled" class="badge-disabled">{{ t('cron.disabled') }}</span>
|
||||
<span v-if="cronStore.taskStatus[task.id] === 'running'" class="badge-running">{{ t('cron.running') }}</span>
|
||||
<span v-else-if="cronStore.taskStatus[task.id] === 'success'" class="badge-success">{{ t('cron.success') }}</span>
|
||||
<span v-else-if="cronStore.taskStatus[task.id] === 'error'" class="badge-error">{{ t('cron.error') }}</span>
|
||||
</div>
|
||||
<div class="task-cron">{{ task.cron }}</div>
|
||||
<div class="task-request">
|
||||
<span class="method-badge" :class="'method-' + task.method.toLowerCase()">{{ task.method }}</span>
|
||||
{{ task.url }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('cron.nextRun') }}</span>
|
||||
<span class="stat-value">{{ task.nextRun || '-' }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('cron.lastRun') }}</span>
|
||||
<span class="stat-value">{{ task.lastRun || '-' }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">{{ t('cron.runCount') }}</span>
|
||||
<span class="stat-value">{{ task.runCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
<button class="btn-action" @click="executeTask(task)" :disabled="cronStore.taskStatus[task.id] === 'running'">
|
||||
{{ cronStore.taskStatus[task.id] === 'running' ? t('cron.executing') : t('cron.runNow') }}
|
||||
</button>
|
||||
<button class="btn-action" @click="toggleTask(task.id)">
|
||||
{{ task.enabled ? t('cron.disable') : t('cron.enable') }}
|
||||
</button>
|
||||
<button class="btn-action" @click="openModal(task)">{{ t('cron.edit') }}</button>
|
||||
<button class="btn-action btn-danger" @click="deleteTask(task.id)">{{ t('cron.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ editingTask ? t('cron.editTask') : t('cron.newTask') }}</h2>
|
||||
<button class="btn-close" @click="closeModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>{{ t('cron.taskName') }} *</label>
|
||||
<input v-model="form.name" type="text" :placeholder="t('cron.taskNamePlaceholder')" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('cron.cronExpression') }} *</label>
|
||||
<input v-model="form.cron" type="text" placeholder="*/5 * * * *" />
|
||||
<span class="form-hint">{{ t('cron.cronHint') }}</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>{{ t('cron.requestMethod') }}</label>
|
||||
<select v-model="form.method">
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group flex-1">
|
||||
<label>{{ t('cron.requestURL') }} *</label>
|
||||
<input v-model="form.url" type="text" placeholder="https://api.example.com/webhook" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('cron.requestHeaders') }} (JSON)</label>
|
||||
<input v-model="form.headers" type="text" placeholder='{"Content-Type": "application/json"}' />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('cron.requestBody') }}</label>
|
||||
<textarea v-model="form.body" rows="3" placeholder='{"key": "value"}'></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
{{ t('cron.enableTask') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="closeModal">{{ t('cron.cancel') }}</button>
|
||||
<button class="btn-primary" @click="saveTask">{{ t('cron.save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cron-page {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cron-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.cron-header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cron-tips {
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.cron-tips h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.cron-tips ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cron-tips li {
|
||||
line-height: 1.8;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.cron-tips code {
|
||||
background: var(--color-border);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.task-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.badge-disabled {
|
||||
background: #999;
|
||||
color: white;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.badge-running {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.task-cron {
|
||||
font-family: monospace;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.task-request {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.method-badge {
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.method-get { background: #22c55e; color: white; }
|
||||
.method-post { background: #3b82f6; color: white; }
|
||||
.method-put { background: #f59e0b; color: white; }
|
||||
.method-patch { background: #8b5cf6; color: white; }
|
||||
.method-delete { background: #ef4444; color: white; }
|
||||
|
||||
.task-stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: var(--color-border);
|
||||
border: none;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.btn-action:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-border);
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #ccc;
|
||||
}
|
||||
</style>
|
||||
79
src/views/HomeView.vue
Normal file
79
src/views/HomeView.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PostList from '@/components/PostList.vue'
|
||||
import { usePostsStore } from '@/stores/posts'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const router = useRouter()
|
||||
const postsStore = usePostsStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
onMounted(() => {
|
||||
if (postsStore.posts.length === 0) {
|
||||
postsStore.fetchPosts()
|
||||
}
|
||||
})
|
||||
|
||||
function goToNewPost() {
|
||||
router.push('/post/new')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<div class="home">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ t('home.title') }}</h1>
|
||||
<button class="btn-new-post" @click="goToNewPost" :style="{ display: 'none' }">+ {{ t('home.newPost') }}</button>
|
||||
</div>
|
||||
<div v-if="postsStore.loading" class="loading">{{ t('home.loading') }}</div>
|
||||
<div v-else-if="postsStore.error" class="error">{{ postsStore.error }}</div>
|
||||
<PostList v-else />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-new-post {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-new-post:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
240
src/views/PostDetail.vue
Normal file
240
src/views/PostDetail.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { usePostsStore } from '@/stores/posts'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const postsStore = usePostsStore()
|
||||
|
||||
const post = computed(() => postsStore.getPostById(route.params.id))
|
||||
const renderedContent = computed(() => post.value ? marked(post.value.content || '') : '')
|
||||
|
||||
function goToEdit() {
|
||||
router.push(`/post/edit/${route.params.id}`)
|
||||
}
|
||||
|
||||
async function deletePost() {
|
||||
if (confirm('确定要删除这篇文章吗?')) {
|
||||
await postsStore.deletePost(route.params.id)
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="post-detail" v-if="post">
|
||||
<div class="nav-buttons">
|
||||
<RouterLink to="/" class="back-link">← 返回列表</RouterLink>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-edit" @click="goToEdit" :style="{ display: 'none' }">编辑</button>
|
||||
<button class="btn-delete" @click="deletePost" :style="{ display: 'none' }">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<article class="post">
|
||||
<header class="post-header">
|
||||
<h1 class="post-title">{{ post.title }}</h1>
|
||||
<p class="post-date">{{ post.date }}</p>
|
||||
</header>
|
||||
<div class="post-content markdown-body" v-html="renderedContent"></div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="not-found">
|
||||
<h2>文章未找到</h2>
|
||||
<RouterLink to="/">返回首页</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.post-detail {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-edit, .btn-delete {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
.post {
|
||||
background: var(--color-background);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.post-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.post-title {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.not-found h2 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.markdown-body {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 1.8rem;
|
||||
margin: 1.5rem 0 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 1.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 1.25rem;
|
||||
margin: 1.25rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
background: var(--color-border);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.9rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-body ul, .markdown-body ol {
|
||||
margin: 0 0 1rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.markdown-body table th,
|
||||
.markdown-body table td {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-body table th {
|
||||
background: var(--color-surface);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
margin: 1rem 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-left: 4px solid var(--color-primary);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.markdown-body a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
215
src/views/PostEditorView.vue
Normal file
215
src/views/PostEditorView.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usePostsStore } from '@/stores/posts'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const postsStore = usePostsStore()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const postId = route.params.id ? Number(route.params.id) : null
|
||||
const isEditing = ref(false)
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
excerpt: '',
|
||||
content: ''
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
if (postId) {
|
||||
isEditing.value = true
|
||||
// 尝试从 store 获取
|
||||
let post = postsStore.getPostById(postId)
|
||||
if (!post) {
|
||||
// 从 API 获取
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${postId}`)
|
||||
post = await res.json()
|
||||
} catch (e) {
|
||||
error.value = t('postEditor.loadFailed')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (post) {
|
||||
form.value = {
|
||||
title: post.title || '',
|
||||
date: post.date ? post.date.split('T')[0] : '',
|
||||
excerpt: post.excerpt || '',
|
||||
content: post.content || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function savePost() {
|
||||
if (!form.value.title || !form.value.date) {
|
||||
alert(t('postEditor.titleRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await postsStore.updatePost(postId, form.value)
|
||||
} else {
|
||||
await postsStore.createPost(form.value)
|
||||
}
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
error.value = t('postEditor.saveFailed')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="post-editor">
|
||||
<div class="editor-header">
|
||||
<h1>{{ isEditing ? t('postEditor.editTitle') : t('postEditor.newTitle') }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||
|
||||
<form @submit.prevent="savePost" class="editor-form">
|
||||
<div class="form-group">
|
||||
<label>{{ t('postEditor.title') }} *</label>
|
||||
<input v-model="form.title" type="text" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('postEditor.date') }} *</label>
|
||||
<input v-model="form.date" type="date" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('postEditor.excerpt') }}</label>
|
||||
<textarea v-model="form.excerpt" rows="2" :placeholder="t('postEditor.excerptPlaceholder')"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ t('postEditor.content') }}</label>
|
||||
<textarea v-model="form.content" rows="15" :placeholder="t('postEditor.contentPlaceholder')"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-cancel" @click="cancel">{{ t('postEditor.cancel') }}</button>
|
||||
<button type="submit" class="btn-save" :disabled="saving">
|
||||
{{ saving ? t('postEditor.saving') : t('postEditor.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.post-editor {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.editor-header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #ef4444;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.editor-form {
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-save {
|
||||
padding: 0.6rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-save:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
275
src/views/ServerConfigView.vue
Normal file
275
src/views/ServerConfigView.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const config = ref({
|
||||
db: { host: '', port: '', user: '', password: '', name: '' },
|
||||
rustfs: { endpoint: '', region: '', bucket: '', accessKey: '', secretKey: '' },
|
||||
server: { port: '' }
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const restarting = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/config')
|
||||
const data = await res.json()
|
||||
config.value = data
|
||||
} catch (e) {
|
||||
error.value = '加载配置失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
try {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config.value)
|
||||
})
|
||||
if (res.ok) {
|
||||
message.value = t('serverConfig.saved')
|
||||
setTimeout(() => { message.value = '' }, 3000)
|
||||
} else {
|
||||
error.value = t('serverConfig.saveFailed')
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = t('serverConfig.saveFailed')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
if (!confirm(t('serverConfig.confirmRestart'))) return
|
||||
|
||||
restarting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await fetch('/api/config/restart', { method: 'POST' })
|
||||
message.value = t('serverConfig.restarting')
|
||||
} catch (e) {
|
||||
error.value = t('serverConfig.restartFailed')
|
||||
} finally {
|
||||
setTimeout(() => { restarting.value = false }, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="server-config-page">
|
||||
<div class="page-header">
|
||||
<h1>{{ t('serverConfig.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">{{ t('serverConfig.loading') }}</div>
|
||||
|
||||
<div v-else-if="error" class="error-msg">{{ error }}</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="config-section">
|
||||
<h2>{{ t('serverConfig.database') }}</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>{{ t('serverConfig.dbHost') }}</label>
|
||||
<input type="text" v-model="config.db.host" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('serverConfig.dbPort') }}</label>
|
||||
<input type="text" v-model="config.db.port" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('serverConfig.dbUser') }}</label>
|
||||
<input type="text" v-model="config.db.user" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('serverConfig.dbPassword') }}</label>
|
||||
<input type="password" v-model="config.db.password" :placeholder="t('serverConfig.passwordPlaceholder')" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('serverConfig.dbName') }}</label>
|
||||
<input type="text" v-model="config.db.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h2>{{ t('serverConfig.rustfs') }}</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label>{{ t('serverConfig.rustfsEndpoint') }}</label>
|
||||
<input type="text" v-model="config.rustfs.endpoint" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('serverConfig.rustfsRegion') }}</label>
|
||||
<input type="text" v-model="config.rustfs.region" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('serverConfig.rustfsBucket') }}</label>
|
||||
<input type="text" v-model="config.rustfs.bucket" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('serverConfig.rustfsAccessKey') }}</label>
|
||||
<input type="text" v-model="config.rustfs.accessKey" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{{ t('serverConfig.rustfsSecretKey') }}</label>
|
||||
<input type="password" v-model="config.rustfs.secretKey" :placeholder="t('serverConfig.passwordPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-section">
|
||||
<h2>{{ t('serverConfig.server') }}</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>{{ t('serverConfig.serverPort') }}</label>
|
||||
<input type="text" v-model="config.server.port" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-save" @click="saveConfig" :disabled="saving">
|
||||
{{ saving ? t('serverConfig.saving') : t('serverConfig.save') }}
|
||||
</button>
|
||||
<button class="btn-restart" @click="restartServer" :disabled="restarting">
|
||||
{{ restarting ? t('serverConfig.restarting') : t('serverConfig.restart') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="message" class="success-msg">{{ message }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.server-config-page {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.config-section h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.form-group.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-save, .btn-restart {
|
||||
padding: 0.6rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-restart {
|
||||
background: #e67e22;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-save:hover, .btn-restart:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-save:disabled, .btn-restart:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading, .error-msg, .success-msg {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.success-msg {
|
||||
color: #27ae60;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
771
src/views/SettingsView.vue
Normal file
771
src/views/SettingsView.vue
Normal file
@@ -0,0 +1,771 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const settings = useSettingsStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const bgPresets = [
|
||||
{ color: '#f5f5f5', name: '浅灰' },
|
||||
{ color: '#e8f4f8', name: '淡蓝' },
|
||||
{ color: '#f0f0f0', name: '银灰' },
|
||||
{ color: '#fff8dc', name: '米黄' },
|
||||
{ color: '#e8f0e8', name: '淡绿' },
|
||||
{ color: '#fce4ec', name: '淡粉' }
|
||||
]
|
||||
|
||||
const bgImagePresets = [
|
||||
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=1920',
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1920',
|
||||
'https://images.unsplash.com/photo-1470071459604-3b5ec3a55120?w=1920',
|
||||
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=1920'
|
||||
]
|
||||
|
||||
const isUploading = ref(false)
|
||||
|
||||
function onBgTypeChange(value) {
|
||||
settings.setBgType(value)
|
||||
}
|
||||
|
||||
function onBgColorChange(value) {
|
||||
settings.setBgColor(value)
|
||||
}
|
||||
|
||||
function onBgOpacityChange(value) {
|
||||
settings.setBgOpacity(value)
|
||||
}
|
||||
|
||||
function onLanguageChange(value) {
|
||||
settings.setLanguage(value)
|
||||
}
|
||||
|
||||
function onBgImageUrlChange(event) {
|
||||
settings.setBgImage(event.target.value)
|
||||
}
|
||||
|
||||
function compressImage(file, maxSizeKB = 500) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
let width = img.width
|
||||
let height = img.height
|
||||
|
||||
const maxDimension = 1920
|
||||
if (width > maxDimension || height > maxDimension) {
|
||||
if (width > height) {
|
||||
height = (height / width) * maxDimension
|
||||
width = maxDimension
|
||||
} else {
|
||||
width = (width / height) * maxDimension
|
||||
height = maxDimension
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
let quality = 0.9
|
||||
let result = canvas.toDataURL('image/jpeg', quality)
|
||||
|
||||
while (result.length > maxSizeKB * 1024 && quality > 0.3) {
|
||||
quality -= 0.1
|
||||
result = canvas.toDataURL('image/jpeg', quality)
|
||||
}
|
||||
|
||||
if (result.length > maxSizeKB * 1024) {
|
||||
const scale = Math.sqrt((maxSizeKB * 1024) / result.length)
|
||||
canvas.width = width * scale
|
||||
canvas.height = height * scale
|
||||
const ctx2 = canvas.getContext('2d')
|
||||
ctx2.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
result = canvas.toDataURL('image/jpeg', 0.6)
|
||||
}
|
||||
|
||||
resolve(result)
|
||||
}
|
||||
img.onerror = reject
|
||||
img.src = e.target.result
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
function readFileAsDataURL(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => resolve(e.target.result)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleImageUpload(event) {
|
||||
const files = Array.from(event.target.files || [])
|
||||
if (files.length === 0) return
|
||||
|
||||
const invalidFiles = files.filter(f => !f.type.startsWith('image/'))
|
||||
if (invalidFiles.length > 0) {
|
||||
alert('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
const oversizedFiles = files.filter(f => f.size > 10 * 1024 * 1024)
|
||||
if (oversizedFiles.length > 0) {
|
||||
alert('图片大小不能超过10MB')
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
try {
|
||||
// 压缩所有图片
|
||||
const compressedImages = []
|
||||
for (const file of files) {
|
||||
const compressed = await compressImage(file, 500)
|
||||
compressedImages.push(compressed)
|
||||
}
|
||||
|
||||
// 批量上传到 RUSTFS
|
||||
await settings.addUploadedImagesBatch(compressedImages)
|
||||
|
||||
// 只将第一张设为背景
|
||||
if (compressedImages.length > 0) {
|
||||
await settings.setBgImageFromData(compressedImages[0])
|
||||
}
|
||||
} catch (error) {
|
||||
alert('图片上传失败')
|
||||
}
|
||||
isUploading.value = false
|
||||
}
|
||||
|
||||
function selectUploadedImage(url) {
|
||||
settings.setBgImage(url)
|
||||
}
|
||||
|
||||
function deleteUploadedImage(id) {
|
||||
if (confirm('确定要删除这张图片吗?')) {
|
||||
settings.removeUploadedImage(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIconUpload(event) {
|
||||
const files = Array.from(event.target.files || [])
|
||||
if (files.length === 0) return
|
||||
|
||||
const invalidFiles = files.filter(f => !f.type.startsWith('image/'))
|
||||
if (invalidFiles.length > 0) {
|
||||
alert('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
const oversizedFiles = files.filter(f => f.size > 512 * 1024)
|
||||
if (oversizedFiles.length > 0) {
|
||||
alert('图标大小不能超过512KB')
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
try {
|
||||
// 读取所有图标
|
||||
const iconDataList = []
|
||||
for (const file of files) {
|
||||
const data = await readFileAsDataURL(file)
|
||||
iconDataList.push(data)
|
||||
}
|
||||
|
||||
// 批量上传到 RUSTFS
|
||||
await settings.addUploadedIconsBatch(iconDataList)
|
||||
|
||||
// 只将第一个设为当前图标
|
||||
if (iconDataList.length > 0) {
|
||||
await settings.setFaviconFromData(iconDataList[0])
|
||||
}
|
||||
} catch (error) {
|
||||
alert('图标上传失败')
|
||||
}
|
||||
isUploading.value = false
|
||||
}
|
||||
|
||||
function selectUploadedIcon(url) {
|
||||
settings.setFavicon(url)
|
||||
}
|
||||
|
||||
function deleteUploadedIcon(id) {
|
||||
if (confirm('确定要删除这个图标吗?')) {
|
||||
settings.removeUploadedIcon(id)
|
||||
}
|
||||
}
|
||||
|
||||
function resetFavicon() {
|
||||
settings.setFavicon('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="settings-header">
|
||||
<h1>{{ t('settings.title') }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>{{ t('settings.language') }}</h2>
|
||||
<div class="setting-item">
|
||||
<label>{{ t('settings.interfaceLang') }}</label>
|
||||
<div class="language-switch">
|
||||
<button
|
||||
:class="{ active: settings.language === 'zh' }"
|
||||
@click="onLanguageChange('zh')"
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: settings.language === 'en' }"
|
||||
@click="onLanguageChange('en')"
|
||||
>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>{{ t('settings.siteIcon') }}</h2>
|
||||
<div class="setting-item">
|
||||
<div class="icon-preview">
|
||||
<img
|
||||
v-if="settings.favicon"
|
||||
:src="settings.favicon"
|
||||
class="icon-img"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
src="/favicon.ico"
|
||||
class="icon-img"
|
||||
/>
|
||||
<div class="icon-actions">
|
||||
<div class="upload-btn-wrapper">
|
||||
<button class="btn-upload-small">
|
||||
{{ t('settings.uploadIcon') }}
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
@change="handleIconUpload"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="settings.favicon"
|
||||
class="btn-reset"
|
||||
@click="resetFavicon"
|
||||
>
|
||||
{{ t('settings.defaultIcon') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="settings.uploadedIcons.length > 0" class="uploaded-list">
|
||||
<label>{{ t('settings.myIcons') }}</label>
|
||||
<div class="uploaded-items">
|
||||
<div
|
||||
v-for="icon in settings.uploadedIcons"
|
||||
:key="icon.id"
|
||||
class="uploaded-item"
|
||||
:class="{ selected: settings.favicon === icon.url }"
|
||||
>
|
||||
<img :src="icon.url" @click="selectUploadedIcon(icon.url)" />
|
||||
<button class="btn-delete" @click="deleteUploadedIcon(icon.id)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>{{ t('settings.background') }}</h2>
|
||||
|
||||
<div class="setting-item">
|
||||
<label>{{ t('settings.bgType') }}</label>
|
||||
<div class="bg-type-switch">
|
||||
<button
|
||||
:class="{ active: settings.bgType === 'color' }"
|
||||
@click="onBgTypeChange('color')"
|
||||
>
|
||||
{{ t('settings.solid') }}
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: settings.bgType === 'image' }"
|
||||
@click="onBgTypeChange('image')"
|
||||
>
|
||||
{{ t('settings.image') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.bgType === 'color'" class="setting-item">
|
||||
<label>{{ t('settings.bgColor') }}</label>
|
||||
<div class="color-presets">
|
||||
<div
|
||||
v-for="preset in bgPresets"
|
||||
:key="preset.color"
|
||||
class="color-preset"
|
||||
:style="{ backgroundColor: preset.color }"
|
||||
:class="{ selected: settings.bgColor === preset.color }"
|
||||
@click="onBgColorChange(preset.color)"
|
||||
:title="preset.name"
|
||||
></div>
|
||||
</div>
|
||||
<div class="color-input">
|
||||
<input
|
||||
type="color"
|
||||
:value="settings.bgColor"
|
||||
@input="onBgColorChange($event.target.value)"
|
||||
/>
|
||||
<span>{{ settings.bgColor }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.bgType === 'image'" class="setting-item">
|
||||
<label>{{ t('settings.bgImage') }}</label>
|
||||
<div class="image-presets">
|
||||
<div
|
||||
v-for="(img, index) in bgImagePresets"
|
||||
:key="index"
|
||||
class="image-preset"
|
||||
:style="{ backgroundImage: `url(${img})` }"
|
||||
:class="{ selected: settings.bgImage === img }"
|
||||
@click="settings.setBgImage(img)"
|
||||
></div>
|
||||
</div>
|
||||
<div class="image-upload-area">
|
||||
<div class="upload-btn-wrapper">
|
||||
<button class="btn-upload" :disabled="isUploading">
|
||||
{{ isUploading ? t('settings.uploading') : t('settings.uploadImage') }}
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
@change="handleImageUpload"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-input">
|
||||
<input
|
||||
type="text"
|
||||
:value="settings.bgImage"
|
||||
@input="onBgImageUrlChange"
|
||||
:placeholder="t('settings.imageUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.bgType === 'image' && settings.uploadedImages.length > 0" class="uploaded-list">
|
||||
<label>{{ t('settings.myImages') }}</label>
|
||||
<div class="uploaded-items uploaded-images">
|
||||
<div
|
||||
v-for="img in settings.uploadedImages"
|
||||
:key="img.id"
|
||||
class="uploaded-item"
|
||||
:class="{ selected: settings.bgImage === img.url }"
|
||||
>
|
||||
<div
|
||||
class="uploaded-bg-preview"
|
||||
:style="{ backgroundImage: `url(${img.url})` }"
|
||||
@click="selectUploadedImage(img.url)"
|
||||
></div>
|
||||
<button class="btn-delete" @click="deleteUploadedImage(img.id)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<label>{{ t('settings.opacity') }}: {{ Math.round(settings.bgOpacity * 100) }}%</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
:value="settings.bgOpacity"
|
||||
@input="onBgOpacityChange($event.target.value)"
|
||||
class="opacity-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<label>{{ t('settings.preview') }}</label>
|
||||
<div
|
||||
class="preview-box"
|
||||
:style="{
|
||||
backgroundColor: settings.bgType === 'color' ? settings.bgColor : 'transparent',
|
||||
backgroundImage: settings.bgType === 'image' && settings.bgImage ? `url(${settings.bgImage})` : 'none',
|
||||
opacity: settings.bgOpacity
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.settings-section h2 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.setting-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.setting-item > label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.language-switch,
|
||||
.bg-type-switch {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.language-switch button,
|
||||
.bg-type-switch button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.language-switch button:hover,
|
||||
.bg-type-switch button:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.language-switch button.active,
|
||||
.bg-type-switch button.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.color-presets {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.color-preset {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.color-preset:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.color-preset.selected {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary);
|
||||
}
|
||||
|
||||
.color-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-input input[type="color"] {
|
||||
width: 50px;
|
||||
height: 35px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-input span {
|
||||
font-family: monospace;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.image-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preset {
|
||||
height: 60px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.image-preset:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.image-preset.selected {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary);
|
||||
}
|
||||
|
||||
.image-upload-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-btn-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-upload:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-upload:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.upload-btn-wrapper input[type="file"] {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.uploaded-list {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.uploaded-list > label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.uploaded-items {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.uploaded-images .uploaded-item {
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.uploaded-item {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.uploaded-item:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.uploaded-item.selected {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px var(--color-primary);
|
||||
}
|
||||
|
||||
.uploaded-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.uploaded-bg-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: rgba(239, 68, 68, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0 4px 0 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.uploaded-item:hover .btn-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-input input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.image-input input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.opacity-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-border);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.opacity-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.icon-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.icon-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
object-fit: contain;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.icon-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-upload-small {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-upload-small:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background: var(--color-border);
|
||||
color: var(--color-text);
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-reset:hover {
|
||||
background: #ccc;
|
||||
}
|
||||
</style>
|
||||
36
vite.config.js
Normal file
36
vite.config.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// Vite 配置
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 加载环境变量
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// 本地开发模式:VITE_SERVER_URL 为空或本地地址时使用代理
|
||||
target: env.VITE_SERVER_URL || `http://localhost:${env.VITE_API_PORT || '3000'}`,
|
||||
changeOrigin: true,
|
||||
// 仅远程部署时改变 origin
|
||||
configure: (proxy) => {
|
||||
if (env.VITE_SERVER_URL && !env.VITE_SERVER_URL.includes('localhost')) {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
proxyReq.setHeader('origin', env.VITE_SERVER_URL)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user