This commit is contained in:
dcr_xuxgc
2026-06-12 17:49:54 +08:00
commit d759a9e740
69 changed files with 14243 additions and 0 deletions

View 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
View File

@@ -0,0 +1,8 @@
# 本地开发环境配置
# npm run dev 使用此配置mode=development
# 后端端口
VITE_API_PORT=4501
# 留空,使用本地代理模式
VITE_SERVER_URL=

7
.env.development Normal file
View File

@@ -0,0 +1,7 @@
# 本地开发环境配置
# 后端端口
VITE_API_PORT=4501
# 留空,使用本地代理模式
VITE_SERVER_URL=

8
.env.example Normal file
View 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
View File

@@ -0,0 +1,7 @@
# 远程生产环境配置
# 后端端口
VITE_API_PORT=4501
# 远程服务器地址(直接连接,不走代理)
VITE_SERVER_URL=http://43.156.91.115:4501

27
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

17
server/.env Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

193
server/package.js Normal file
View 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
View 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
View 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
View 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

View 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

Binary file not shown.

View File

@@ -0,0 +1,4 @@
#!/bin/bash
# JNote 后端服务启动脚本
cd "$(dirname "$0")"
./jnote-api

View 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

View 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

Binary file not shown.

View File

@@ -0,0 +1,5 @@
@echo off
:: JNote 后端服务启动脚本
cd /d "%~dp0"
jnote-api.exe
pause

View 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
};

View 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 };

View 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
};

View 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小团队推荐使用简化 GitFlowmain + 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
View 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
View 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
View 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();

View 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;

View 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
View 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
View 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;

View 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;

View 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();

View 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();

View 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小团队推荐使用简化 GitFlowmain + 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();

View 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();

View File

@@ -0,0 +1,115 @@
/**
* 数据存储结构迁移脚本
* 将 posts.content 升级为 MEDIUMTEXTabout 和 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
View 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
View 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
View 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
View 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

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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">&times;</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
View 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
View 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>

View 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>

View 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
View 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)">&times;</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)">&times;</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
View 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)
})
}
}
}
}
}
}
})