70 changed files with 10211 additions and 0 deletions
@ -0,0 +1,10 @@ |
|||
node_modules/ |
|||
dist/ |
|||
*.log |
|||
.env |
|||
.DS_Store |
|||
database/*.sqlite3 |
|||
uploads/ |
|||
.vscode/ |
|||
.idea/ |
|||
|
|||
@ -0,0 +1,55 @@ |
|||
# 前端简易部署项目 |
|||
|
|||
一个基于 Koa3 和 Vue3 的前端网页部署平台,支持上传单个 HTML 文件或完整项目文件夹,提供沙盒预览、文档管理、用户权限控制等功能。 |
|||
|
|||
## 技术栈 |
|||
|
|||
- 后端:Koa3 + TypeScript |
|||
- 前端:Vue3 + TypeScript + Vite |
|||
- 数据库:SQLite + Knex.js |
|||
- 包管理:pnpm |
|||
|
|||
## 快速开始 |
|||
|
|||
### 安装依赖 |
|||
|
|||
```bash |
|||
pnpm install:all |
|||
``` |
|||
|
|||
### 开发 |
|||
|
|||
```bash |
|||
# 同时启动前后端 |
|||
pnpm dev |
|||
|
|||
# 或分别启动 |
|||
pnpm dev:backend # 后端:http://localhost:3000 |
|||
pnpm dev:frontend # 前端:http://localhost:5173 |
|||
``` |
|||
|
|||
### 构建 |
|||
|
|||
```bash |
|||
pnpm build |
|||
``` |
|||
|
|||
## 项目结构 |
|||
|
|||
``` |
|||
just-demo/ |
|||
├── backend/ # 后端服务 |
|||
├── frontend/ # 前端应用 |
|||
├── database/ # 数据库文件 |
|||
└── package.json # 根配置 |
|||
``` |
|||
|
|||
## 功能特性 |
|||
|
|||
- 用户认证(注册、登录) |
|||
- 网页上传(单个HTML或ZIP文件夹) |
|||
- 项目展示和筛选 |
|||
- 沙盒预览 |
|||
- 文档管理 |
|||
- 权限控制 |
|||
|
|||
@ -0,0 +1,177 @@ |
|||
# 项目设置指南 |
|||
|
|||
## 环境要求 |
|||
|
|||
- Node.js >= 16 |
|||
- pnpm >= 8 |
|||
|
|||
## 安装步骤 |
|||
|
|||
### 1. 安装所有依赖 |
|||
|
|||
```bash |
|||
pnpm install:all |
|||
``` |
|||
|
|||
或者分别安装: |
|||
|
|||
```bash |
|||
# 根目录 |
|||
pnpm install |
|||
|
|||
# 后端 |
|||
cd backend |
|||
pnpm install |
|||
|
|||
# 前端 |
|||
cd ../frontend |
|||
pnpm install |
|||
``` |
|||
|
|||
### 2. 初始化数据库 |
|||
|
|||
```bash |
|||
cd backend |
|||
pnpm run migrate |
|||
``` |
|||
|
|||
这将创建所有必要的数据库表并插入默认设置。 |
|||
|
|||
### 3. 启动开发服务器 |
|||
|
|||
在项目根目录运行: |
|||
|
|||
```bash |
|||
pnpm dev |
|||
``` |
|||
|
|||
这将同时启动: |
|||
- 后端服务:http://localhost:3000 |
|||
- 前端服务:http://localhost:5173 |
|||
|
|||
或者分别启动: |
|||
|
|||
```bash |
|||
# 后端 |
|||
pnpm dev:backend |
|||
|
|||
# 前端(新终端) |
|||
pnpm dev:frontend |
|||
``` |
|||
|
|||
## 项目结构 |
|||
|
|||
``` |
|||
just-demo/ |
|||
├── backend/ # 后端服务 |
|||
│ ├── src/ |
|||
│ │ ├── controllers/ # 控制器 |
|||
│ │ ├── models/ # 数据模型 |
|||
│ │ ├── routes/ # 路由 |
|||
│ │ ├── middleware/ # 中间件 |
|||
│ │ ├── utils/ # 工具函数 |
|||
│ │ ├── config/ # 配置文件 |
|||
│ │ └── migrations/ # 数据库迁移 |
|||
│ ├── uploads/ # 上传文件存储 |
|||
│ └── package.json |
|||
├── frontend/ # 前端应用 |
|||
│ ├── src/ |
|||
│ │ ├── views/ # 页面组件 |
|||
│ │ ├── components/ # 通用组件 |
|||
│ │ ├── router/ # 路由配置 |
|||
│ │ ├── store/ # 状态管理 |
|||
│ │ ├── api/ # API接口 |
|||
│ │ └── utils/ # 工具函数 |
|||
│ └── package.json |
|||
├── database/ # 数据库文件 |
|||
└── package.json # 根配置 |
|||
``` |
|||
|
|||
## 功能特性 |
|||
|
|||
### 已实现功能 |
|||
|
|||
1. **用户认证系统** |
|||
- 用户注册(第一个注册的为超级管理员) |
|||
- 用户登录(JWT token认证) |
|||
- 用户信息获取 |
|||
|
|||
2. **项目管理** |
|||
- 上传单个HTML文件或ZIP压缩包 |
|||
- 项目列表展示(网格布局) |
|||
- 项目筛选(分类、标签) |
|||
- 项目搜索 |
|||
- 项目详情查看 |
|||
- 项目删除(仅创建者或管理员) |
|||
|
|||
3. **文件管理** |
|||
- 附件上传和下载 |
|||
- Markdown文档上传和查看 |
|||
- 静态资源服务 |
|||
|
|||
4. **预览功能** |
|||
- iframe沙盒预览 |
|||
- 项目信息抽屉 |
|||
- 文档查看器 |
|||
|
|||
5. **系统设置** |
|||
- 允许/禁止注册 |
|||
- 允许/禁止上传 |
|||
- 仅超级管理员可修改 |
|||
|
|||
6. **安全措施** |
|||
- 文件类型验证 |
|||
- 文件大小限制 |
|||
- 路径遍历防护 |
|||
- XSS防护(CSP头) |
|||
- JWT认证 |
|||
- 密码加密(bcrypt) |
|||
- 权限验证 |
|||
|
|||
## API端点 |
|||
|
|||
### 认证 |
|||
- `POST /api/auth/register` - 注册 |
|||
- `POST /api/auth/login` - 登录 |
|||
- `POST /api/auth/logout` - 登出 |
|||
- `GET /api/auth/me` - 获取当前用户信息 |
|||
|
|||
### 项目 |
|||
- `GET /api/projects` - 获取项目列表 |
|||
- `GET /api/projects/:id` - 获取项目详情 |
|||
- `POST /api/projects` - 上传项目(需要登录) |
|||
- `DELETE /api/projects/:id` - 删除项目(需要登录) |
|||
- `GET /api/projects/:id/preview` - 预览项目 |
|||
|
|||
### 文件 |
|||
- `GET /api/files/:projectId/*` - 获取项目文件 |
|||
- `GET /api/files/attachment/:id` - 下载附件 |
|||
- `GET /api/files/document/:id` - 获取文档 |
|||
|
|||
### 设置 |
|||
- `GET /api/settings` - 获取所有设置(需要管理员权限) |
|||
- `PUT /api/settings/:key` - 更新设置(需要超级管理员权限) |
|||
|
|||
## 数据库表结构 |
|||
|
|||
- `users` - 用户表 |
|||
- `projects` - 项目表 |
|||
- `attachments` - 附件表 |
|||
- `documents` - 文档表 |
|||
- `settings` - 设置表 |
|||
|
|||
## 注意事项 |
|||
|
|||
1. 首次注册的用户自动成为超级管理员 |
|||
2. 上传的文件存储在 `backend/uploads/` 目录 |
|||
3. 数据库文件存储在 `database/` 目录 |
|||
4. 生产环境请修改 `backend/src/config/config.ts` 中的 `jwtSecret` |
|||
5. 文件大小限制:单个HTML文件10MB,ZIP文件50MB |
|||
|
|||
## 开发说明 |
|||
|
|||
- 后端使用 TypeScript + Koa3 |
|||
- 前端使用 TypeScript + Vue3 + Vite |
|||
- 数据库使用 SQLite + Knex.js |
|||
- 包管理使用 pnpm |
|||
|
|||
@ -0,0 +1,7 @@ |
|||
node_modules/ |
|||
dist/ |
|||
uploads/ |
|||
*.log |
|||
.env |
|||
.DS_Store |
|||
|
|||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,42 @@ |
|||
const path = require('path'); |
|||
|
|||
// 根据环境选择迁移文件目录
|
|||
// 开发模式使用 TypeScript 源文件(通过 tsx 运行)
|
|||
// 生产模式使用编译后的 JavaScript 文件
|
|||
const migrationsDir = process.env.NODE_ENV === 'production' |
|||
? path.join(__dirname, 'dist/migrations') |
|||
: path.join(__dirname, 'src/migrations'); |
|||
|
|||
module.exports = { |
|||
development: { |
|||
client: 'sqlite3', |
|||
connection: { |
|||
filename: path.join(__dirname, './database/dev.sqlite3') |
|||
}, |
|||
migrations: { |
|||
directory: migrationsDir |
|||
}, |
|||
useNullAsDefault: true, |
|||
pool: { |
|||
afterCreate: (conn, cb) => { |
|||
conn.run('PRAGMA foreign_keys = ON', cb); |
|||
} |
|||
} |
|||
}, |
|||
production: { |
|||
client: 'sqlite3', |
|||
connection: { |
|||
filename: path.join(__dirname, './database/prod.sqlite3') |
|||
}, |
|||
migrations: { |
|||
directory: migrationsDir |
|||
}, |
|||
useNullAsDefault: true, |
|||
pool: { |
|||
afterCreate: (conn, cb) => { |
|||
conn.run('PRAGMA foreign_keys = ON', cb); |
|||
} |
|||
} |
|||
} |
|||
}; |
|||
|
|||
@ -0,0 +1,49 @@ |
|||
{ |
|||
"name": "just-demo-backend", |
|||
"version": "1.0.0", |
|||
"description": "后端服务", |
|||
"main": "dist/app.js", |
|||
"scripts": { |
|||
"postinstall": "node scripts/fix-generator.js", |
|||
"dev": "tsx watch src/start.ts", |
|||
"build": "tsc", |
|||
"start": "node dist/app.js", |
|||
"start:p": "node -r dotenv/config dist/app.js", |
|||
"start:prod": "cross-env NODE_ENV=production node dist/app.js", |
|||
"migrate": "tsx node_modules/.bin/knex migrate:latest", |
|||
"migrate:prod": "cross-env NODE_ENV=production knex migrate:latest", |
|||
"migrate:rollback": "tsx node_modules/.bin/knex migrate:rollback", |
|||
"migrate:rollback:prod": "cross-env NODE_ENV=production knex migrate:rollback", |
|||
"migrate:make": "tsx node_modules/.bin/knex migrate:make" |
|||
}, |
|||
"dependencies": { |
|||
"@koa/cors": "^5.0.0", |
|||
"@koa/multer": "^4.0.0", |
|||
"@koa/router": "^12.0.1", |
|||
"bcrypt": "^5.1.1", |
|||
"dotenv": "^17.2.3", |
|||
"is-generator-function": "^1.1.2", |
|||
"jsonwebtoken": "^9.0.2", |
|||
"knex": "^3.0.1", |
|||
"koa": "^2.16.3", |
|||
"koa-bodyparser": "^4.4.1", |
|||
"koa-static": "^5.0.0", |
|||
"sqlite3": "^5.1.6", |
|||
"yauzl": "^2.10.0" |
|||
}, |
|||
"devDependencies": { |
|||
"@types/bcrypt": "^5.0.2", |
|||
"@types/jsonwebtoken": "^9.0.2", |
|||
"@types/koa": "^2.14.0", |
|||
"@types/koa-bodyparser": "^4.3.12", |
|||
"@types/koa-static": "^4.0.4", |
|||
"@types/koa__cors": "^5.0.1", |
|||
"@types/koa__multer": "^2.0.8", |
|||
"@types/koa__router": "^12.0.5", |
|||
"@types/node": "^20.10.6", |
|||
"@types/yauzl": "^2.10.3", |
|||
"cross-env": "^7.0.3", |
|||
"tsx": "^4.7.0", |
|||
"typescript": "^5.3.3" |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,31 @@ |
|||
// 修复 is-generator-function 的 Node.js 22 兼容性问题
|
|||
const fs = require('fs'); |
|||
const path = require('path'); |
|||
|
|||
const indexPath = path.join(__dirname, '../node_modules/.pnpm/is-generator-function@1.1.2/node_modules/is-generator-function/index.js'); |
|||
|
|||
if (fs.existsSync(indexPath)) { |
|||
let content = fs.readFileSync(indexPath, 'utf8'); |
|||
|
|||
// 替换有问题的 getGeneratorFunction 调用
|
|||
const fixedContent = content.replace( |
|||
/var GeneratorFunction = getGeneratorFunction\(\);/g, |
|||
`var GeneratorFunction;
|
|||
try { |
|||
GeneratorFunction = getGeneratorFunction(); |
|||
} catch (e) { |
|||
// Node.js 22 兼容性修复
|
|||
try { |
|||
GeneratorFunction = (function*() {}).constructor; |
|||
} catch (e2) { |
|||
GeneratorFunction = Object.getPrototypeOf(function*() {}).constructor; |
|||
} |
|||
}` |
|||
); |
|||
|
|||
fs.writeFileSync(indexPath, fixedContent, 'utf8'); |
|||
console.log('✅ 已修复 is-generator-function 兼容性问题'); |
|||
} else { |
|||
console.log('⚠️ 未找到 is-generator-function 文件,可能需要先安装依赖'); |
|||
} |
|||
|
|||
@ -0,0 +1,51 @@ |
|||
import Koa from 'koa'; |
|||
import cors from '@koa/cors'; |
|||
import bodyParser from 'koa-bodyparser'; |
|||
import serve from 'koa-static'; |
|||
import path from 'path'; |
|||
import { router } from './routes'; |
|||
import { errorHandler } from './middleware/errorHandler'; |
|||
import config from './config/config'; |
|||
|
|||
const app = new Koa(); |
|||
|
|||
// 错误处理
|
|||
app.use(errorHandler); |
|||
|
|||
// CORS配置
|
|||
app.use(cors({ |
|||
origin: config.frontendUrl, |
|||
credentials: true |
|||
})); |
|||
|
|||
// Body解析
|
|||
app.use(bodyParser()); |
|||
|
|||
// 静态文件服务(上传的文件)
|
|||
app.use(serve(path.join(__dirname, '../uploads'))); |
|||
|
|||
// 路由
|
|||
app.use(router.routes()).use(router.allowedMethods()); |
|||
|
|||
// 错误事件监听
|
|||
app.on('error', (err, ctx) => { |
|||
if (config.isProduction) { |
|||
// 生产模式下记录错误但不输出到控制台
|
|||
// 可以在这里集成日志服务(如 Winston、Pino 等)
|
|||
} else { |
|||
console.error('Application error:', err); |
|||
} |
|||
}); |
|||
|
|||
const PORT = config.port; |
|||
|
|||
app.listen(PORT, () => { |
|||
console.log(`🚀 后端服务运行在 http://localhost:${PORT}`); |
|||
console.log(`📦 环境: ${config.env}`); |
|||
if (config.isProduction) { |
|||
console.log('🔒 生产模式已启用'); |
|||
} |
|||
}); |
|||
|
|||
export default app; |
|||
|
|||
@ -0,0 +1,14 @@ |
|||
const isProduction = process.env.NODE_ENV === 'production'; |
|||
|
|||
export default { |
|||
env: process.env.NODE_ENV || 'development', |
|||
isProduction, |
|||
port: process.env.PORT || 3000, |
|||
jwtSecret: process.env.JWT_SECRET || (isProduction ? '' : 'your-secret-key-change-in-production'), |
|||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d', |
|||
uploadDir: process.env.UPLOAD_DIR || 'uploads', |
|||
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|||
maxZipSize: 50 * 1024 * 1024, // 50MB
|
|||
frontendUrl: process.env.FRONTEND_URL || (isProduction ? 'http://localhost:5500' : 'http://localhost:5173') |
|||
}; |
|||
|
|||
@ -0,0 +1,30 @@ |
|||
import knex from 'knex'; |
|||
import path from 'path'; |
|||
import fs from 'fs'; |
|||
import config from './config'; |
|||
|
|||
// 确保数据库目录存在
|
|||
const dbDir = path.join(process.cwd(), './database'); |
|||
if (!fs.existsSync(dbDir)) { |
|||
fs.mkdirSync(dbDir, { recursive: true }); |
|||
} |
|||
|
|||
// 根据环境选择数据库文件
|
|||
const dbFileName = config.isProduction ? 'prod.sqlite3' : 'dev.sqlite3'; |
|||
const dbPath = path.join(dbDir, dbFileName); |
|||
|
|||
const db = knex({ |
|||
client: 'sqlite3', |
|||
connection: { |
|||
filename: dbPath |
|||
}, |
|||
useNullAsDefault: true, |
|||
pool: { |
|||
afterCreate: (conn: any, cb: any) => { |
|||
conn.run('PRAGMA foreign_keys = ON', cb); |
|||
} |
|||
} |
|||
}); |
|||
|
|||
export default db; |
|||
|
|||
@ -0,0 +1,131 @@ |
|||
import { Context } from 'koa'; |
|||
import { UserModel } from '../models/User'; |
|||
import { SettingModel } from '../models/Setting'; |
|||
import { hashPassword, comparePassword } from '../utils/password'; |
|||
import { generateToken } from '../utils/jwt'; |
|||
import { AuthContext } from '../middleware/auth'; |
|||
|
|||
export class AuthController { |
|||
static async register(ctx: Context) { |
|||
const { username, password, email } = ctx.request.body as any; |
|||
|
|||
// 验证输入
|
|||
if (!username || !password) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '用户名和密码不能为空', code: 'VALIDATION_ERROR' } }; |
|||
return; |
|||
} |
|||
|
|||
// 检查是否允许注册
|
|||
const allowRegister = await SettingModel.getBoolean('allow_register'); |
|||
if (!allowRegister) { |
|||
ctx.status = 403; |
|||
ctx.body = { error: { message: '注册功能已关闭', code: 'REGISTRATION_DISABLED' } }; |
|||
return; |
|||
} |
|||
|
|||
// 检查用户名是否已存在
|
|||
const existingUser = await UserModel.findByUsername(username); |
|||
if (existingUser) { |
|||
ctx.status = 409; |
|||
ctx.body = { error: { message: '用户名已存在', code: 'USER_EXISTS' } }; |
|||
return; |
|||
} |
|||
|
|||
// 判断是否为第一个用户(超级管理员)
|
|||
const isFirstUser = await UserModel.isFirstUser(); |
|||
const role = isFirstUser ? 'super_admin' : 'user'; |
|||
|
|||
// 创建用户
|
|||
const hashedPassword = await hashPassword(password); |
|||
const user = await UserModel.create({ |
|||
username, |
|||
password: hashedPassword, |
|||
email, |
|||
role |
|||
}); |
|||
|
|||
// 生成token
|
|||
const token = generateToken({ |
|||
userId: user.id, |
|||
username: user.username, |
|||
role: user.role |
|||
}); |
|||
|
|||
ctx.body = { |
|||
user: { |
|||
id: user.id, |
|||
username: user.username, |
|||
email: user.email, |
|||
role: user.role |
|||
}, |
|||
token |
|||
}; |
|||
} |
|||
|
|||
static async login(ctx: Context) { |
|||
const { username, password } = ctx.request.body as any; |
|||
|
|||
if (!username || !password) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '用户名和密码不能为空', code: 'VALIDATION_ERROR' } }; |
|||
return; |
|||
} |
|||
|
|||
// 查找用户
|
|||
const user = await UserModel.findByUsername(username); |
|||
if (!user) { |
|||
ctx.status = 401; |
|||
ctx.body = { error: { message: '用户名或密码错误', code: 'INVALID_CREDENTIALS' } }; |
|||
return; |
|||
} |
|||
|
|||
// 验证密码
|
|||
const isValid = await comparePassword(password, user.password); |
|||
if (!isValid) { |
|||
ctx.status = 401; |
|||
ctx.body = { error: { message: '用户名或密码错误', code: 'INVALID_CREDENTIALS' } }; |
|||
return; |
|||
} |
|||
|
|||
// 生成token
|
|||
const token = generateToken({ |
|||
userId: user.id, |
|||
username: user.username, |
|||
role: user.role |
|||
}); |
|||
|
|||
ctx.body = { |
|||
user: { |
|||
id: user.id, |
|||
username: user.username, |
|||
email: user.email, |
|||
role: user.role |
|||
}, |
|||
token |
|||
}; |
|||
} |
|||
|
|||
static async logout(ctx: Context) { |
|||
// JWT是无状态的,客户端删除token即可
|
|||
ctx.body = { message: '登出成功' }; |
|||
} |
|||
|
|||
static async getMe(ctx: AuthContext) { |
|||
if (!ctx.user) { |
|||
ctx.status = 401; |
|||
ctx.body = { error: { message: '未认证', code: 'UNAUTHORIZED' } }; |
|||
return; |
|||
} |
|||
|
|||
ctx.body = { |
|||
user: { |
|||
id: ctx.user.id, |
|||
username: ctx.user.username, |
|||
email: ctx.user.email, |
|||
role: ctx.user.role |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,149 @@ |
|||
import { Context } from 'koa'; |
|||
import path from 'path'; |
|||
import fs from 'fs/promises'; |
|||
import { AttachmentModel } from '../models/Attachment'; |
|||
import { DocumentModel } from '../models/Document'; |
|||
import { ProjectModel } from '../models/Project'; |
|||
import { sanitizeFileName, toAbsolutePath } from '../utils/fileUtils'; |
|||
|
|||
export class FileController { |
|||
// 获取项目文件(静态资源)
|
|||
static async getProjectFile(ctx: Context) { |
|||
const projectId = parseInt(ctx.params.projectId); |
|||
if (isNaN(projectId)) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '无效的项目ID', code: 'INVALID_ID' } }; |
|||
return; |
|||
} |
|||
|
|||
const project = await ProjectModel.findById(projectId); |
|||
if (!project) { |
|||
ctx.status = 404; |
|||
ctx.body = { error: { message: '项目不存在', code: 'PROJECT_NOT_FOUND' } }; |
|||
return; |
|||
} |
|||
|
|||
// 将相对路径转换为绝对路径
|
|||
const absoluteProjectPath = toAbsolutePath(project.file_path); |
|||
|
|||
// 获取请求的文件路径(Koa路由参数)
|
|||
const filePath = ctx.params.path || ''; |
|||
|
|||
// 清理路径,防止路径遍历攻击
|
|||
// 移除开头的 / 和 ../
|
|||
let cleanPath = filePath.replace(/^\/+/, '').replace(/\.\./g, ''); |
|||
|
|||
// 构建完整路径
|
|||
let fullPath: string; |
|||
if (project.file_type === 'single_html') { |
|||
// 单个HTML文件,返回项目目录下的文件
|
|||
fullPath = path.join(path.dirname(absoluteProjectPath), cleanPath); |
|||
} else { |
|||
// 文件夹类型
|
|||
fullPath = path.join(absoluteProjectPath, cleanPath); |
|||
} |
|||
|
|||
// 验证路径安全性(确保在项目目录内)
|
|||
const projectDir = project.file_type === 'single_html' |
|||
? path.dirname(absoluteProjectPath) |
|||
: absoluteProjectPath; |
|||
|
|||
const resolvedPath = path.resolve(fullPath); |
|||
const resolvedProjectDir = path.resolve(projectDir); |
|||
|
|||
if (!resolvedPath.startsWith(resolvedProjectDir)) { |
|||
ctx.status = 403; |
|||
ctx.body = { error: { message: '无权访问此文件', code: 'FORBIDDEN' } }; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
const stats = await fs.stat(fullPath); |
|||
if (stats.isDirectory()) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '不能访问目录', code: 'IS_DIRECTORY' } }; |
|||
return; |
|||
} |
|||
|
|||
// 设置适当的Content-Type
|
|||
const ext = path.extname(fullPath).toLowerCase(); |
|||
const contentTypes: Record<string, string> = { |
|||
'.html': 'text/html', |
|||
'.htm': 'text/html', |
|||
'.css': 'text/css', |
|||
'.js': 'application/javascript', |
|||
'.json': 'application/json', |
|||
'.png': 'image/png', |
|||
'.jpg': 'image/jpeg', |
|||
'.jpeg': 'image/jpeg', |
|||
'.gif': 'image/gif', |
|||
'.svg': 'image/svg+xml', |
|||
'.woff': 'font/woff', |
|||
'.woff2': 'font/woff2', |
|||
'.ttf': 'font/ttf', |
|||
'.eot': 'application/vnd.ms-fontobject' |
|||
}; |
|||
|
|||
ctx.set('Content-Type', contentTypes[ext] || 'application/octet-stream'); |
|||
ctx.body = await fs.readFile(fullPath); |
|||
} catch (error: any) { |
|||
if (error.code === 'ENOENT') { |
|||
ctx.status = 404; |
|||
ctx.body = { error: { message: '文件不存在', code: 'FILE_NOT_FOUND' } }; |
|||
} else { |
|||
ctx.status = 500; |
|||
ctx.body = { error: { message: '读取文件失败', code: 'READ_ERROR' } }; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 下载附件
|
|||
static async downloadAttachment(ctx: Context) { |
|||
const id = parseInt(ctx.params.id); |
|||
if (isNaN(id)) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '无效的附件ID', code: 'INVALID_ID' } }; |
|||
return; |
|||
} |
|||
|
|||
const attachment = await AttachmentModel.findById(id); |
|||
if (!attachment) { |
|||
ctx.status = 404; |
|||
ctx.body = { error: { message: '附件不存在', code: 'ATTACHMENT_NOT_FOUND' } }; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
// 将相对路径转换为绝对路径
|
|||
const absolutePath = toAbsolutePath(attachment.file_path); |
|||
const stats = await fs.stat(absolutePath); |
|||
ctx.set('Content-Type', 'application/octet-stream'); |
|||
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(attachment.file_name)}"`); |
|||
ctx.set('Content-Length', stats.size.toString()); |
|||
ctx.body = await fs.readFile(absolutePath); |
|||
} catch (error: any) { |
|||
ctx.status = 500; |
|||
ctx.body = { error: { message: '下载失败', code: 'DOWNLOAD_ERROR' } }; |
|||
} |
|||
} |
|||
|
|||
// 获取文档内容
|
|||
static async getDocument(ctx: Context) { |
|||
const id = parseInt(ctx.params.id); |
|||
if (isNaN(id)) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '无效的文档ID', code: 'INVALID_ID' } }; |
|||
return; |
|||
} |
|||
|
|||
const document = await DocumentModel.findById(id); |
|||
if (!document) { |
|||
ctx.status = 404; |
|||
ctx.body = { error: { message: '文档不存在', code: 'DOCUMENT_NOT_FOUND' } }; |
|||
return; |
|||
} |
|||
|
|||
ctx.body = document; |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,404 @@ |
|||
import { Context } from 'koa'; |
|||
import path from 'path'; |
|||
import fs from 'fs/promises'; |
|||
import { ProjectModel, ProjectQuery } from '../models/Project'; |
|||
import { AttachmentModel } from '../models/Attachment'; |
|||
import { DocumentModel } from '../models/Document'; |
|||
import { SettingModel } from '../models/Setting'; |
|||
import { extractZip, findHtmlEntry, removeDirectory, sanitizeFileName, rewriteHtmlResourcePaths, toRelativePath, toAbsolutePath } from '../utils/fileUtils'; |
|||
import { AuthContext } from '../middleware/auth'; |
|||
import config from '../config/config'; |
|||
|
|||
export class ProjectController { |
|||
static async list(ctx: Context) { |
|||
const query: ProjectQuery = { |
|||
category: ctx.query.category as string, |
|||
tag: ctx.query.tag as string, |
|||
search: ctx.query.search as string, |
|||
page: parseInt(ctx.query.page as string) || 1, |
|||
limit: parseInt(ctx.query.limit as string) || 20 |
|||
}; |
|||
|
|||
const result = await ProjectModel.findMany(query); |
|||
ctx.body = result; |
|||
} |
|||
|
|||
static async getById(ctx: Context) { |
|||
const id = parseInt(ctx.params.id); |
|||
if (isNaN(id)) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '无效的项目ID', code: 'INVALID_ID' } }; |
|||
return; |
|||
} |
|||
|
|||
const project = await ProjectModel.findById(id); |
|||
if (!project) { |
|||
ctx.status = 404; |
|||
ctx.body = { error: { message: '项目不存在', code: 'PROJECT_NOT_FOUND' } }; |
|||
return; |
|||
} |
|||
|
|||
// 获取附件和文档
|
|||
const [attachments, documents] = await Promise.all([ |
|||
AttachmentModel.findByProjectId(id), |
|||
DocumentModel.findByProjectId(id) |
|||
]); |
|||
|
|||
ctx.body = { |
|||
...project, |
|||
attachments, |
|||
documents |
|||
}; |
|||
} |
|||
|
|||
static async create(ctx: AuthContext) { |
|||
if (!ctx.user) { |
|||
ctx.status = 401; |
|||
ctx.body = { error: { message: '需要登录', code: 'UNAUTHORIZED' } }; |
|||
return; |
|||
} |
|||
|
|||
// 检查是否允许上传
|
|||
const allowUpload = await SettingModel.getBoolean('allow_upload'); |
|||
if (!allowUpload) { |
|||
ctx.status = 403; |
|||
ctx.body = { error: { message: '上传功能已关闭', code: 'UPLOAD_DISABLED' } }; |
|||
return; |
|||
} |
|||
|
|||
const { title, description, category, tags } = ctx.request.body as any; |
|||
const files = ctx.request.files as any; |
|||
|
|||
if (!title) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '标题不能为空', code: 'VALIDATION_ERROR' } }; |
|||
return; |
|||
} |
|||
|
|||
// 获取主文件(网页文件或ZIP)
|
|||
const mainFile = files?.file || files?.htmlFile; |
|||
if (!mainFile) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '请上传网页文件或ZIP文件', code: 'MISSING_FILE' } }; |
|||
return; |
|||
} |
|||
|
|||
const mainFileArray = Array.isArray(mainFile) ? mainFile : [mainFile]; |
|||
const primaryFile = mainFileArray[0]; |
|||
|
|||
try { |
|||
// 创建项目目录
|
|||
const projectDir = path.join( |
|||
__dirname, |
|||
'../../uploads', |
|||
String(ctx.user.id), |
|||
`project_${Date.now()}` |
|||
); |
|||
await fs.mkdir(projectDir, { recursive: true }); |
|||
|
|||
let filePath: string; |
|||
let fileType: 'single_html' | 'folder'; |
|||
|
|||
// 处理主文件
|
|||
if (path.extname(primaryFile.originalname).toLowerCase() === '.zip') { |
|||
// ZIP文件:解压
|
|||
const extractPath = path.join(projectDir, 'extracted'); |
|||
await extractZip(primaryFile.path, extractPath); |
|||
|
|||
// 查找HTML入口文件
|
|||
const htmlEntry = await findHtmlEntry(extractPath); |
|||
if (!htmlEntry) { |
|||
await removeDirectory(projectDir); |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: 'ZIP文件中未找到HTML文件', code: 'NO_HTML_FOUND' } }; |
|||
return; |
|||
} |
|||
|
|||
filePath = extractPath; |
|||
fileType = 'folder'; |
|||
} else { |
|||
// 单个HTML文件:直接移动
|
|||
const fileName = sanitizeFileName(primaryFile.originalname); |
|||
filePath = path.join(projectDir, fileName); |
|||
await fs.rename(primaryFile.path, filePath); |
|||
fileType = 'single_html'; |
|||
} |
|||
|
|||
// 创建项目记录(存储相对路径)
|
|||
const project = await ProjectModel.create({ |
|||
user_id: ctx.user.id, |
|||
title, |
|||
description, |
|||
category, |
|||
tags: tags ? (Array.isArray(tags) ? tags : tags.split(',').map((t: string) => t.trim())) : [], |
|||
file_path: toRelativePath(filePath), |
|||
file_type: fileType, |
|||
status: 'active' |
|||
}); |
|||
|
|||
// 处理附件
|
|||
const attachments = files?.attachments; |
|||
if (attachments) { |
|||
const attachmentArray = Array.isArray(attachments) ? attachments : [attachments]; |
|||
const attachmentDir = path.join(projectDir, 'attachments'); |
|||
await fs.mkdir(attachmentDir, { recursive: true }); |
|||
|
|||
for (const attachment of attachmentArray) { |
|||
const fileName = sanitizeFileName(attachment.originalname); |
|||
const destPath = path.join(attachmentDir, fileName); |
|||
await fs.rename(attachment.path, destPath); |
|||
|
|||
await AttachmentModel.create({ |
|||
project_id: project.id, |
|||
file_name: fileName, |
|||
file_path: toRelativePath(destPath), |
|||
file_size: attachment.size |
|||
}); |
|||
} |
|||
} |
|||
|
|||
// 处理Markdown文档
|
|||
const documents = files?.documents; |
|||
if (documents) { |
|||
const documentArray = Array.isArray(documents) ? documents : [documents]; |
|||
const documentDir = path.join(projectDir, 'documents'); |
|||
await fs.mkdir(documentDir, { recursive: true }); |
|||
|
|||
const documentRecords = []; |
|||
for (const doc of documentArray) { |
|||
const fileName = sanitizeFileName(doc.originalname); |
|||
const destPath = path.join(documentDir, fileName); |
|||
await fs.rename(doc.path, destPath); |
|||
|
|||
// 读取Markdown内容
|
|||
const content = await fs.readFile(destPath, 'utf-8'); |
|||
|
|||
documentRecords.push({ |
|||
project_id: project.id, |
|||
title: path.basename(fileName, path.extname(fileName)), |
|||
content, |
|||
file_path: toRelativePath(destPath) |
|||
}); |
|||
} |
|||
|
|||
if (documentRecords.length > 0) { |
|||
await DocumentModel.createMany(documentRecords); |
|||
} |
|||
} |
|||
|
|||
// 清理临时文件
|
|||
try { |
|||
await fs.rm(path.join(__dirname, '../../uploads/temp'), { recursive: true, force: true }); |
|||
} catch (error) { |
|||
// 忽略清理错误
|
|||
} |
|||
|
|||
ctx.body = { |
|||
project: { |
|||
...project, |
|||
attachments: await AttachmentModel.findByProjectId(project.id), |
|||
documents: await DocumentModel.findByProjectId(project.id) |
|||
} |
|||
}; |
|||
} catch (error: any) { |
|||
// 清理已创建的文件
|
|||
try { |
|||
const projectDir = path.join(__dirname, '../../uploads', String(ctx.user.id)); |
|||
const dirs = await fs.readdir(projectDir); |
|||
for (const dir of dirs) { |
|||
if (dir.startsWith('project_')) { |
|||
await removeDirectory(path.join(projectDir, dir)); |
|||
} |
|||
} |
|||
} catch (cleanupError) { |
|||
// 忽略清理错误
|
|||
} |
|||
|
|||
ctx.status = 500; |
|||
ctx.body = { error: { message: error.message || '上传失败', code: 'UPLOAD_ERROR' } }; |
|||
} |
|||
} |
|||
|
|||
static async update(ctx: AuthContext) { |
|||
if (!ctx.user) { |
|||
ctx.status = 401; |
|||
ctx.body = { error: { message: '需要登录', code: 'UNAUTHORIZED' } }; |
|||
return; |
|||
} |
|||
|
|||
const id = parseInt(ctx.params.id); |
|||
if (isNaN(id)) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '无效的项目ID', code: 'INVALID_ID' } }; |
|||
return; |
|||
} |
|||
|
|||
const project = await ProjectModel.findById(id); |
|||
if (!project) { |
|||
ctx.status = 404; |
|||
ctx.body = { error: { message: '项目不存在', code: 'PROJECT_NOT_FOUND' } }; |
|||
return; |
|||
} |
|||
|
|||
// 检查权限:只有创建者可以编辑
|
|||
if (project.user_id !== ctx.user.id) { |
|||
ctx.status = 403; |
|||
ctx.body = { error: { message: '无权编辑此项目', code: 'FORBIDDEN' } }; |
|||
return; |
|||
} |
|||
|
|||
const { title, description, category, tags } = ctx.request.body as any; |
|||
const updateData: any = {}; |
|||
if (title !== undefined) updateData.title = title; |
|||
if (description !== undefined) updateData.description = description; |
|||
if (category !== undefined) updateData.category = category; |
|||
if (tags !== undefined) { |
|||
updateData.tags = Array.isArray(tags) ? tags : tags.split(',').map((t: string) => t.trim()); |
|||
} |
|||
|
|||
const updated = await ProjectModel.update(id, ctx.user.id, updateData); |
|||
if (updated) { |
|||
ctx.body = { project: updated }; |
|||
} else { |
|||
ctx.status = 500; |
|||
ctx.body = { error: { message: '更新失败', code: 'UPDATE_ERROR' } }; |
|||
} |
|||
} |
|||
|
|||
static async delete(ctx: AuthContext) { |
|||
if (!ctx.user) { |
|||
ctx.status = 401; |
|||
ctx.body = { error: { message: '需要登录', code: 'UNAUTHORIZED' } }; |
|||
return; |
|||
} |
|||
|
|||
const id = parseInt(ctx.params.id); |
|||
if (isNaN(id)) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '无效的项目ID', code: 'INVALID_ID' } }; |
|||
return; |
|||
} |
|||
|
|||
const project = await ProjectModel.findById(id); |
|||
if (!project) { |
|||
ctx.status = 404; |
|||
ctx.body = { error: { message: '项目不存在', code: 'PROJECT_NOT_FOUND' } }; |
|||
return; |
|||
} |
|||
|
|||
// 检查权限:只有创建者或管理员可以删除
|
|||
const isAdmin = ctx.user.role === 'super_admin' || ctx.user.role === 'admin'; |
|||
if (project.user_id !== ctx.user.id && !isAdmin) { |
|||
ctx.status = 403; |
|||
ctx.body = { error: { message: '无权删除此项目', code: 'FORBIDDEN' } }; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
// 从相对路径转换为绝对路径
|
|||
const absoluteFilePath = toAbsolutePath(project.file_path); |
|||
let projectDir: string; |
|||
|
|||
if (project.file_type === 'folder') { |
|||
// 文件夹类型:file_path 就是 extracted 目录,项目目录是它的父目录
|
|||
projectDir = path.dirname(absoluteFilePath); |
|||
} else { |
|||
// 单个HTML文件:file_path 是文件路径,项目目录是它的父目录
|
|||
projectDir = path.dirname(absoluteFilePath); |
|||
} |
|||
|
|||
// 验证项目目录名称格式(安全措施)
|
|||
const dirName = path.basename(projectDir); |
|||
if (!dirName.startsWith('project_')) { |
|||
console.warn(`警告:项目目录名称不符合预期格式: ${projectDir}`); |
|||
} |
|||
|
|||
// 删除项目目录及其所有文件
|
|||
try { |
|||
await removeDirectory(projectDir); |
|||
} catch (fileError: any) { |
|||
// 记录错误但不阻止删除操作
|
|||
console.error(`删除项目文件失败: ${fileError.message}`, projectDir); |
|||
} |
|||
|
|||
// 删除数据库记录
|
|||
const deleted = await ProjectModel.delete(id, ctx.user.id, isAdmin); |
|||
if (deleted) { |
|||
// 删除关联的附件和文档记录(可选,因为项目已标记为 deleted)
|
|||
try { |
|||
await AttachmentModel.deleteByProjectId(id); |
|||
await DocumentModel.deleteByProjectId(id); |
|||
} catch (dbError: any) { |
|||
// 记录错误但不阻止删除操作
|
|||
console.error(`删除关联记录失败: ${dbError.message}`); |
|||
} |
|||
|
|||
ctx.body = { message: '删除成功' }; |
|||
} else { |
|||
ctx.status = 500; |
|||
ctx.body = { error: { message: '删除失败', code: 'DELETE_ERROR' } }; |
|||
} |
|||
} catch (error: any) { |
|||
ctx.status = 500; |
|||
ctx.body = { error: { message: error.message || '删除失败', code: 'DELETE_ERROR' } }; |
|||
} |
|||
} |
|||
|
|||
static async preview(ctx: Context) { |
|||
const id = parseInt(ctx.params.id); |
|||
if (isNaN(id)) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '无效的项目ID', code: 'INVALID_ID' } }; |
|||
return; |
|||
} |
|||
|
|||
const project = await ProjectModel.findById(id); |
|||
if (!project) { |
|||
ctx.status = 404; |
|||
ctx.body = { error: { message: '项目不存在', code: 'PROJECT_NOT_FOUND' } }; |
|||
return; |
|||
} |
|||
|
|||
// 读取HTML内容(将相对路径转换为绝对路径)
|
|||
try { |
|||
const absoluteFilePath = toAbsolutePath(project.file_path); |
|||
let htmlPath: string; |
|||
if (project.file_type === 'single_html') { |
|||
htmlPath = absoluteFilePath; |
|||
} else { |
|||
// 文件夹类型,查找HTML入口
|
|||
const htmlEntry = await findHtmlEntry(absoluteFilePath); |
|||
if (!htmlEntry) { |
|||
ctx.status = 404; |
|||
ctx.body = { error: { message: '未找到HTML文件', code: 'HTML_NOT_FOUND' } }; |
|||
return; |
|||
} |
|||
htmlPath = htmlEntry; |
|||
} |
|||
|
|||
let htmlContent = await fs.readFile(htmlPath, 'utf-8'); |
|||
|
|||
// 确定项目根目录路径
|
|||
const projectBasePath = project.file_type === 'single_html' |
|||
? path.dirname(absoluteFilePath) |
|||
: absoluteFilePath; |
|||
|
|||
// 重写HTML中的资源路径
|
|||
htmlContent = rewriteHtmlResourcePaths( |
|||
htmlContent, |
|||
project.id, |
|||
htmlPath, |
|||
projectBasePath |
|||
); |
|||
|
|||
// 设置CSP头
|
|||
ctx.set('Content-Security-Policy', "default-src 'self'; script-src 'unsafe-inline' 'unsafe-eval' 'self'; style-src 'unsafe-inline' 'self';"); |
|||
ctx.set('Content-Type', 'text/html; charset=utf-8'); |
|||
ctx.body = htmlContent; |
|||
} catch (error: any) { |
|||
ctx.status = 500; |
|||
ctx.body = { error: { message: '读取文件失败', code: 'READ_ERROR' } }; |
|||
} |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,34 @@ |
|||
import { Context } from 'koa'; |
|||
import { SettingModel } from '../models/Setting'; |
|||
import { AuthContext, adminMiddleware, superAdminMiddleware } from '../middleware/auth'; |
|||
|
|||
export class SettingController { |
|||
// 获取所有设置(仅管理员)
|
|||
static async getAll(ctx: AuthContext) { |
|||
const settings = await SettingModel.findAll(); |
|||
ctx.body = { settings }; |
|||
} |
|||
|
|||
// 更新设置(仅超级管理员)
|
|||
static async update(ctx: AuthContext) { |
|||
const { key } = ctx.params; |
|||
const { value } = ctx.request.body as any; |
|||
|
|||
if (!key || value === undefined) { |
|||
ctx.status = 400; |
|||
ctx.body = { error: { message: '参数不完整', code: 'VALIDATION_ERROR' } }; |
|||
return; |
|||
} |
|||
|
|||
const setting = await SettingModel.findByKey(key); |
|||
if (!setting) { |
|||
ctx.status = 404; |
|||
ctx.body = { error: { message: '设置不存在', code: 'SETTING_NOT_FOUND' } }; |
|||
return; |
|||
} |
|||
|
|||
const updated = await SettingModel.update(key, String(value)); |
|||
ctx.body = { setting: updated }; |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,72 @@ |
|||
import { Context, Next } from 'koa'; |
|||
import { verifyToken } from '../utils/jwt'; |
|||
import { UserModel } from '../models/User'; |
|||
|
|||
export interface AuthContext extends Context { |
|||
user?: { |
|||
id: number; |
|||
username: string; |
|||
email?: string; |
|||
role: string; |
|||
}; |
|||
} |
|||
|
|||
export async function authMiddleware(ctx: AuthContext, next: Next) { |
|||
const token = ctx.headers.authorization?.replace('Bearer ', ''); |
|||
|
|||
if (!token) { |
|||
ctx.status = 401; |
|||
ctx.body = { error: { message: '未提供认证token', code: 'UNAUTHORIZED' } }; |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
const payload = verifyToken(token); |
|||
const user = await UserModel.findById(payload.userId); |
|||
|
|||
if (!user) { |
|||
ctx.status = 401; |
|||
ctx.body = { error: { message: '用户不存在', code: 'USER_NOT_FOUND' } }; |
|||
return; |
|||
} |
|||
|
|||
ctx.user = user; |
|||
await next(); |
|||
} catch (error: any) { |
|||
ctx.status = 401; |
|||
ctx.body = { error: { message: '无效的token', code: 'INVALID_TOKEN' } }; |
|||
} |
|||
} |
|||
|
|||
export async function adminMiddleware(ctx: AuthContext, next: Next) { |
|||
if (!ctx.user) { |
|||
ctx.status = 401; |
|||
ctx.body = { error: { message: '需要认证', code: 'UNAUTHORIZED' } }; |
|||
return; |
|||
} |
|||
|
|||
if (ctx.user.role !== 'super_admin' && ctx.user.role !== 'admin') { |
|||
ctx.status = 403; |
|||
ctx.body = { error: { message: '需要管理员权限', code: 'FORBIDDEN' } }; |
|||
return; |
|||
} |
|||
|
|||
await next(); |
|||
} |
|||
|
|||
export async function superAdminMiddleware(ctx: AuthContext, next: Next) { |
|||
if (!ctx.user) { |
|||
ctx.status = 401; |
|||
ctx.body = { error: { message: '需要认证', code: 'UNAUTHORIZED' } }; |
|||
return; |
|||
} |
|||
|
|||
if (ctx.user.role !== 'super_admin') { |
|||
ctx.status = 403; |
|||
ctx.body = { error: { message: '需要超级管理员权限', code: 'FORBIDDEN' } }; |
|||
return; |
|||
} |
|||
|
|||
await next(); |
|||
} |
|||
|
|||
@ -0,0 +1,39 @@ |
|||
import { Context, Next } from 'koa'; |
|||
import config from '../config/config'; |
|||
|
|||
export async function errorHandler(ctx: Context, next: Next) { |
|||
try { |
|||
await next(); |
|||
} catch (err: any) { |
|||
ctx.status = err.status || 500; |
|||
|
|||
// 生产模式下不暴露详细错误信息
|
|||
const isProduction = config.isProduction; |
|||
|
|||
ctx.body = { |
|||
error: { |
|||
message: isProduction && ctx.status === 500 |
|||
? '内部服务器错误' |
|||
: (err.message || '内部服务器错误'), |
|||
code: err.code || 'INTERNAL_ERROR', |
|||
...(isProduction ? {} : { stack: err.stack }) // 开发模式下包含堆栈信息
|
|||
} |
|||
}; |
|||
|
|||
// 记录错误日志
|
|||
if (isProduction) { |
|||
console.error('Error:', { |
|||
status: ctx.status, |
|||
message: err.message, |
|||
code: err.code, |
|||
path: ctx.path, |
|||
method: ctx.method |
|||
}); |
|||
} else { |
|||
console.error('Error:', err); |
|||
} |
|||
|
|||
ctx.app.emit('error', err, ctx); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,101 @@ |
|||
import multer from '@koa/multer'; |
|||
import path from 'path'; |
|||
import { sanitizeFileName, isValidFileType, isValidFileSize } from '../utils/fileUtils'; |
|||
import config from '../config/config'; |
|||
import { ensureDirectory } from '../utils/fileUtils'; |
|||
import { IncomingMessage } from 'http'; |
|||
|
|||
// 确保上传目录存在
|
|||
const uploadsDir = path.join(__dirname, '../../uploads'); |
|||
ensureDirectory(uploadsDir).catch(() => {}); |
|||
|
|||
const storage = multer.diskStorage({ |
|||
destination: async (req: IncomingMessage, file: multer.File, cb: (error: Error | null, destination: string) => void) => { |
|||
const uploadPath = path.join(__dirname, '../../uploads/temp'); |
|||
try { |
|||
await ensureDirectory(uploadPath); |
|||
cb(null, uploadPath); |
|||
} catch (error) { |
|||
cb(error as Error, uploadPath); |
|||
} |
|||
}, |
|||
filename: (req: IncomingMessage, file: multer.File, cb: (error: Error | null, filename: string) => void) => { |
|||
const sanitized = sanitizeFileName(file.originalname); |
|||
const ext = path.extname(sanitized); |
|||
const name = path.basename(sanitized, ext); |
|||
const timestamp = Date.now(); |
|||
cb(null, `${name}_${timestamp}${ext}`); |
|||
} |
|||
}); |
|||
|
|||
const fileFilter = (req: IncomingMessage, file: multer.File, cb: (error: Error | null, acceptFile: boolean) => void) => { |
|||
// 根据字段名判断文件类型要求
|
|||
const fieldName = file.fieldname; |
|||
|
|||
// 主文件(file 或 htmlFile):只允许 .html 和 .zip
|
|||
if (fieldName === 'file' || fieldName === 'htmlFile') { |
|||
if (!isValidFileType(file.originalname)) { |
|||
cb(new Error('不支持的文件类型,主文件仅支持 .html 和 .zip 文件'), false); |
|||
return; |
|||
} |
|||
} |
|||
// 附件(attachments):允许任何文件类型
|
|||
else if (fieldName === 'attachments') { |
|||
// 附件可以是任何类型,只检查文件名安全性
|
|||
const sanitized = sanitizeFileName(file.originalname); |
|||
if (sanitized !== file.originalname) { |
|||
cb(new Error('附件文件名包含不安全字符'), false); |
|||
return; |
|||
} |
|||
} |
|||
// 文档(documents):只允许 .md 和 .markdown
|
|||
else if (fieldName === 'documents') { |
|||
const ext = path.extname(file.originalname).toLowerCase(); |
|||
if (ext !== '.md' && ext !== '.markdown') { |
|||
cb(new Error('文档文件仅支持 .md 和 .markdown 格式'), false); |
|||
return; |
|||
} |
|||
} |
|||
// 其他未知字段
|
|||
else { |
|||
cb(new Error(`未知的文件字段: ${fieldName}`), false); |
|||
return; |
|||
} |
|||
|
|||
cb(null, true); |
|||
}; |
|||
|
|||
export const upload = multer({ |
|||
storage, |
|||
fileFilter, |
|||
limits: { |
|||
fileSize: config.maxZipSize, // 最大50MB(ZIP文件)
|
|||
files: 10 // 最多10个文件
|
|||
} |
|||
}); |
|||
|
|||
// 验证文件大小中间件
|
|||
export async function validateFileSize(ctx: any, next: any) { |
|||
if (ctx.request.files) { |
|||
const files = Array.isArray(ctx.request.files) |
|||
? ctx.request.files |
|||
: Object.values(ctx.request.files).flat(); |
|||
|
|||
for (const file of files) { |
|||
const isZip = path.extname(file.originalname).toLowerCase() === '.zip'; |
|||
if (!isValidFileSize(file.size, isZip)) { |
|||
ctx.status = 400; |
|||
ctx.body = { |
|||
error: { |
|||
message: `文件大小超过限制(${isZip ? 'ZIP' : '单个'}文件最大${isZip ? '50MB' : '10MB'})`, |
|||
code: 'FILE_TOO_LARGE' |
|||
} |
|||
}; |
|||
return; |
|||
} |
|||
} |
|||
} |
|||
|
|||
await next(); |
|||
} |
|||
|
|||
@ -0,0 +1,17 @@ |
|||
import { Knex } from 'knex'; |
|||
|
|||
export async function up(knex: Knex): Promise<void> { |
|||
return knex.schema.createTable('users', (table) => { |
|||
table.increments('id').primary(); |
|||
table.string('username', 50).notNullable().unique(); |
|||
table.string('password', 255).notNullable(); |
|||
table.string('email', 100); |
|||
table.string('role', 20).defaultTo('user').notNullable(); // super_admin, admin, user
|
|||
table.timestamps(true, true); |
|||
}); |
|||
} |
|||
|
|||
export async function down(knex: Knex): Promise<void> { |
|||
return knex.schema.dropTable('users'); |
|||
} |
|||
|
|||
@ -0,0 +1,25 @@ |
|||
import { Knex } from 'knex'; |
|||
|
|||
export async function up(knex: Knex): Promise<void> { |
|||
return knex.schema.createTable('projects', (table) => { |
|||
table.increments('id').primary(); |
|||
table.integer('user_id').unsigned().notNullable(); |
|||
table.foreign('user_id').references('id').inTable('users').onDelete('CASCADE'); |
|||
table.string('title', 200).notNullable(); |
|||
table.text('description'); |
|||
table.string('category', 50); |
|||
table.text('tags'); // JSON数组字符串
|
|||
table.string('file_path', 500).notNullable(); |
|||
table.string('file_type', 20).notNullable(); // single_html, folder
|
|||
table.string('status', 20).defaultTo('active').notNullable(); // active, deleted
|
|||
table.timestamps(true, true); |
|||
table.index(['user_id']); |
|||
table.index(['category']); |
|||
table.index(['status']); |
|||
}); |
|||
} |
|||
|
|||
export async function down(knex: Knex): Promise<void> { |
|||
return knex.schema.dropTable('projects'); |
|||
} |
|||
|
|||
@ -0,0 +1,19 @@ |
|||
import { Knex } from 'knex'; |
|||
|
|||
export async function up(knex: Knex): Promise<void> { |
|||
return knex.schema.createTable('attachments', (table) => { |
|||
table.increments('id').primary(); |
|||
table.integer('project_id').unsigned().notNullable(); |
|||
table.foreign('project_id').references('id').inTable('projects').onDelete('CASCADE'); |
|||
table.string('file_name', 255).notNullable(); |
|||
table.string('file_path', 500).notNullable(); |
|||
table.integer('file_size').unsigned(); // 字节
|
|||
table.timestamps(true, true); |
|||
table.index(['project_id']); |
|||
}); |
|||
} |
|||
|
|||
export async function down(knex: Knex): Promise<void> { |
|||
return knex.schema.dropTable('attachments'); |
|||
} |
|||
|
|||
@ -0,0 +1,19 @@ |
|||
import { Knex } from 'knex'; |
|||
|
|||
export async function up(knex: Knex): Promise<void> { |
|||
return knex.schema.createTable('documents', (table) => { |
|||
table.increments('id').primary(); |
|||
table.integer('project_id').unsigned().notNullable(); |
|||
table.foreign('project_id').references('id').inTable('projects').onDelete('CASCADE'); |
|||
table.string('title', 200).notNullable(); |
|||
table.text('content'); // Markdown内容
|
|||
table.string('file_path', 500); // 可选的文件路径
|
|||
table.timestamps(true, true); |
|||
table.index(['project_id']); |
|||
}); |
|||
} |
|||
|
|||
export async function down(knex: Knex): Promise<void> { |
|||
return knex.schema.dropTable('documents'); |
|||
} |
|||
|
|||
@ -0,0 +1,16 @@ |
|||
import { Knex } from 'knex'; |
|||
|
|||
export async function up(knex: Knex): Promise<void> { |
|||
return knex.schema.createTable('settings', (table) => { |
|||
table.increments('id').primary(); |
|||
table.string('key', 100).notNullable().unique(); |
|||
table.text('value').notNullable(); |
|||
table.text('description'); |
|||
table.timestamps(true, true); |
|||
}); |
|||
} |
|||
|
|||
export async function down(knex: Knex): Promise<void> { |
|||
return knex.schema.dropTable('settings'); |
|||
} |
|||
|
|||
@ -0,0 +1,22 @@ |
|||
import { Knex } from 'knex'; |
|||
|
|||
export async function up(knex: Knex): Promise<void> { |
|||
// 插入默认设置
|
|||
return knex('settings').insert([ |
|||
{ |
|||
key: 'allow_register', |
|||
value: 'true', |
|||
description: '是否允许用户注册' |
|||
}, |
|||
{ |
|||
key: 'allow_upload', |
|||
value: 'true', |
|||
description: '是否允许上传网页' |
|||
} |
|||
]); |
|||
} |
|||
|
|||
export async function down(knex: Knex): Promise<void> { |
|||
return knex('settings').whereIn('key', ['allow_register', 'allow_upload']).del(); |
|||
} |
|||
|
|||
@ -0,0 +1,30 @@ |
|||
import db from '../config/database'; |
|||
|
|||
export interface Attachment { |
|||
id: number; |
|||
project_id: number; |
|||
file_name: string; |
|||
file_path: string; |
|||
file_size?: number; |
|||
created_at: Date; |
|||
} |
|||
|
|||
export class AttachmentModel { |
|||
static async create(attachmentData: Omit<Attachment, 'id' | 'created_at'>): Promise<Attachment> { |
|||
const [attachment] = await db('attachments').insert(attachmentData).returning('*'); |
|||
return attachment; |
|||
} |
|||
|
|||
static async findByProjectId(projectId: number): Promise<Attachment[]> { |
|||
return db('attachments').where({ project_id: projectId }); |
|||
} |
|||
|
|||
static async findById(id: number): Promise<Attachment | undefined> { |
|||
return db('attachments').where({ id }).first(); |
|||
} |
|||
|
|||
static async deleteByProjectId(projectId: number): Promise<number> { |
|||
return db('attachments').where({ project_id: projectId }).delete(); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,35 @@ |
|||
import db from '../config/database'; |
|||
|
|||
export interface Document { |
|||
id: number; |
|||
project_id: number; |
|||
title: string; |
|||
content?: string; |
|||
file_path?: string; |
|||
created_at: Date; |
|||
updated_at: Date; |
|||
} |
|||
|
|||
export class DocumentModel { |
|||
static async create(documentData: Omit<Document, 'id' | 'created_at' | 'updated_at'>): Promise<Document> { |
|||
const [document] = await db('documents').insert(documentData).returning('*'); |
|||
return document; |
|||
} |
|||
|
|||
static async createMany(documentsData: Omit<Document, 'id' | 'created_at' | 'updated_at'>[]): Promise<Document[]> { |
|||
return db('documents').insert(documentsData).returning('*'); |
|||
} |
|||
|
|||
static async findByProjectId(projectId: number): Promise<Document[]> { |
|||
return db('documents').where({ project_id: projectId }); |
|||
} |
|||
|
|||
static async findById(id: number): Promise<Document | undefined> { |
|||
return db('documents').where({ id }).first(); |
|||
} |
|||
|
|||
static async deleteByProjectId(projectId: number): Promise<number> { |
|||
return db('documents').where({ project_id: projectId }).delete(); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,97 @@ |
|||
import db from '../config/database'; |
|||
|
|||
export interface Project { |
|||
id: number; |
|||
user_id: number; |
|||
title: string; |
|||
description?: string; |
|||
category?: string; |
|||
tags?: string[]; |
|||
file_path: string; |
|||
file_type: 'single_html' | 'folder'; |
|||
status: 'active' | 'deleted'; |
|||
created_at: Date; |
|||
updated_at: Date; |
|||
} |
|||
|
|||
export interface ProjectQuery { |
|||
category?: string; |
|||
tag?: string; |
|||
search?: string; |
|||
page?: number; |
|||
limit?: number; |
|||
} |
|||
|
|||
export class ProjectModel { |
|||
static async create(projectData: Omit<Project, 'id' | 'created_at' | 'updated_at'>): Promise<Project> { |
|||
const data = { |
|||
...projectData, |
|||
tags: JSON.stringify(projectData.tags || []) |
|||
}; |
|||
const [project] = await db('projects').insert(data).returning('*'); |
|||
return this.formatProject(project); |
|||
} |
|||
|
|||
static async findById(id: number): Promise<Project | undefined> { |
|||
const project = await db('projects').where({ id, status: 'active' }).first(); |
|||
return project ? this.formatProject(project) : undefined; |
|||
} |
|||
|
|||
static async findMany(query: ProjectQuery = {}): Promise<{ projects: Project[]; total: number }> { |
|||
const { category, tag, search, page = 1, limit = 20 } = query; |
|||
let queryBuilder = db('projects').where({ status: 'active' }); |
|||
|
|||
if (category) { |
|||
queryBuilder = queryBuilder.where({ category }); |
|||
} |
|||
|
|||
if (tag) { |
|||
queryBuilder = queryBuilder.whereRaw("json_extract(tags, '$') LIKE ?", [`%"${tag}"%`]); |
|||
} |
|||
|
|||
if (search) { |
|||
queryBuilder = queryBuilder.where('title', 'like', `%${search}%`); |
|||
} |
|||
|
|||
const total = await queryBuilder.clone().count('* as count').first(); |
|||
const projects = await queryBuilder |
|||
.orderBy('created_at', 'desc') |
|||
.limit(limit) |
|||
.offset((page - 1) * limit); |
|||
|
|||
return { |
|||
projects: projects.map(this.formatProject), |
|||
total: (total as any).count |
|||
}; |
|||
} |
|||
|
|||
static async update(id: number, userId: number, projectData: Partial<Omit<Project, 'id' | 'user_id' | 'created_at' | 'updated_at' | 'file_path' | 'file_type'>>): Promise<Project | undefined> { |
|||
const data: any = { ...projectData }; |
|||
if (data.tags) { |
|||
data.tags = JSON.stringify(data.tags); |
|||
} |
|||
const [project] = await db('projects') |
|||
.where({ id, user_id: userId }) |
|||
.update({ ...data, updated_at: db.fn.now() }) |
|||
.returning('*'); |
|||
return project ? this.formatProject(project) : undefined; |
|||
} |
|||
|
|||
static async delete(id: number, userId: number, isAdmin: boolean = false): Promise<boolean> { |
|||
const query = db('projects').where({ id }); |
|||
// 如果不是管理员,只能删除自己的项目
|
|||
if (!isAdmin) { |
|||
query.where({ user_id: userId }); |
|||
} |
|||
const result = await query.update({ status: 'deleted' }); |
|||
return result > 0; |
|||
} |
|||
|
|||
private static formatProject(project: any): Project { |
|||
return { |
|||
...project, |
|||
tags: project.tags ? JSON.parse(project.tags) : [] |
|||
}; |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,34 @@ |
|||
import db from '../config/database'; |
|||
|
|||
export interface Setting { |
|||
id: number; |
|||
key: string; |
|||
value: string; |
|||
description?: string; |
|||
created_at: Date; |
|||
updated_at: Date; |
|||
} |
|||
|
|||
export class SettingModel { |
|||
static async findAll(): Promise<Setting[]> { |
|||
return db('settings'); |
|||
} |
|||
|
|||
static async findByKey(key: string): Promise<Setting | undefined> { |
|||
return db('settings').where({ key }).first(); |
|||
} |
|||
|
|||
static async update(key: string, value: string): Promise<Setting | undefined> { |
|||
const [setting] = await db('settings') |
|||
.where({ key }) |
|||
.update({ value, updated_at: db.fn.now() }) |
|||
.returning('*'); |
|||
return setting; |
|||
} |
|||
|
|||
static async getBoolean(key: string): Promise<boolean> { |
|||
const setting = await this.findByKey(key); |
|||
return setting?.value === 'true'; |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,32 @@ |
|||
import db from '../config/database'; |
|||
|
|||
export interface User { |
|||
id: number; |
|||
username: string; |
|||
password: string; |
|||
email?: string; |
|||
role: 'super_admin' | 'admin' | 'user'; |
|||
created_at: Date; |
|||
updated_at: Date; |
|||
} |
|||
|
|||
export class UserModel { |
|||
static async create(userData: Omit<User, 'id' | 'created_at' | 'updated_at'>): Promise<User> { |
|||
const [user] = await db('users').insert(userData).returning('*'); |
|||
return user; |
|||
} |
|||
|
|||
static async findByUsername(username: string): Promise<User | undefined> { |
|||
return db('users').where({ username }).first(); |
|||
} |
|||
|
|||
static async findById(id: number): Promise<User | undefined> { |
|||
return db('users').where({ id }).first(); |
|||
} |
|||
|
|||
static async isFirstUser(): Promise<boolean> { |
|||
const count = await db('users').count('* as count').first(); |
|||
return (count as any).count === 0; |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,13 @@ |
|||
import Router from '@koa/router'; |
|||
import { AuthController } from '../controllers/authController'; |
|||
import { authMiddleware } from '../middleware/auth'; |
|||
|
|||
const router = new Router(); |
|||
|
|||
router.post('/register', AuthController.register); |
|||
router.post('/login', AuthController.login); |
|||
router.post('/logout', AuthController.logout); |
|||
router.get('/me', authMiddleware, AuthController.getMe); |
|||
|
|||
export default router; |
|||
|
|||
@ -0,0 +1,13 @@ |
|||
import Router from '@koa/router'; |
|||
import { FileController } from '../controllers/fileController'; |
|||
|
|||
const router = new Router(); |
|||
|
|||
// 注意:这个路由需要放在最后,因为它会匹配所有路径
|
|||
router.get('/attachment/:id', FileController.downloadAttachment); |
|||
router.get('/document/:id', FileController.getDocument); |
|||
// 使用 (.*) 来匹配任意路径
|
|||
router.get('/:projectId/:path(.*)', FileController.getProjectFile); |
|||
|
|||
export default router; |
|||
|
|||
@ -0,0 +1,15 @@ |
|||
import Router from '@koa/router'; |
|||
import authRoutes from './auth'; |
|||
import projectRoutes from './projects'; |
|||
import fileRoutes from './files'; |
|||
import settingRoutes from './settings'; |
|||
|
|||
const router = new Router(); |
|||
|
|||
router.use('/api/auth', authRoutes.routes()); |
|||
router.use('/api/projects', projectRoutes.routes()); |
|||
router.use('/api/files', fileRoutes.routes()); |
|||
router.use('/api/settings', settingRoutes.routes()); |
|||
|
|||
export { router }; |
|||
|
|||
@ -0,0 +1,27 @@ |
|||
import Router from '@koa/router'; |
|||
import { ProjectController } from '../controllers/projectController'; |
|||
import { authMiddleware } from '../middleware/auth'; |
|||
import { upload, validateFileSize } from '../middleware/upload'; |
|||
|
|||
const router = new Router(); |
|||
|
|||
router.get('/', ProjectController.list); |
|||
router.get('/:id', ProjectController.getById); |
|||
router.post( |
|||
'/', |
|||
authMiddleware, |
|||
upload.fields([ |
|||
{ name: 'file', maxCount: 1 }, |
|||
{ name: 'htmlFile', maxCount: 1 }, |
|||
{ name: 'attachments', maxCount: 10 }, |
|||
{ name: 'documents', maxCount: 10 } |
|||
]), |
|||
validateFileSize, |
|||
ProjectController.create |
|||
); |
|||
router.put('/:id', authMiddleware, ProjectController.update); |
|||
router.delete('/:id', authMiddleware, ProjectController.delete); |
|||
router.get('/:id/preview', ProjectController.preview); |
|||
|
|||
export default router; |
|||
|
|||
@ -0,0 +1,11 @@ |
|||
import Router from '@koa/router'; |
|||
import { SettingController } from '../controllers/settingController'; |
|||
import { authMiddleware, adminMiddleware, superAdminMiddleware } from '../middleware/auth'; |
|||
|
|||
const router = new Router(); |
|||
|
|||
router.get('/', authMiddleware, adminMiddleware, SettingController.getAll); |
|||
router.put('/:key', authMiddleware, superAdminMiddleware, SettingController.update); |
|||
|
|||
export default router; |
|||
|
|||
@ -0,0 +1,51 @@ |
|||
// 启动脚本:修复 Node.js 22 兼容性问题
|
|||
// 必须在任何其他导入之前执行
|
|||
const Module = require('module'); |
|||
const originalLoad = Module._load; |
|||
|
|||
Module._load = function(request: string, parent: any, isMain: boolean) { |
|||
if (request === 'is-generator-function') { |
|||
// 返回一个兼容 Node.js 22 的实现
|
|||
const isGeneratorFunction = function(fn: any): boolean { |
|||
if (typeof fn !== 'function') return false; |
|||
try { |
|||
// 尝试获取 GeneratorFunction 构造函数
|
|||
let GeneratorFunction: any; |
|||
try { |
|||
GeneratorFunction = (function*() {}).constructor; |
|||
} catch (e) { |
|||
// 如果失败,尝试其他方法
|
|||
const genFn = Object.getPrototypeOf(function*() {}).constructor; |
|||
GeneratorFunction = genFn; |
|||
} |
|||
|
|||
if (GeneratorFunction) { |
|||
return fn instanceof GeneratorFunction; |
|||
} |
|||
|
|||
// 备用方法:检查函数名和 toString
|
|||
const constructor = fn.constructor; |
|||
if (constructor && (constructor.name === 'GeneratorFunction' || constructor.name === 'AsyncGeneratorFunction')) { |
|||
return true; |
|||
} |
|||
|
|||
const str = fn.toString(); |
|||
if (str.includes('function*') || str.includes('function *')) { |
|||
return true; |
|||
} |
|||
} catch (e) { |
|||
return false; |
|||
} |
|||
return false; |
|||
}; |
|||
|
|||
// 设置 default 导出
|
|||
isGeneratorFunction.default = isGeneratorFunction; |
|||
return isGeneratorFunction; |
|||
} |
|||
return originalLoad.apply(this, arguments as any); |
|||
}; |
|||
|
|||
// 现在导入应用
|
|||
import './app'; |
|||
|
|||
@ -0,0 +1,324 @@ |
|||
import fs from 'fs/promises'; |
|||
import path from 'path'; |
|||
import yauzl from 'yauzl'; |
|||
import config from '../config/config'; |
|||
|
|||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|||
const MAX_ZIP_SIZE = 50 * 1024 * 1024; // 50MB
|
|||
|
|||
// 允许的文件扩展名
|
|||
const ALLOWED_EXTENSIONS = ['.html', '.zip']; |
|||
const ALLOWED_HTML_EXTENSIONS = ['.html', '.htm']; |
|||
|
|||
// 获取上传目录的绝对路径
|
|||
export function getUploadsDir(): string { |
|||
return path.join(__dirname, '../../', config.uploadDir); |
|||
} |
|||
|
|||
// 将绝对路径转换为相对路径(相对于上传目录)
|
|||
export function toRelativePath(absolutePath: string): string { |
|||
const uploadsDir = getUploadsDir(); |
|||
const relativePath = path.relative(uploadsDir, absolutePath); |
|||
// 统一使用正斜杠,跨平台兼容
|
|||
return relativePath.split(path.sep).join('/'); |
|||
} |
|||
|
|||
// 将相对路径转换为绝对路径
|
|||
export function toAbsolutePath(relativePath: string): string { |
|||
const uploadsDir = getUploadsDir(); |
|||
// 处理相对路径中的正斜杠和反斜杠
|
|||
const normalizedPath = relativePath.replace(/\//g, path.sep); |
|||
return path.join(uploadsDir, normalizedPath); |
|||
} |
|||
|
|||
// 清理文件名,防止路径遍历攻击
|
|||
export function sanitizeFileName(fileName: string): string { |
|||
// 移除路径分隔符和危险字符
|
|||
return fileName |
|||
.replace(/[\/\\]/g, '') |
|||
.replace(/\.\./g, '') |
|||
.replace(/[<>:"|?*]/g, '') |
|||
.trim(); |
|||
} |
|||
|
|||
// 验证文件类型
|
|||
export function isValidFileType(fileName: string, allowedTypes: string[] = ALLOWED_EXTENSIONS): boolean { |
|||
const ext = path.extname(fileName).toLowerCase(); |
|||
return allowedTypes.includes(ext); |
|||
} |
|||
|
|||
// 验证文件大小
|
|||
export function isValidFileSize(size: number, isZip: boolean = false): boolean { |
|||
const maxSize = isZip ? MAX_ZIP_SIZE : MAX_FILE_SIZE; |
|||
return size <= maxSize; |
|||
} |
|||
|
|||
// 创建目录(如果不存在)
|
|||
export async function ensureDirectory(dirPath: string): Promise<void> { |
|||
try { |
|||
await fs.access(dirPath); |
|||
} catch { |
|||
await fs.mkdir(dirPath, { recursive: true }); |
|||
} |
|||
} |
|||
|
|||
// 解压ZIP文件
|
|||
export async function extractZip(zipPath: string, extractTo: string): Promise<void> { |
|||
return new Promise((resolve, reject) => { |
|||
yauzl.open(zipPath, { lazyEntries: true }, (err: Error | null, zipfile: yauzl.ZipFile | undefined) => { |
|||
if (err) { |
|||
reject(err); |
|||
return; |
|||
} |
|||
|
|||
if (!zipfile) { |
|||
reject(new Error('无法打开ZIP文件')); |
|||
return; |
|||
} |
|||
|
|||
let totalSize = 0; |
|||
const MAX_EXTRACT_SIZE = 100 * 1024 * 1024; // 100MB 解压后总大小限制
|
|||
const fsSync = require('fs') as typeof import('fs'); |
|||
|
|||
zipfile.readEntry(); |
|||
|
|||
zipfile.on('entry', (entry: yauzl.Entry) => { |
|||
// 检查文件名安全性
|
|||
const sanitized = sanitizeFileName(entry.fileName); |
|||
if (sanitized !== entry.fileName) { |
|||
reject(new Error('ZIP文件包含不安全的文件名')); |
|||
return; |
|||
} |
|||
|
|||
// 检查解压后总大小(防止zip炸弹)
|
|||
totalSize += entry.uncompressedSize; |
|||
if (totalSize > MAX_EXTRACT_SIZE) { |
|||
reject(new Error('ZIP文件解压后大小超过限制')); |
|||
return; |
|||
} |
|||
|
|||
if (/\/$/.test(entry.fileName)) { |
|||
// 目录
|
|||
zipfile.readEntry(); |
|||
} else { |
|||
// 文件
|
|||
zipfile.openReadStream(entry, (err: Error | null, readStream: NodeJS.ReadableStream | undefined) => { |
|||
if (err) { |
|||
reject(err); |
|||
return; |
|||
} |
|||
|
|||
const filePath = path.join(extractTo, entry.fileName); |
|||
ensureDirectory(path.dirname(filePath)).then(() => { |
|||
const writeStream = fsSync.createWriteStream(filePath); |
|||
readStream!.pipe(writeStream); |
|||
writeStream.on('close', () => { |
|||
zipfile.readEntry(); |
|||
}); |
|||
writeStream.on('error', (error: any) => { |
|||
reject(error); |
|||
}); |
|||
}).catch((error: any) => { |
|||
reject(error); |
|||
}); |
|||
}); |
|||
} |
|||
}); |
|||
|
|||
zipfile.on('end', () => { |
|||
resolve(); |
|||
}); |
|||
|
|||
zipfile.on('error', (err: any) => { |
|||
reject(err); |
|||
}); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
// 查找HTML入口文件
|
|||
export async function findHtmlEntry(dirPath: string): Promise<string | null> { |
|||
const files = await fs.readdir(dirPath, { withFileTypes: true }); |
|||
|
|||
// 优先查找index.html
|
|||
for (const file of files) { |
|||
if (file.isFile()) { |
|||
const ext = path.extname(file.name).toLowerCase(); |
|||
if (file.name.toLowerCase() === 'index.html' && ALLOWED_HTML_EXTENSIONS.includes(ext)) { |
|||
return path.join(dirPath, file.name); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 查找第一个HTML文件
|
|||
for (const file of files) { |
|||
if (file.isFile()) { |
|||
const ext = path.extname(file.name).toLowerCase(); |
|||
if (ALLOWED_HTML_EXTENSIONS.includes(ext)) { |
|||
return path.join(dirPath, file.name); |
|||
} |
|||
} |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
// 递归删除目录
|
|||
export async function removeDirectory(dirPath: string): Promise<void> { |
|||
try { |
|||
await fs.rm(dirPath, { recursive: true, force: true }); |
|||
} catch (error) { |
|||
// 忽略错误
|
|||
} |
|||
} |
|||
|
|||
// 重写HTML中的资源路径,将相对路径转换为API路径
|
|||
export function rewriteHtmlResourcePaths( |
|||
htmlContent: string, |
|||
projectId: number, |
|||
htmlFilePath: string, |
|||
projectBasePath: string |
|||
): string { |
|||
// 计算HTML文件相对于项目根目录的路径
|
|||
const htmlDir = path.dirname(htmlFilePath); |
|||
const relativeHtmlDir = path.relative(projectBasePath, htmlDir).replace(/\\/g, '/'); |
|||
const basePath = relativeHtmlDir ? `${relativeHtmlDir}/` : ''; |
|||
|
|||
// 匹配各种资源引用
|
|||
// 1. <link rel="stylesheet" href="...">
|
|||
htmlContent = htmlContent.replace( |
|||
/<link([^>]*)\s+href=["']([^"']+)["']([^>]*)>/gi, |
|||
(match, before, href, after) => { |
|||
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//') || href.startsWith('data:')) { |
|||
return match; // 跳过绝对URL和data URI
|
|||
} |
|||
const newHref = rewritePath(href, basePath, projectId); |
|||
return `<link${before} href="${newHref}"${after}>`; |
|||
} |
|||
); |
|||
|
|||
// 2. <script src="...">
|
|||
htmlContent = htmlContent.replace( |
|||
/<script([^>]*)\s+src=["']([^"']+)["']([^>]*)>/gi, |
|||
(match, before, src, after) => { |
|||
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//') || src.startsWith('data:')) { |
|||
return match; // 跳过绝对URL和data URI
|
|||
} |
|||
const newSrc = rewritePath(src, basePath, projectId); |
|||
return `<script${before} src="${newSrc}"${after}>`; |
|||
} |
|||
); |
|||
|
|||
// 3. <img src="...">
|
|||
htmlContent = htmlContent.replace( |
|||
/<img([^>]*)\s+src=["']([^"']+)["']([^>]*)>/gi, |
|||
(match, before, src, after) => { |
|||
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//') || src.startsWith('data:')) { |
|||
return match; // 跳过绝对URL和data URI
|
|||
} |
|||
const newSrc = rewritePath(src, basePath, projectId); |
|||
return `<img${before} src="${newSrc}"${after}>`; |
|||
} |
|||
); |
|||
|
|||
// 4. <source src="..."> 或 <source srcset="...">
|
|||
htmlContent = htmlContent.replace( |
|||
/<source([^>]*)\s+(src|srcset)=["']([^"']+)["']([^>]*)>/gi, |
|||
(match, before, attr, value, after) => { |
|||
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('//') || value.startsWith('data:')) { |
|||
return match; // 跳过绝对URL和data URI
|
|||
} |
|||
const newValue = rewritePath(value, basePath, projectId); |
|||
return `<source${before} ${attr}="${newValue}"${after}>`; |
|||
} |
|||
); |
|||
|
|||
// 5. <video src="..."> 和 <video poster="...">
|
|||
htmlContent = htmlContent.replace( |
|||
/<video([^>]*)\s+(src|poster)=["']([^"']+)["']([^>]*)>/gi, |
|||
(match, before, attr, value, after) => { |
|||
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('//') || value.startsWith('data:')) { |
|||
return match; |
|||
} |
|||
const newValue = rewritePath(value, basePath, projectId); |
|||
return `<video${before} ${attr}="${newValue}"${after}>`; |
|||
} |
|||
); |
|||
|
|||
// 6. <audio src="...">
|
|||
htmlContent = htmlContent.replace( |
|||
/<audio([^>]*)\s+src=["']([^"']+)["']([^>]*)>/gi, |
|||
(match, before, src, after) => { |
|||
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//') || src.startsWith('data:')) { |
|||
return match; |
|||
} |
|||
const newSrc = rewritePath(src, basePath, projectId); |
|||
return `<audio${before} src="${newSrc}"${after}>`; |
|||
} |
|||
); |
|||
|
|||
// 7. CSS中的url()引用
|
|||
htmlContent = htmlContent.replace( |
|||
/url\(["']?([^"')]+)["']?\)/gi, |
|||
(match, url) => { |
|||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//') || url.startsWith('data:') || url.startsWith('/')) { |
|||
return match; // 跳过绝对URL和data URI
|
|||
} |
|||
const newUrl = rewritePath(url, basePath, projectId); |
|||
return `url("${newUrl}")`; |
|||
} |
|||
); |
|||
|
|||
// 8. CSS @import 语句
|
|||
htmlContent = htmlContent.replace( |
|||
/@import\s+["']([^"']+)["']/gi, |
|||
(match, importPath) => { |
|||
if (importPath.startsWith('http://') || importPath.startsWith('https://') || importPath.startsWith('//')) { |
|||
return match; |
|||
} |
|||
const newImportPath = rewritePath(importPath, basePath, projectId); |
|||
return `@import "${newImportPath}"`; |
|||
} |
|||
); |
|||
|
|||
return htmlContent; |
|||
} |
|||
|
|||
// 重写单个路径
|
|||
function rewritePath(originalPath: string, basePath: string, projectId: number): string { |
|||
// 如果路径以 / 开头,说明是绝对路径(相对于项目根目录),直接使用
|
|||
if (originalPath.startsWith('/')) { |
|||
const cleanPath = originalPath.replace(/^\/+/, ''); |
|||
return `/api/files/${projectId}/${cleanPath}`; |
|||
} |
|||
|
|||
// 处理相对路径
|
|||
// 移除开头的 ./
|
|||
let cleanPath = originalPath.replace(/^\.\//, ''); |
|||
|
|||
// 处理 ../
|
|||
if (cleanPath.startsWith('../')) { |
|||
// 计算需要向上几级目录
|
|||
const baseParts = basePath ? basePath.split('/').filter(p => p) : []; |
|||
let pathParts = cleanPath.split('/').filter(p => p); |
|||
|
|||
// 移除 .. 并向上级目录
|
|||
while (pathParts.length > 0 && pathParts[0] === '..') { |
|||
pathParts.shift(); |
|||
if (baseParts.length > 0) { |
|||
baseParts.pop(); |
|||
} |
|||
} |
|||
|
|||
// 组合路径
|
|||
const finalParts = [...baseParts, ...pathParts]; |
|||
const finalPath = finalParts.join('/'); |
|||
return `/api/files/${projectId}/${finalPath}`; |
|||
} |
|||
|
|||
// 普通相对路径,直接拼接
|
|||
const fullPath = basePath ? `${basePath}${cleanPath}` : cleanPath; |
|||
const finalPath = fullPath.replace(/^\//, ''); |
|||
|
|||
return `/api/files/${projectId}/${finalPath}`; |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
import jwt, { SignOptions } from 'jsonwebtoken'; |
|||
import config from '../config/config'; |
|||
|
|||
export interface TokenPayload { |
|||
userId: number; |
|||
username: string; |
|||
role: string; |
|||
} |
|||
|
|||
export function generateToken(payload: TokenPayload): string { |
|||
const secret: string = String(config.jwtSecret); |
|||
// @ts-expect-error - expiresIn accepts string values like '7d' which are valid StringValue types from 'ms' package
|
|||
return jwt.sign(payload, secret, { |
|||
expiresIn: config.jwtExpiresIn |
|||
}); |
|||
} |
|||
|
|||
export function verifyToken(token: string): TokenPayload { |
|||
try { |
|||
return jwt.verify(token, config.jwtSecret) as TokenPayload; |
|||
} catch (error) { |
|||
throw new Error('无效的token'); |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,12 @@ |
|||
import bcrypt from 'bcrypt'; |
|||
|
|||
const SALT_ROUNDS = 10; |
|||
|
|||
export async function hashPassword(password: string): Promise<string> { |
|||
return bcrypt.hash(password, SALT_ROUNDS); |
|||
} |
|||
|
|||
export async function comparePassword(password: string, hash: string): Promise<boolean> { |
|||
return bcrypt.compare(password, hash); |
|||
} |
|||
|
|||
@ -0,0 +1,21 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "ES2020", |
|||
"module": "commonjs", |
|||
"lib": ["ES2020"], |
|||
"outDir": "./dist", |
|||
"rootDir": "./src", |
|||
"strict": true, |
|||
"esModuleInterop": true, |
|||
"skipLibCheck": true, |
|||
"forceConsistentCasingInFileNames": true, |
|||
"resolveJsonModule": true, |
|||
"moduleResolution": "node", |
|||
"declaration": false, |
|||
"declarationMap": false, |
|||
"sourceMap": true |
|||
}, |
|||
"include": ["src/**/*"], |
|||
"exclude": ["node_modules", "dist"] |
|||
} |
|||
|
|||
@ -0,0 +1,6 @@ |
|||
node_modules |
|||
dist |
|||
dist-ssr |
|||
*.local |
|||
.DS_Store |
|||
|
|||
@ -0,0 +1,14 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="zh-CN"> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
|||
<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.ts"></script> |
|||
</body> |
|||
</html> |
|||
|
|||
@ -0,0 +1,29 @@ |
|||
{ |
|||
"name": "just-demo-frontend", |
|||
"version": "1.0.0", |
|||
"description": "前端应用", |
|||
"type": "module", |
|||
"scripts": { |
|||
"dev": "vite", |
|||
"build": "vite build", |
|||
"preview": "vite preview", |
|||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" |
|||
}, |
|||
"dependencies": { |
|||
"axios": "^1.6.2", |
|||
"marked": "^11.1.1", |
|||
"pinia": "^2.1.7", |
|||
"vue": "^3.4.3", |
|||
"vue-router": "^4.2.5" |
|||
}, |
|||
"devDependencies": { |
|||
"@vitejs/plugin-vue": "^5.0.0", |
|||
"@vue/eslint-config-typescript": "^12.0.0", |
|||
"eslint": "^8.56.0", |
|||
"eslint-plugin-vue": "^9.19.2", |
|||
"typescript": "^5.3.3", |
|||
"vite": "^5.0.8", |
|||
"vue-tsc": "^1.8.27" |
|||
} |
|||
} |
|||
|
|||
File diff suppressed because it is too large
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,43 @@ |
|||
<template> |
|||
<router-view /> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { onMounted } from 'vue'; |
|||
import { useUserStore } from './store'; |
|||
import { authApi } from './api/auth'; |
|||
|
|||
const userStore = useUserStore(); |
|||
|
|||
onMounted(async () => { |
|||
// 如果存在token,尝试获取用户信息 |
|||
if (userStore.token) { |
|||
try { |
|||
const response = await authApi.getMe(); |
|||
userStore.setUser(response.user); |
|||
} catch (error) { |
|||
// token无效,清除 |
|||
userStore.logout(); |
|||
} |
|||
} |
|||
}); |
|||
</script> |
|||
|
|||
<style> |
|||
* { |
|||
margin: 0; |
|||
padding: 0; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
body { |
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; |
|||
-webkit-font-smoothing: antialiased; |
|||
-moz-osx-font-smoothing: grayscale; |
|||
} |
|||
|
|||
#app { |
|||
min-height: 100vh; |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,20 @@ |
|||
import api from './index'; |
|||
|
|||
export interface LoginData { |
|||
username: string; |
|||
password: string; |
|||
} |
|||
|
|||
export interface RegisterData { |
|||
username: string; |
|||
password: string; |
|||
email?: string; |
|||
} |
|||
|
|||
export const authApi = { |
|||
login: (data: LoginData) => api.post('/auth/login', data), |
|||
register: (data: RegisterData) => api.post('/auth/register', data), |
|||
logout: () => api.post('/auth/logout'), |
|||
getMe: () => api.get('/auth/me') |
|||
}; |
|||
|
|||
@ -0,0 +1,60 @@ |
|||
import axios from 'axios'; |
|||
import router from '@/router'; |
|||
|
|||
const api = axios.create({ |
|||
baseURL: import.meta.env.VITE_API_URL || '/api', |
|||
timeout: 30000, |
|||
headers: { |
|||
'Content-Type': 'application/json' |
|||
} |
|||
}); |
|||
|
|||
// 请求拦截器
|
|||
api.interceptors.request.use( |
|||
(config) => { |
|||
const token = localStorage.getItem('token'); |
|||
if (token) { |
|||
config.headers.Authorization = `Bearer ${token}`; |
|||
} |
|||
// 如果是FormData,不要设置Content-Type,让浏览器自动设置(包含boundary)
|
|||
// 确保在删除 Content-Type 之前 Authorization header 已经设置
|
|||
if (config.data instanceof FormData) { |
|||
// 确保 headers 对象存在
|
|||
if (!config.headers) { |
|||
config.headers = {}; |
|||
} |
|||
// 保存 Authorization header(如果存在)
|
|||
const authHeader = config.headers.Authorization; |
|||
// 删除 Content-Type,让浏览器自动设置
|
|||
delete config.headers['Content-Type']; |
|||
// 确保 Authorization header 仍然存在
|
|||
if (authHeader) { |
|||
config.headers.Authorization = authHeader; |
|||
} |
|||
} |
|||
return config; |
|||
}, |
|||
(error) => { |
|||
return Promise.reject(error); |
|||
} |
|||
); |
|||
|
|||
// 响应拦截器
|
|||
api.interceptors.response.use( |
|||
(response) => { |
|||
return response.data; |
|||
}, |
|||
async (error) => { |
|||
if (error.response?.status === 401) { |
|||
localStorage.removeItem('token'); |
|||
// 使用 router 跳转,避免在登录页面重复跳转
|
|||
if (router.currentRoute.value.path !== '/login') { |
|||
router.push('/login'); |
|||
} |
|||
} |
|||
return Promise.reject(error); |
|||
} |
|||
); |
|||
|
|||
export default api; |
|||
|
|||
@ -0,0 +1,68 @@ |
|||
import api from './index'; |
|||
|
|||
export interface Project { |
|||
id: number; |
|||
user_id: number; |
|||
title: string; |
|||
description?: string; |
|||
category?: string; |
|||
tags?: string[]; |
|||
file_path: string; |
|||
file_type: 'single_html' | 'folder'; |
|||
status: 'active' | 'deleted'; |
|||
created_at: string; |
|||
updated_at: string; |
|||
attachments?: Attachment[]; |
|||
documents?: Document[]; |
|||
} |
|||
|
|||
export interface Attachment { |
|||
id: number; |
|||
project_id: number; |
|||
file_name: string; |
|||
file_path: string; |
|||
file_size?: number; |
|||
created_at: string; |
|||
} |
|||
|
|||
export interface Document { |
|||
id: number; |
|||
project_id: number; |
|||
title: string; |
|||
content?: string; |
|||
file_path?: string; |
|||
created_at: string; |
|||
updated_at: string; |
|||
} |
|||
|
|||
export interface ProjectQuery { |
|||
category?: string; |
|||
tag?: string; |
|||
search?: string; |
|||
page?: number; |
|||
limit?: number; |
|||
} |
|||
|
|||
export interface ProjectListResponse { |
|||
projects: Project[]; |
|||
total: number; |
|||
} |
|||
|
|||
export const projectApi = { |
|||
list: (query?: ProjectQuery) => api.get<ProjectListResponse>('/projects', { params: query }), |
|||
getById: (id: number) => api.get<Project>(`/projects/${id}`), |
|||
create: (formData: FormData) => { |
|||
// 不要手动设置 Content-Type,让浏览器自动设置(包含 boundary)
|
|||
return api.post<{ project: Project }>('/projects', formData, { |
|||
headers: { |
|||
// 移除 Content-Type,让浏览器自动设置
|
|||
} |
|||
}); |
|||
}, |
|||
update: (id: number, data: { title: string; description?: string; category?: string; tags?: string }) => { |
|||
return api.put<{ project: Project }>(`/projects/${id}`, data); |
|||
}, |
|||
delete: (id: number) => api.delete(`/projects/${id}`), |
|||
preview: (id: number) => api.get(`/projects/${id}/preview`, { responseType: 'text' }) |
|||
}; |
|||
|
|||
@ -0,0 +1,16 @@ |
|||
import api from './index'; |
|||
|
|||
export interface Setting { |
|||
id: number; |
|||
key: string; |
|||
value: string; |
|||
description?: string; |
|||
created_at: string; |
|||
updated_at: string; |
|||
} |
|||
|
|||
export const settingApi = { |
|||
getAll: () => api.get<{ settings: Setting[] }>('/settings'), |
|||
update: (key: string, value: string) => api.put<{ setting: Setting }>(`/settings/${key}`, { value }) |
|||
}; |
|||
|
|||
@ -0,0 +1,126 @@ |
|||
<template> |
|||
<Transition name="drawer"> |
|||
<div v-if="visible" class="drawer-overlay" @click="handleOverlayClick"> |
|||
<div class="drawer-content" @click.stop> |
|||
<div class="drawer-header"> |
|||
<h2>{{ title }}</h2> |
|||
<button @click="handleClose" class="close-btn">×</button> |
|||
</div> |
|||
<div class="drawer-body"> |
|||
<slot></slot> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</Transition> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
defineProps<{ |
|||
visible: boolean; |
|||
title: string; |
|||
}>(); |
|||
|
|||
const emit = defineEmits<{ |
|||
'update:visible': [value: boolean]; |
|||
close: []; |
|||
}>(); |
|||
|
|||
const handleClose = () => { |
|||
emit('update:visible', false); |
|||
emit('close'); |
|||
}; |
|||
|
|||
const handleOverlayClick = () => { |
|||
handleClose(); |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.drawer-overlay { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background: rgba(0, 0, 0, 0.5); |
|||
z-index: 1000; |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
} |
|||
|
|||
.drawer-content { |
|||
background: white; |
|||
width: 400px; |
|||
max-width: 90vw; |
|||
height: 100%; |
|||
display: flex; |
|||
flex-direction: column; |
|||
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1); |
|||
} |
|||
|
|||
.drawer-header { |
|||
padding: 1.5rem; |
|||
border-bottom: 1px solid #eee; |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
} |
|||
|
|||
.drawer-header h2 { |
|||
margin: 0; |
|||
font-size: 1.5rem; |
|||
color: #333; |
|||
} |
|||
|
|||
.close-btn { |
|||
background: none; |
|||
border: none; |
|||
font-size: 2rem; |
|||
color: #999; |
|||
cursor: pointer; |
|||
line-height: 1; |
|||
padding: 0; |
|||
width: 32px; |
|||
height: 32px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.close-btn:hover { |
|||
color: #333; |
|||
} |
|||
|
|||
.drawer-body { |
|||
flex: 1; |
|||
overflow-y: auto; |
|||
padding: 1.5rem; |
|||
} |
|||
|
|||
.drawer-enter-active, |
|||
.drawer-leave-active { |
|||
transition: opacity 0.3s; |
|||
} |
|||
|
|||
.drawer-enter-active .drawer-content, |
|||
.drawer-leave-active .drawer-content { |
|||
transition: transform 0.3s; |
|||
} |
|||
|
|||
.drawer-enter-from { |
|||
opacity: 0; |
|||
} |
|||
|
|||
.drawer-enter-from .drawer-content { |
|||
transform: translateX(100%); |
|||
} |
|||
|
|||
.drawer-leave-to { |
|||
opacity: 0; |
|||
} |
|||
|
|||
.drawer-leave-to .drawer-content { |
|||
transform: translateX(100%); |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,154 @@ |
|||
<template> |
|||
<nav class="navbar"> |
|||
<div class="navbar-container"> |
|||
<div class="navbar-brand" @click="$router.push('/')"> |
|||
<h1>前端部署平台</h1> |
|||
</div> |
|||
<div class="navbar-search"> |
|||
<input |
|||
v-model="searchQuery" |
|||
type="text" |
|||
placeholder="搜索项目..." |
|||
@input="handleSearch" |
|||
class="search-input" |
|||
/> |
|||
</div> |
|||
<div class="navbar-actions"> |
|||
<template v-if="userStore.isAuthenticated"> |
|||
<button v-if="userStore.isSuperAdmin" @click="$router.push('/settings')" class="btn-icon"> |
|||
设置 |
|||
</button> |
|||
<button @click="$router.push('/upload')" class="btn-primary"> |
|||
上传 |
|||
</button> |
|||
<div class="user-menu"> |
|||
<span class="username">{{ userStore.user?.username }}</span> |
|||
<button @click="handleLogout" class="btn-text">登出</button> |
|||
</div> |
|||
</template> |
|||
<template v-else> |
|||
<button @click="$router.push('/login')" class="btn-text">登录</button> |
|||
<button @click="$router.push('/register')" class="btn-primary">注册</button> |
|||
</template> |
|||
</div> |
|||
</div> |
|||
</nav> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref } from 'vue'; |
|||
import { useRouter } from 'vue-router'; |
|||
import { useUserStore } from '@/store'; |
|||
import { projectApi } from '@/api/project'; |
|||
|
|||
const router = useRouter(); |
|||
const userStore = useUserStore(); |
|||
const searchQuery = ref(''); |
|||
|
|||
const handleSearch = () => { |
|||
// 搜索逻辑将在首页实现 |
|||
router.push({ path: '/', query: { search: searchQuery.value } }); |
|||
}; |
|||
|
|||
const handleLogout = () => { |
|||
userStore.logout(); |
|||
router.push('/'); |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.navbar { |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
padding: 1rem 2rem; |
|||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); |
|||
} |
|||
|
|||
.navbar-container { |
|||
max-width: 1400px; |
|||
margin: 0 auto; |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 2rem; |
|||
} |
|||
|
|||
.navbar-brand { |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.navbar-brand h1 { |
|||
color: white; |
|||
font-size: 1.5rem; |
|||
font-weight: 700; |
|||
margin: 0; |
|||
} |
|||
|
|||
.navbar-search { |
|||
flex: 1; |
|||
max-width: 400px; |
|||
} |
|||
|
|||
.search-input { |
|||
width: 100%; |
|||
padding: 0.5rem 1rem; |
|||
border: none; |
|||
border-radius: 8px; |
|||
font-size: 0.9rem; |
|||
outline: none; |
|||
} |
|||
|
|||
.navbar-actions { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 1rem; |
|||
} |
|||
|
|||
.user-menu { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 0.5rem; |
|||
color: white; |
|||
} |
|||
|
|||
.username { |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.btn-primary { |
|||
padding: 0.5rem 1.5rem; |
|||
background: white; |
|||
color: #667eea; |
|||
border: none; |
|||
border-radius: 8px; |
|||
font-weight: 600; |
|||
cursor: pointer; |
|||
transition: transform 0.2s; |
|||
} |
|||
|
|||
.btn-primary:hover { |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
.btn-text { |
|||
padding: 0.5rem 1rem; |
|||
background: transparent; |
|||
color: white; |
|||
border: 1px solid white; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: background 0.2s; |
|||
} |
|||
|
|||
.btn-text:hover { |
|||
background: rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.btn-icon { |
|||
padding: 0.5rem 1rem; |
|||
background: rgba(255, 255, 255, 0.2); |
|||
color: white; |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,192 @@ |
|||
<template> |
|||
<div class="project-card"> |
|||
<div class="card-content" @click="handleClick"> |
|||
<div class="card-header"> |
|||
<h3 class="card-title">{{ project.title }}</h3> |
|||
<span v-if="project.category" class="card-category">{{ project.category }}</span> |
|||
</div> |
|||
<p v-if="project.description" class="card-description">{{ project.description }}</p> |
|||
<div v-if="project.tags && project.tags.length > 0" class="card-tags"> |
|||
<span v-for="tag in project.tags" :key="tag" class="tag">{{ tag }}</span> |
|||
</div> |
|||
<div class="card-footer"> |
|||
<span class="card-date">{{ formatDate(project.created_at) }}</span> |
|||
</div> |
|||
</div> |
|||
<div v-if="canEdit" class="card-actions" @click.stop> |
|||
<button @click="handleEdit" class="action-btn edit-btn">编辑</button> |
|||
<button @click="handleDelete" class="action-btn delete-btn">删除</button> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { computed } from 'vue'; |
|||
import { useRouter } from 'vue-router'; |
|||
import { useUserStore } from '@/store'; |
|||
import { Project, projectApi } from '@/api/project'; |
|||
|
|||
const props = defineProps<{ |
|||
project: Project; |
|||
}>(); |
|||
|
|||
const emit = defineEmits<{ |
|||
deleted: [projectId: number]; |
|||
}>(); |
|||
|
|||
const router = useRouter(); |
|||
const userStore = useUserStore(); |
|||
|
|||
const canEdit = computed(() => { |
|||
return userStore.isAuthenticated && |
|||
(userStore.user?.id === props.project.user_id || userStore.isSuperAdmin); |
|||
}); |
|||
|
|||
const handleClick = () => { |
|||
router.push(`/project/${props.project.id}`); |
|||
}; |
|||
|
|||
const handleEdit = () => { |
|||
router.push(`/upload?edit=${props.project.id}`); |
|||
}; |
|||
|
|||
const handleDelete = async () => { |
|||
if (!confirm('确定要删除这个项目吗?')) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
await projectApi.delete(props.project.id); |
|||
// 触发父组件刷新列表 |
|||
emit('deleted', props.project.id); |
|||
} catch (error: any) { |
|||
alert(error.response?.data?.error?.message || '删除失败'); |
|||
} |
|||
}; |
|||
|
|||
const formatDate = (dateString: string) => { |
|||
const date = new Date(dateString); |
|||
return date.toLocaleDateString('zh-CN'); |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.project-card { |
|||
background: white; |
|||
border-radius: 12px; |
|||
padding: 1.5rem; |
|||
transition: all 0.3s ease; |
|||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|||
position: relative; |
|||
} |
|||
|
|||
.project-card:hover { |
|||
transform: translateY(-4px); |
|||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); |
|||
} |
|||
|
|||
.card-content { |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.card-actions { |
|||
display: flex; |
|||
gap: 0.5rem; |
|||
margin-top: 1rem; |
|||
padding-top: 1rem; |
|||
border-top: 1px solid #eee; |
|||
} |
|||
|
|||
.action-btn { |
|||
padding: 0.5rem 1rem; |
|||
border: none; |
|||
border-radius: 6px; |
|||
cursor: pointer; |
|||
font-size: 0.85rem; |
|||
font-weight: 500; |
|||
transition: all 0.2s; |
|||
} |
|||
|
|||
.edit-btn { |
|||
background: #667eea; |
|||
color: white; |
|||
} |
|||
|
|||
.edit-btn:hover { |
|||
background: #5568d3; |
|||
} |
|||
|
|||
.delete-btn { |
|||
background: #e74c3c; |
|||
color: white; |
|||
} |
|||
|
|||
.delete-btn:hover { |
|||
background: #c0392b; |
|||
} |
|||
|
|||
.card-header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: start; |
|||
margin-bottom: 0.75rem; |
|||
} |
|||
|
|||
.card-title { |
|||
font-size: 1.25rem; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin: 0; |
|||
flex: 1; |
|||
} |
|||
|
|||
.card-category { |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
color: white; |
|||
padding: 0.25rem 0.75rem; |
|||
border-radius: 12px; |
|||
font-size: 0.75rem; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.card-description { |
|||
color: #666; |
|||
font-size: 0.9rem; |
|||
margin: 0.75rem 0; |
|||
line-height: 1.5; |
|||
display: -webkit-box; |
|||
-webkit-line-clamp: 2; |
|||
-webkit-box-orient: vertical; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.card-tags { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 0.5rem; |
|||
margin: 0.75rem 0; |
|||
} |
|||
|
|||
.tag { |
|||
background: #f0f0f0; |
|||
color: #666; |
|||
padding: 0.25rem 0.5rem; |
|||
border-radius: 6px; |
|||
font-size: 0.75rem; |
|||
} |
|||
|
|||
.card-footer { |
|||
margin-top: 1rem; |
|||
padding-top: 1rem; |
|||
border-top: 1px solid #eee; |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
} |
|||
|
|||
.card-date { |
|||
color: #999; |
|||
font-size: 0.8rem; |
|||
} |
|||
</style> |
|||
|
|||
@ -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'); |
|||
|
|||
@ -0,0 +1,78 @@ |
|||
import { createRouter, createWebHashHistory } from 'vue-router'; |
|||
import { useUserStore } from '@/store'; |
|||
import { authApi } from '@/api/auth'; |
|||
|
|||
const router = createRouter({ |
|||
history: createWebHashHistory(), |
|||
routes: [ |
|||
{ |
|||
path: '/', |
|||
name: 'Home', |
|||
component: () => import('@/views/Home.vue') |
|||
}, |
|||
{ |
|||
path: '/login', |
|||
name: 'Login', |
|||
component: () => import('@/views/Login.vue') |
|||
}, |
|||
{ |
|||
path: '/register', |
|||
name: 'Register', |
|||
component: () => import('@/views/Register.vue') |
|||
}, |
|||
{ |
|||
path: '/upload', |
|||
name: 'Upload', |
|||
component: () => import('@/views/Upload.vue'), |
|||
meta: { requiresAuth: true } |
|||
}, |
|||
{ |
|||
path: '/project/:id', |
|||
name: 'Project', |
|||
component: () => import('@/views/Project.vue') |
|||
}, |
|||
{ |
|||
path: '/settings', |
|||
name: 'Settings', |
|||
component: () => import('@/views/Settings.vue'), |
|||
meta: { requiresAuth: true, requiresAdmin: true } |
|||
} |
|||
] |
|||
}); |
|||
|
|||
// 路由守卫
|
|||
router.beforeEach(async (to, from, next) => { |
|||
const userStore = useUserStore(); |
|||
|
|||
// 检查是否需要认证
|
|||
if (to.meta.requiresAuth) { |
|||
if (!userStore.token) { |
|||
next('/login'); |
|||
return; |
|||
} |
|||
|
|||
if (!userStore.user) { |
|||
try { |
|||
const response = await authApi.getMe(); |
|||
userStore.setUser(response); |
|||
} catch (error) { |
|||
userStore.logout(); |
|||
next('/login'); |
|||
return; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 检查是否需要管理员权限
|
|||
if (to.meta.requiresAdmin) { |
|||
if (!userStore.isAuthenticated || !userStore.isSuperAdmin) { |
|||
next('/'); |
|||
return; |
|||
} |
|||
} |
|||
|
|||
next(); |
|||
}); |
|||
|
|||
export default router; |
|||
|
|||
@ -0,0 +1,27 @@ |
|||
import { defineStore } from 'pinia'; |
|||
|
|||
export const useUserStore = defineStore('user', { |
|||
state: () => ({ |
|||
user: null as any, |
|||
token: localStorage.getItem('token') || null |
|||
}), |
|||
getters: { |
|||
isAuthenticated: (state) => !!state.token, |
|||
isSuperAdmin: (state) => state.user?.role === 'super_admin' |
|||
}, |
|||
actions: { |
|||
setUser(user: any) { |
|||
this.user = user; |
|||
}, |
|||
setToken(token: string) { |
|||
this.token = token; |
|||
localStorage.setItem('token', token); |
|||
}, |
|||
logout() { |
|||
this.user = null; |
|||
this.token = null; |
|||
localStorage.removeItem('token'); |
|||
} |
|||
} |
|||
}); |
|||
|
|||
@ -0,0 +1,293 @@ |
|||
<template> |
|||
<div class="home"> |
|||
<NavBar /> |
|||
<div class="container"> |
|||
<div class="filters"> |
|||
<div class="filter-group"> |
|||
<label>分类:</label> |
|||
<select v-model="selectedCategory" @change="loadProjects" class="filter-select"> |
|||
<option value="">全部</option> |
|||
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option> |
|||
</select> |
|||
</div> |
|||
<div class="filter-group"> |
|||
<label>标签:</label> |
|||
<select v-model="selectedTag" @change="loadProjects" class="filter-select"> |
|||
<option value="">全部</option> |
|||
<option v-for="tag in tags" :key="tag" :value="tag">{{ tag }}</option> |
|||
</select> |
|||
</div> |
|||
<div class="filter-group"> |
|||
<button @click="clearFilters" class="btn-clear">清除筛选</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="loading" class="loading"> |
|||
<div class="spinner"></div> |
|||
<p>加载中...</p> |
|||
</div> |
|||
|
|||
<div v-else-if="projects.length === 0" class="empty"> |
|||
<p>暂无项目</p> |
|||
</div> |
|||
|
|||
<div v-else class="projects-grid"> |
|||
<ProjectCard |
|||
v-for="project in projects" |
|||
:key="project.id" |
|||
:project="project" |
|||
@deleted="handleProjectDeleted" |
|||
/> |
|||
</div> |
|||
|
|||
<div v-if="total > limit" class="pagination"> |
|||
<button |
|||
@click="loadPage(currentPage - 1)" |
|||
:disabled="currentPage === 1" |
|||
class="page-btn" |
|||
> |
|||
上一页 |
|||
</button> |
|||
<span class="page-info"> |
|||
第 {{ currentPage }} 页 / 共 {{ Math.ceil(total / limit) }} 页 |
|||
</span> |
|||
<button |
|||
@click="loadPage(currentPage + 1)" |
|||
:disabled="currentPage >= Math.ceil(total / limit)" |
|||
class="page-btn" |
|||
> |
|||
下一页 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, onMounted, watch } from 'vue'; |
|||
import { useRoute, useRouter } from 'vue-router'; |
|||
import NavBar from '@/components/NavBar.vue'; |
|||
import ProjectCard from '@/components/ProjectCard.vue'; |
|||
import { projectApi, Project } from '@/api/project'; |
|||
|
|||
const route = useRoute(); |
|||
const router = useRouter(); |
|||
|
|||
const projects = ref<Project[]>([]); |
|||
const categories = ref<string[]>([]); |
|||
const tags = ref<string[]>([]); |
|||
const selectedCategory = ref(''); |
|||
const selectedTag = ref(''); |
|||
const loading = ref(false); |
|||
const currentPage = ref(1); |
|||
const limit = 20; |
|||
const total = ref(0); |
|||
|
|||
// 从URL获取查询参数 |
|||
const initFilters = () => { |
|||
selectedCategory.value = (route.query.category as string) || ''; |
|||
selectedTag.value = (route.query.tag as string) || ''; |
|||
const search = (route.query.search as string) || ''; |
|||
if (search) { |
|||
// 搜索逻辑 |
|||
} |
|||
}; |
|||
|
|||
const loadProjects = async () => { |
|||
loading.value = true; |
|||
try { |
|||
const query: any = { |
|||
page: currentPage.value, |
|||
limit |
|||
}; |
|||
if (selectedCategory.value) { |
|||
query.category = selectedCategory.value; |
|||
} |
|||
if (selectedTag.value) { |
|||
query.tag = selectedTag.value; |
|||
} |
|||
if (route.query.search) { |
|||
query.search = route.query.search; |
|||
} |
|||
|
|||
const response = await projectApi.list(query); |
|||
projects.value = response.projects; |
|||
total.value = response.total; |
|||
|
|||
// 提取所有分类和标签 |
|||
const allCategories = new Set<string>(); |
|||
const allTags = new Set<string>(); |
|||
response.projects.forEach(project => { |
|||
if (project.category) allCategories.add(project.category); |
|||
if (project.tags) project.tags.forEach(tag => allTags.add(tag)); |
|||
}); |
|||
categories.value = Array.from(allCategories).sort(); |
|||
tags.value = Array.from(allTags).sort(); |
|||
|
|||
// 更新URL |
|||
router.replace({ query: { ...route.query, ...query } }); |
|||
} catch (error) { |
|||
console.error('加载项目失败:', error); |
|||
} finally { |
|||
loading.value = false; |
|||
} |
|||
}; |
|||
|
|||
const loadPage = (page: number) => { |
|||
currentPage.value = page; |
|||
loadProjects(); |
|||
}; |
|||
|
|||
const clearFilters = () => { |
|||
selectedCategory.value = ''; |
|||
selectedTag.value = ''; |
|||
currentPage.value = 1; |
|||
router.replace({ query: {} }); |
|||
loadProjects(); |
|||
}; |
|||
|
|||
const handleProjectDeleted = () => { |
|||
// 如果当前页没有项目了,且不是第一页,则返回上一页 |
|||
if (projects.value.length === 1 && currentPage.value > 1) { |
|||
currentPage.value--; |
|||
} |
|||
loadProjects(); |
|||
}; |
|||
|
|||
// 监听路由变化 |
|||
watch(() => route.query, () => { |
|||
initFilters(); |
|||
loadProjects(); |
|||
}, { immediate: true }); |
|||
|
|||
onMounted(() => { |
|||
initFilters(); |
|||
loadProjects(); |
|||
}); |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.home { |
|||
min-height: 100vh; |
|||
background: #f5f5f5; |
|||
} |
|||
|
|||
.container { |
|||
max-width: 1400px; |
|||
margin: 0 auto; |
|||
padding: 2rem; |
|||
} |
|||
|
|||
.filters { |
|||
background: white; |
|||
padding: 1.5rem; |
|||
border-radius: 12px; |
|||
margin-bottom: 2rem; |
|||
display: flex; |
|||
gap: 1.5rem; |
|||
align-items: center; |
|||
flex-wrap: wrap; |
|||
} |
|||
|
|||
.filter-group { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 0.5rem; |
|||
} |
|||
|
|||
.filter-group label { |
|||
font-weight: 500; |
|||
color: #666; |
|||
} |
|||
|
|||
.filter-select { |
|||
padding: 0.5rem 1rem; |
|||
border: 1px solid #ddd; |
|||
border-radius: 8px; |
|||
font-size: 0.9rem; |
|||
outline: none; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
.filter-select:focus { |
|||
border-color: #667eea; |
|||
} |
|||
|
|||
.btn-clear { |
|||
padding: 0.5rem 1rem; |
|||
background: #f0f0f0; |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
color: #666; |
|||
} |
|||
|
|||
.btn-clear:hover { |
|||
background: #e0e0e0; |
|||
} |
|||
|
|||
.loading { |
|||
text-align: center; |
|||
padding: 4rem; |
|||
} |
|||
|
|||
.spinner { |
|||
width: 40px; |
|||
height: 40px; |
|||
border: 4px solid #f3f3f3; |
|||
border-top: 4px solid #667eea; |
|||
border-radius: 50%; |
|||
animation: spin 1s linear infinite; |
|||
margin: 0 auto 1rem; |
|||
} |
|||
|
|||
@keyframes spin { |
|||
0% { transform: rotate(0deg); } |
|||
100% { transform: rotate(360deg); } |
|||
} |
|||
|
|||
.empty { |
|||
text-align: center; |
|||
padding: 4rem; |
|||
color: #999; |
|||
} |
|||
|
|||
.projects-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
|||
gap: 1.5rem; |
|||
margin-bottom: 2rem; |
|||
} |
|||
|
|||
.pagination { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
gap: 1rem; |
|||
padding: 2rem; |
|||
} |
|||
|
|||
.page-btn { |
|||
padding: 0.5rem 1.5rem; |
|||
background: white; |
|||
border: 1px solid #ddd; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: all 0.2s; |
|||
} |
|||
|
|||
.page-btn:hover:not(:disabled) { |
|||
background: #667eea; |
|||
color: white; |
|||
border-color: #667eea; |
|||
} |
|||
|
|||
.page-btn:disabled { |
|||
opacity: 0.5; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.page-info { |
|||
color: #666; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,170 @@ |
|||
<template> |
|||
<div class="login-page"> |
|||
<div class="login-container"> |
|||
<h1>登录</h1> |
|||
<form @submit.prevent="handleLogin" class="login-form"> |
|||
<div class="form-group"> |
|||
<label>用户名</label> |
|||
<input |
|||
v-model="form.username" |
|||
type="text" |
|||
required |
|||
placeholder="请输入用户名" |
|||
class="form-input" |
|||
/> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label>密码</label> |
|||
<input |
|||
v-model="form.password" |
|||
type="password" |
|||
required |
|||
placeholder="请输入密码" |
|||
class="form-input" |
|||
/> |
|||
</div> |
|||
<div v-if="error" class="error-message">{{ error }}</div> |
|||
<button type="submit" :disabled="loading" class="submit-btn"> |
|||
{{ loading ? '登录中...' : '登录' }} |
|||
</button> |
|||
<p class="form-footer"> |
|||
还没有账号? |
|||
<router-link to="/register">立即注册</router-link> |
|||
</p> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref } from 'vue'; |
|||
import { useRouter } from 'vue-router'; |
|||
import { useUserStore } from '@/store'; |
|||
import { authApi } from '@/api/auth'; |
|||
|
|||
const router = useRouter(); |
|||
const userStore = useUserStore(); |
|||
|
|||
const form = ref({ |
|||
username: '', |
|||
password: '' |
|||
}); |
|||
const loading = ref(false); |
|||
const error = ref(''); |
|||
|
|||
const handleLogin = async () => { |
|||
loading.value = true; |
|||
error.value = ''; |
|||
|
|||
try { |
|||
const response = await authApi.login(form.value); |
|||
userStore.setToken(response.token); |
|||
userStore.setUser(response.user); |
|||
router.push('/'); |
|||
} catch (err: any) { |
|||
error.value = err.response?.data?.error?.message || '登录失败,请重试'; |
|||
} finally { |
|||
loading.value = false; |
|||
} |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.login-page { |
|||
min-height: 100vh; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
padding: 2rem; |
|||
} |
|||
|
|||
.login-container { |
|||
background: white; |
|||
border-radius: 16px; |
|||
padding: 3rem; |
|||
width: 100%; |
|||
max-width: 400px; |
|||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); |
|||
} |
|||
|
|||
.login-container h1 { |
|||
text-align: center; |
|||
margin-bottom: 2rem; |
|||
color: #333; |
|||
font-size: 2rem; |
|||
} |
|||
|
|||
.login-form { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 1.5rem; |
|||
} |
|||
|
|||
.form-group { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 0.5rem; |
|||
} |
|||
|
|||
.form-group label { |
|||
font-weight: 500; |
|||
color: #666; |
|||
} |
|||
|
|||
.form-input { |
|||
padding: 0.75rem; |
|||
border: 1px solid #ddd; |
|||
border-radius: 8px; |
|||
font-size: 1rem; |
|||
outline: none; |
|||
transition: border-color 0.2s; |
|||
} |
|||
|
|||
.form-input:focus { |
|||
border-color: #667eea; |
|||
} |
|||
|
|||
.error-message { |
|||
color: #e74c3c; |
|||
font-size: 0.9rem; |
|||
text-align: center; |
|||
} |
|||
|
|||
.submit-btn { |
|||
padding: 0.75rem; |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
color: white; |
|||
border: none; |
|||
border-radius: 8px; |
|||
font-size: 1rem; |
|||
font-weight: 600; |
|||
cursor: pointer; |
|||
transition: transform 0.2s; |
|||
} |
|||
|
|||
.submit-btn:hover:not(:disabled) { |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
.submit-btn:disabled { |
|||
opacity: 0.6; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.form-footer { |
|||
text-align: center; |
|||
color: #666; |
|||
margin-top: 1rem; |
|||
} |
|||
|
|||
.form-footer a { |
|||
color: #667eea; |
|||
text-decoration: none; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.form-footer a:hover { |
|||
text-decoration: underline; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,380 @@ |
|||
<template> |
|||
<div class="project-page"> |
|||
<iframe |
|||
v-if="previewUrl" |
|||
:src="previewUrl" |
|||
class="preview-iframe" |
|||
sandbox="allow-scripts allow-same-origin allow-forms" |
|||
></iframe> |
|||
<div class="control-buttons"> |
|||
<button @click="goHome" class="control-btn back-btn"> |
|||
<span>←</span> |
|||
</button> |
|||
<button @click="drawerVisible = true" class="control-btn info-btn"> |
|||
<span>ℹ️</span> |
|||
</button> |
|||
</div> |
|||
<Drawer v-model:visible="drawerVisible" title="项目信息" @close="handleDrawerClose"> |
|||
<div v-if="project" class="project-info"> |
|||
<div class="info-section"> |
|||
<h3>{{ project.title }}</h3> |
|||
<p v-if="project.description" class="description">{{ project.description }}</p> |
|||
</div> |
|||
<div v-if="project.category" class="info-item"> |
|||
<strong>分类:</strong>{{ project.category }} |
|||
</div> |
|||
<div v-if="project.tags && project.tags.length > 0" class="info-item"> |
|||
<strong>标签:</strong> |
|||
<span v-for="tag in project.tags" :key="tag" class="tag">{{ tag }}</span> |
|||
</div> |
|||
<div class="info-item"> |
|||
<strong>创建时间:</strong>{{ formatDate(project.created_at) }} |
|||
</div> |
|||
|
|||
<div v-if="canEdit" class="info-section actions-section"> |
|||
<button @click="handleEdit" class="action-btn edit-btn">编辑</button> |
|||
<button @click="handleDelete" class="action-btn delete-btn">删除</button> |
|||
</div> |
|||
|
|||
<div v-if="project.attachments && project.attachments.length > 0" class="info-section"> |
|||
<h4>附件</h4> |
|||
<ul class="attachment-list"> |
|||
<li v-for="attachment in project.attachments" :key="attachment.id" class="attachment-item"> |
|||
<span>{{ attachment.file_name }}</span> |
|||
<a |
|||
:href="`/api/files/attachment/${attachment.id}`" |
|||
:download="attachment.file_name" |
|||
class="download-link" |
|||
> |
|||
下载 |
|||
</a> |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
|
|||
<div v-if="project.documents && project.documents.length > 0" class="info-section"> |
|||
<h4>文档</h4> |
|||
<ul class="document-list"> |
|||
<li |
|||
v-for="document in project.documents" |
|||
:key="document.id" |
|||
class="document-item" |
|||
@click="viewDocument(document)" |
|||
> |
|||
{{ document.title }} |
|||
</li> |
|||
</ul> |
|||
</div> |
|||
</div> |
|||
<div v-else-if="loading" class="loading">加载中...</div> |
|||
<div v-else class="error">加载失败</div> |
|||
</Drawer> |
|||
|
|||
<Drawer v-model:visible="documentVisible" title="文档" @close="documentVisible = false"> |
|||
<div v-if="currentDocument" class="document-viewer"> |
|||
<div v-html="renderedMarkdown" class="markdown-content"></div> |
|||
</div> |
|||
</Drawer> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, onMounted, computed } from 'vue'; |
|||
import { useRoute, useRouter } from 'vue-router'; |
|||
import { marked } from 'marked'; |
|||
import Drawer from '@/components/Drawer.vue'; |
|||
import { projectApi, Project, Document } from '@/api/project'; |
|||
|
|||
const route = useRoute(); |
|||
const router = useRouter(); |
|||
const projectId = parseInt(route.params.id as string); |
|||
|
|||
const project = ref<Project | null>(null); |
|||
const loading = ref(false); |
|||
const drawerVisible = ref(false); |
|||
const documentVisible = ref(false); |
|||
const currentDocument = ref<Document | null>(null); |
|||
const previewUrl = ref(''); |
|||
|
|||
const renderedMarkdown = computed(() => { |
|||
if (!currentDocument.value?.content) return ''; |
|||
return marked(currentDocument.value.content); |
|||
}); |
|||
|
|||
const loadProject = async () => { |
|||
loading.value = true; |
|||
try { |
|||
const data = await projectApi.getById(projectId); |
|||
project.value = data; |
|||
previewUrl.value = `/api/projects/${projectId}/preview`; |
|||
} catch (error) { |
|||
console.error('加载项目失败:', error); |
|||
} finally { |
|||
loading.value = false; |
|||
} |
|||
}; |
|||
|
|||
const viewDocument = (document: Document) => { |
|||
currentDocument.value = document; |
|||
documentVisible.value = true; |
|||
drawerVisible.value = false; |
|||
}; |
|||
|
|||
const handleDrawerClose = () => { |
|||
drawerVisible.value = false; |
|||
}; |
|||
|
|||
const handleEdit = () => { |
|||
router.push(`/upload?edit=${projectId}`); |
|||
}; |
|||
|
|||
const goHome = () => { |
|||
router.push('/'); |
|||
}; |
|||
|
|||
const handleDelete = async () => { |
|||
if (!confirm('确定要删除这个项目吗?')) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
await projectApi.delete(projectId); |
|||
router.push('/'); |
|||
} catch (error: any) { |
|||
alert(error.response?.data?.error?.message || '删除失败'); |
|||
} |
|||
}; |
|||
|
|||
const formatDate = (dateString: string) => { |
|||
const date = new Date(dateString); |
|||
return date.toLocaleString('zh-CN'); |
|||
}; |
|||
|
|||
onMounted(() => { |
|||
loadProject(); |
|||
}); |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.project-page { |
|||
position: relative; |
|||
width: 100vw; |
|||
height: 100vh; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.preview-iframe { |
|||
width: 100%; |
|||
height: 100%; |
|||
border: none; |
|||
} |
|||
|
|||
.control-buttons { |
|||
position: fixed; |
|||
right: 0; |
|||
top: 50%; |
|||
transform: translateY(-50%) translateX(calc(100% - 20px)); |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 0.75rem; |
|||
z-index: 999; |
|||
transition: transform 0.3s ease; |
|||
padding-right: 0.5rem; |
|||
} |
|||
|
|||
.control-buttons:hover { |
|||
transform: translateY(-50%) translateX(0); |
|||
} |
|||
|
|||
.control-btn { |
|||
width: 44px; |
|||
height: 44px; |
|||
border-radius: 50%; |
|||
background: rgba(102, 126, 234, 0.7); |
|||
backdrop-filter: blur(8px); |
|||
color: white; |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
cursor: pointer; |
|||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); |
|||
font-size: 1.2rem; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
transition: all 0.3s ease; |
|||
opacity: 0.8; |
|||
} |
|||
|
|||
.control-btn:hover { |
|||
opacity: 1; |
|||
background: rgba(102, 126, 234, 0.9); |
|||
transform: scale(1.1); |
|||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); |
|||
} |
|||
|
|||
.project-info { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 1.5rem; |
|||
} |
|||
|
|||
.info-section { |
|||
margin-bottom: 1rem; |
|||
} |
|||
|
|||
.info-section h3 { |
|||
margin: 0 0 0.5rem 0; |
|||
color: #333; |
|||
font-size: 1.5rem; |
|||
} |
|||
|
|||
.info-section h4 { |
|||
margin: 0 0 0.75rem 0; |
|||
color: #666; |
|||
font-size: 1.1rem; |
|||
} |
|||
|
|||
.description { |
|||
color: #666; |
|||
line-height: 1.6; |
|||
margin: 0.5rem 0; |
|||
} |
|||
|
|||
.info-item { |
|||
margin-bottom: 0.75rem; |
|||
color: #666; |
|||
} |
|||
|
|||
.info-item strong { |
|||
color: #333; |
|||
} |
|||
|
|||
.tag { |
|||
display: inline-block; |
|||
background: #f0f0f0; |
|||
color: #666; |
|||
padding: 0.25rem 0.5rem; |
|||
border-radius: 4px; |
|||
font-size: 0.85rem; |
|||
margin-right: 0.5rem; |
|||
} |
|||
|
|||
.actions-section { |
|||
display: flex; |
|||
gap: 0.75rem; |
|||
margin-top: 1.5rem; |
|||
padding-top: 1.5rem; |
|||
border-top: 1px solid #eee; |
|||
} |
|||
|
|||
.action-btn { |
|||
padding: 0.75rem 1.5rem; |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
font-weight: 500; |
|||
transition: all 0.2s; |
|||
} |
|||
|
|||
.edit-btn { |
|||
background: #667eea; |
|||
color: white; |
|||
} |
|||
|
|||
.edit-btn:hover { |
|||
background: #5568d3; |
|||
} |
|||
|
|||
.delete-btn { |
|||
background: #e74c3c; |
|||
color: white; |
|||
} |
|||
|
|||
.delete-btn:hover { |
|||
background: #c0392b; |
|||
} |
|||
|
|||
.attachment-list, |
|||
.document-list { |
|||
list-style: none; |
|||
padding: 0; |
|||
margin: 0; |
|||
} |
|||
|
|||
.attachment-item, |
|||
.document-item { |
|||
padding: 0.75rem; |
|||
background: #f9f9f9; |
|||
border-radius: 6px; |
|||
margin-bottom: 0.5rem; |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
} |
|||
|
|||
.document-item { |
|||
cursor: pointer; |
|||
transition: background 0.2s; |
|||
} |
|||
|
|||
.document-item:hover { |
|||
background: #f0f0f0; |
|||
} |
|||
|
|||
.download-link { |
|||
color: #667eea; |
|||
text-decoration: none; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.download-link:hover { |
|||
text-decoration: underline; |
|||
} |
|||
|
|||
.loading, |
|||
.error { |
|||
text-align: center; |
|||
padding: 2rem; |
|||
color: #999; |
|||
} |
|||
|
|||
.document-viewer { |
|||
padding: 1rem 0; |
|||
} |
|||
|
|||
.markdown-content { |
|||
line-height: 1.8; |
|||
color: #333; |
|||
} |
|||
|
|||
.markdown-content :deep(h1), |
|||
.markdown-content :deep(h2), |
|||
.markdown-content :deep(h3) { |
|||
margin-top: 1.5rem; |
|||
margin-bottom: 1rem; |
|||
color: #333; |
|||
} |
|||
|
|||
.markdown-content :deep(p) { |
|||
margin-bottom: 1rem; |
|||
} |
|||
|
|||
.markdown-content :deep(code) { |
|||
background: #f4f4f4; |
|||
padding: 0.2rem 0.4rem; |
|||
border-radius: 4px; |
|||
font-family: 'Courier New', monospace; |
|||
} |
|||
|
|||
.markdown-content :deep(pre) { |
|||
background: #f4f4f4; |
|||
padding: 1rem; |
|||
border-radius: 6px; |
|||
overflow-x: auto; |
|||
} |
|||
|
|||
.markdown-content :deep(ul), |
|||
.markdown-content :deep(ol) { |
|||
margin-left: 1.5rem; |
|||
margin-bottom: 1rem; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,180 @@ |
|||
<template> |
|||
<div class="register-page"> |
|||
<div class="register-container"> |
|||
<h1>注册</h1> |
|||
<form @submit.prevent="handleRegister" class="register-form"> |
|||
<div class="form-group"> |
|||
<label>用户名</label> |
|||
<input |
|||
v-model="form.username" |
|||
type="text" |
|||
required |
|||
placeholder="请输入用户名" |
|||
class="form-input" |
|||
/> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label>邮箱(可选)</label> |
|||
<input |
|||
v-model="form.email" |
|||
type="email" |
|||
placeholder="请输入邮箱" |
|||
class="form-input" |
|||
/> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label>密码</label> |
|||
<input |
|||
v-model="form.password" |
|||
type="password" |
|||
required |
|||
placeholder="请输入密码" |
|||
class="form-input" |
|||
/> |
|||
</div> |
|||
<div v-if="error" class="error-message">{{ error }}</div> |
|||
<button type="submit" :disabled="loading" class="submit-btn"> |
|||
{{ loading ? '注册中...' : '注册' }} |
|||
</button> |
|||
<p class="form-footer"> |
|||
已有账号? |
|||
<router-link to="/login">立即登录</router-link> |
|||
</p> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref } from 'vue'; |
|||
import { useRouter } from 'vue-router'; |
|||
import { useUserStore } from '@/store'; |
|||
import { authApi } from '@/api/auth'; |
|||
|
|||
const router = useRouter(); |
|||
const userStore = useUserStore(); |
|||
|
|||
const form = ref({ |
|||
username: '', |
|||
email: '', |
|||
password: '' |
|||
}); |
|||
const loading = ref(false); |
|||
const error = ref(''); |
|||
|
|||
const handleRegister = async () => { |
|||
loading.value = true; |
|||
error.value = ''; |
|||
|
|||
try { |
|||
const response = await authApi.register(form.value); |
|||
userStore.setToken(response.token); |
|||
userStore.setUser(response.user); |
|||
router.push('/'); |
|||
} catch (err: any) { |
|||
error.value = err.response?.data?.error?.message || '注册失败,请重试'; |
|||
} finally { |
|||
loading.value = false; |
|||
} |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.register-page { |
|||
min-height: 100vh; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
padding: 2rem; |
|||
} |
|||
|
|||
.register-container { |
|||
background: white; |
|||
border-radius: 16px; |
|||
padding: 3rem; |
|||
width: 100%; |
|||
max-width: 400px; |
|||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); |
|||
} |
|||
|
|||
.register-container h1 { |
|||
text-align: center; |
|||
margin-bottom: 2rem; |
|||
color: #333; |
|||
font-size: 2rem; |
|||
} |
|||
|
|||
.register-form { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 1.5rem; |
|||
} |
|||
|
|||
.form-group { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 0.5rem; |
|||
} |
|||
|
|||
.form-group label { |
|||
font-weight: 500; |
|||
color: #666; |
|||
} |
|||
|
|||
.form-input { |
|||
padding: 0.75rem; |
|||
border: 1px solid #ddd; |
|||
border-radius: 8px; |
|||
font-size: 1rem; |
|||
outline: none; |
|||
transition: border-color 0.2s; |
|||
} |
|||
|
|||
.form-input:focus { |
|||
border-color: #667eea; |
|||
} |
|||
|
|||
.error-message { |
|||
color: #e74c3c; |
|||
font-size: 0.9rem; |
|||
text-align: center; |
|||
} |
|||
|
|||
.submit-btn { |
|||
padding: 0.75rem; |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
color: white; |
|||
border: none; |
|||
border-radius: 8px; |
|||
font-size: 1rem; |
|||
font-weight: 600; |
|||
cursor: pointer; |
|||
transition: transform 0.2s; |
|||
} |
|||
|
|||
.submit-btn:hover:not(:disabled) { |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
.submit-btn:disabled { |
|||
opacity: 0.6; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.form-footer { |
|||
text-align: center; |
|||
color: #666; |
|||
margin-top: 1rem; |
|||
} |
|||
|
|||
.form-footer a { |
|||
color: #667eea; |
|||
text-decoration: none; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.form-footer a:hover { |
|||
text-decoration: underline; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,198 @@ |
|||
<template> |
|||
<div class="settings-page"> |
|||
<NavBar /> |
|||
<div class="container"> |
|||
<h1>系统设置</h1> |
|||
<div v-if="loading" class="loading">加载中...</div> |
|||
<div v-else-if="error" class="error">{{ error }}</div> |
|||
<div v-else class="settings-content"> |
|||
<div class="setting-item"> |
|||
<div class="setting-info"> |
|||
<h3>允许注册</h3> |
|||
<p>控制是否允许新用户注册</p> |
|||
</div> |
|||
<label class="switch"> |
|||
<input |
|||
type="checkbox" |
|||
:checked="settings.allow_register === 'true'" |
|||
@change="updateSetting('allow_register', $event)" |
|||
/> |
|||
<span class="slider"></span> |
|||
</label> |
|||
</div> |
|||
<div class="setting-item"> |
|||
<div class="setting-info"> |
|||
<h3>允许上传</h3> |
|||
<p>控制是否允许用户上传网页</p> |
|||
</div> |
|||
<label class="switch"> |
|||
<input |
|||
type="checkbox" |
|||
:checked="settings.allow_upload === 'true'" |
|||
@change="updateSetting('allow_upload', $event)" |
|||
/> |
|||
<span class="slider"></span> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, onMounted, reactive } from 'vue'; |
|||
import NavBar from '@/components/NavBar.vue'; |
|||
import { settingApi } from '@/api/setting'; |
|||
|
|||
const loading = ref(false); |
|||
const error = ref(''); |
|||
const settings = reactive<Record<string, string>>({ |
|||
allow_register: 'true', |
|||
allow_upload: 'true' |
|||
}); |
|||
|
|||
const loadSettings = async () => { |
|||
loading.value = true; |
|||
error.value = ''; |
|||
try { |
|||
const response = await settingApi.getAll(); |
|||
response.settings.forEach((setting: { key: string; value: string }) => { |
|||
settings[setting.key] = setting.value; |
|||
}); |
|||
} catch (err: any) { |
|||
const errorMessage = err.response?.data?.error?.message || '加载设置失败'; |
|||
error.value = errorMessage; |
|||
// 如果是权限错误,显示友好提示 |
|||
if (err.response?.status === 403) { |
|||
error.value = '您没有权限访问设置页面'; |
|||
} else if (err.response?.status === 401) { |
|||
error.value = '请先登录'; |
|||
} |
|||
} finally { |
|||
loading.value = false; |
|||
} |
|||
}; |
|||
|
|||
const updateSetting = async (key: string, event: Event) => { |
|||
const target = event.target as HTMLInputElement; |
|||
const value = target.checked ? 'true' : 'false'; |
|||
|
|||
try { |
|||
await settingApi.update(key, value); |
|||
settings[key] = value; |
|||
} catch (err: any) { |
|||
error.value = err.response?.data?.error?.message || '更新设置失败'; |
|||
// 恢复原值 |
|||
target.checked = settings[key] === 'true'; |
|||
} |
|||
}; |
|||
|
|||
onMounted(() => { |
|||
loadSettings(); |
|||
}); |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.settings-page { |
|||
min-height: 100vh; |
|||
background: #f5f5f5; |
|||
} |
|||
|
|||
.container { |
|||
max-width: 800px; |
|||
margin: 0 auto; |
|||
padding: 2rem; |
|||
} |
|||
|
|||
.container h1 { |
|||
margin-bottom: 2rem; |
|||
color: #333; |
|||
} |
|||
|
|||
.settings-content { |
|||
background: white; |
|||
border-radius: 12px; |
|||
padding: 2rem; |
|||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|||
} |
|||
|
|||
.setting-item { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 1.5rem 0; |
|||
border-bottom: 1px solid #eee; |
|||
} |
|||
|
|||
.setting-item:last-child { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
.setting-info h3 { |
|||
margin: 0 0 0.5rem 0; |
|||
color: #333; |
|||
font-size: 1.25rem; |
|||
} |
|||
|
|||
.setting-info p { |
|||
margin: 0; |
|||
color: #666; |
|||
font-size: 0.9rem; |
|||
} |
|||
|
|||
.switch { |
|||
position: relative; |
|||
display: inline-block; |
|||
width: 60px; |
|||
height: 34px; |
|||
} |
|||
|
|||
.switch input { |
|||
opacity: 0; |
|||
width: 0; |
|||
height: 0; |
|||
} |
|||
|
|||
.slider { |
|||
position: absolute; |
|||
cursor: pointer; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background-color: #ccc; |
|||
transition: 0.4s; |
|||
border-radius: 34px; |
|||
} |
|||
|
|||
.slider:before { |
|||
position: absolute; |
|||
content: ''; |
|||
height: 26px; |
|||
width: 26px; |
|||
left: 4px; |
|||
bottom: 4px; |
|||
background-color: white; |
|||
transition: 0.4s; |
|||
border-radius: 50%; |
|||
} |
|||
|
|||
input:checked + .slider { |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
} |
|||
|
|||
input:checked + .slider:before { |
|||
transform: translateX(26px); |
|||
} |
|||
|
|||
.loading, |
|||
.error { |
|||
text-align: center; |
|||
padding: 2rem; |
|||
color: #999; |
|||
} |
|||
|
|||
.error { |
|||
color: #e74c3c; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,491 @@ |
|||
<template> |
|||
<div class="upload-page"> |
|||
<NavBar /> |
|||
<div class="container"> |
|||
<h1>{{ isEditMode ? '编辑项目' : '上传项目' }}</h1> |
|||
<form @submit.prevent="handleSubmit" class="upload-form"> |
|||
<div class="form-section"> |
|||
<h2>基本信息</h2> |
|||
<div class="form-group"> |
|||
<label>标题 *</label> |
|||
<input |
|||
v-model="form.title" |
|||
type="text" |
|||
required |
|||
placeholder="请输入项目标题" |
|||
class="form-input" |
|||
/> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label>描述</label> |
|||
<textarea |
|||
v-model="form.description" |
|||
placeholder="请输入项目描述" |
|||
rows="4" |
|||
class="form-textarea" |
|||
></textarea> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label>分类</label> |
|||
<input |
|||
v-model="form.category" |
|||
type="text" |
|||
placeholder="请输入分类" |
|||
class="form-input" |
|||
/> |
|||
</div> |
|||
<div class="form-group"> |
|||
<label>标签(用逗号分隔)</label> |
|||
<input |
|||
v-model="form.tags" |
|||
type="text" |
|||
placeholder="例如:vue, react, demo" |
|||
class="form-input" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="!isEditMode" class="form-section"> |
|||
<h2>网页文件 *</h2> |
|||
<div class="file-upload"> |
|||
<input |
|||
ref="fileInput" |
|||
type="file" |
|||
accept=".html,.zip" |
|||
@change="handleFileChange" |
|||
class="file-input" |
|||
/> |
|||
<div class="file-display"> |
|||
<p v-if="form.file">{{ form.file.name }}</p> |
|||
<p v-else class="placeholder">请选择HTML文件或ZIP压缩包</p> |
|||
</div> |
|||
<button type="button" @click="$refs.fileInput.click()" class="btn-select"> |
|||
选择文件 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="!isEditMode" class="form-section"> |
|||
<h2>附件(可选)</h2> |
|||
<div class="file-upload"> |
|||
<input |
|||
ref="attachmentInput" |
|||
type="file" |
|||
multiple |
|||
@change="handleAttachmentChange" |
|||
class="file-input" |
|||
/> |
|||
<div class="file-list"> |
|||
<div v-for="(file, index) in form.attachments" :key="index" class="file-item"> |
|||
<span>{{ file.name }}</span> |
|||
<button type="button" @click="removeAttachment(index)" class="btn-remove">×</button> |
|||
</div> |
|||
</div> |
|||
<button type="button" @click="$refs.attachmentInput.click()" class="btn-select"> |
|||
添加附件 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="!isEditMode" class="form-section"> |
|||
<h2>Markdown文档(可选)</h2> |
|||
<div class="file-upload"> |
|||
<input |
|||
ref="documentInput" |
|||
type="file" |
|||
accept=".md,.markdown" |
|||
multiple |
|||
@change="handleDocumentChange" |
|||
class="file-input" |
|||
/> |
|||
<div class="file-list"> |
|||
<div v-for="(file, index) in form.documents" :key="index" class="file-item"> |
|||
<span>{{ file.name }}</span> |
|||
<button type="button" @click="removeDocument(index)" class="btn-remove">×</button> |
|||
</div> |
|||
</div> |
|||
<button type="button" @click="$refs.documentInput.click()" class="btn-select"> |
|||
添加文档 |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="error" class="error-message">{{ error }}</div> |
|||
<div v-if="uploadProgress > 0 && uploadProgress < 100" class="progress"> |
|||
<div class="progress-bar" :style="{ width: uploadProgress + '%' }"></div> |
|||
<span class="progress-text">{{ uploadProgress }}%</span> |
|||
</div> |
|||
|
|||
<div class="form-actions"> |
|||
<button type="button" @click="$router.back()" class="btn-cancel">取消</button> |
|||
<button type="submit" :disabled="loading || (!isEditMode && !form.file)" class="btn-submit"> |
|||
{{ loading ? (isEditMode ? '保存中...' : '上传中...') : (isEditMode ? '保存' : '上传') }} |
|||
</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, onMounted } from 'vue'; |
|||
import { useRoute, useRouter } from 'vue-router'; |
|||
import NavBar from '@/components/NavBar.vue'; |
|||
import { projectApi } from '@/api/project'; |
|||
|
|||
const route = useRoute(); |
|||
const router = useRouter(); |
|||
|
|||
const isEditMode = ref(false); |
|||
const editProjectId = ref<number | null>(null); |
|||
|
|||
const form = ref({ |
|||
title: '', |
|||
description: '', |
|||
category: '', |
|||
tags: '', |
|||
file: null as File | null, |
|||
attachments: [] as File[], |
|||
documents: [] as File[] |
|||
}); |
|||
|
|||
const loading = ref(false); |
|||
const error = ref(''); |
|||
const uploadProgress = ref(0); |
|||
|
|||
const handleFileChange = (e: Event) => { |
|||
const target = e.target as HTMLInputElement; |
|||
if (target.files && target.files[0]) { |
|||
form.value.file = target.files[0]; |
|||
} |
|||
}; |
|||
|
|||
const handleAttachmentChange = (e: Event) => { |
|||
const target = e.target as HTMLInputElement; |
|||
if (target.files) { |
|||
form.value.attachments = Array.from(target.files); |
|||
} |
|||
}; |
|||
|
|||
const handleDocumentChange = (e: Event) => { |
|||
const target = e.target as HTMLInputElement; |
|||
if (target.files) { |
|||
form.value.documents = Array.from(target.files); |
|||
} |
|||
}; |
|||
|
|||
const removeAttachment = (index: number) => { |
|||
form.value.attachments.splice(index, 1); |
|||
}; |
|||
|
|||
const removeDocument = (index: number) => { |
|||
form.value.documents.splice(index, 1); |
|||
}; |
|||
|
|||
const loadProjectForEdit = async (id: number) => { |
|||
try { |
|||
const project = await projectApi.getById(id); |
|||
form.value.title = project.title; |
|||
form.value.description = project.description || ''; |
|||
form.value.category = project.category || ''; |
|||
form.value.tags = project.tags ? project.tags.join(', ') : ''; |
|||
// 编辑模式下文件是可选的 |
|||
} catch (err: any) { |
|||
error.value = '加载项目失败'; |
|||
} |
|||
}; |
|||
|
|||
const handleSubmit = async () => { |
|||
if (!isEditMode.value && !form.value.file) { |
|||
error.value = '请选择网页文件'; |
|||
return; |
|||
} |
|||
|
|||
loading.value = true; |
|||
error.value = ''; |
|||
uploadProgress.value = 0; |
|||
|
|||
try { |
|||
if (isEditMode.value && editProjectId.value) { |
|||
// 编辑模式:只更新基本信息 |
|||
const updateData: any = { |
|||
title: form.value.title, |
|||
description: form.value.description, |
|||
category: form.value.category, |
|||
tags: form.value.tags |
|||
}; |
|||
|
|||
const response = await projectApi.update(editProjectId.value, updateData); |
|||
router.push(`/project/${editProjectId.value}`); |
|||
return; |
|||
} |
|||
|
|||
// 创建模式:上传文件 |
|||
const formData = new FormData(); |
|||
formData.append('title', form.value.title); |
|||
if (form.value.description) formData.append('description', form.value.description); |
|||
if (form.value.category) formData.append('category', form.value.category); |
|||
if (form.value.tags) formData.append('tags', form.value.tags); |
|||
|
|||
// 判断是HTML还是ZIP |
|||
const isZip = form.value.file!.name.endsWith('.zip'); |
|||
if (isZip) { |
|||
formData.append('file', form.value.file!); |
|||
} else { |
|||
formData.append('htmlFile', form.value.file!); |
|||
} |
|||
|
|||
form.value.attachments.forEach((file: File) => { |
|||
formData.append('attachments', file); |
|||
}); |
|||
|
|||
form.value.documents.forEach((file: File) => { |
|||
formData.append('documents', file); |
|||
}); |
|||
|
|||
// 模拟上传进度 |
|||
const progressInterval = setInterval(() => { |
|||
if (uploadProgress.value < 90) { |
|||
uploadProgress.value += 10; |
|||
} |
|||
}, 200); |
|||
|
|||
const response = await projectApi.create(formData); |
|||
clearInterval(progressInterval); |
|||
uploadProgress.value = 100; |
|||
|
|||
setTimeout(() => { |
|||
router.replace(`/project/${response.project.id}`); |
|||
}, 500); |
|||
} catch (err: any) { |
|||
const errorMessage = err.response?.data?.error?.message || '操作失败,请重试'; |
|||
error.value = errorMessage; |
|||
uploadProgress.value = 0; |
|||
|
|||
// 如果是401错误,不显示错误信息,让拦截器处理跳转 |
|||
if (err.response?.status === 401) { |
|||
return; |
|||
} |
|||
} finally { |
|||
loading.value = false; |
|||
} |
|||
}; |
|||
|
|||
onMounted(() => { |
|||
const editId = route.query.edit as string; |
|||
if (editId) { |
|||
const id = parseInt(editId); |
|||
if (!isNaN(id)) { |
|||
isEditMode.value = true; |
|||
editProjectId.value = id; |
|||
loadProjectForEdit(id); |
|||
} |
|||
} |
|||
}); |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.upload-page { |
|||
min-height: 100vh; |
|||
background: #f5f5f5; |
|||
} |
|||
|
|||
.container { |
|||
max-width: 800px; |
|||
margin: 0 auto; |
|||
padding: 2rem; |
|||
} |
|||
|
|||
.container h1 { |
|||
margin-bottom: 2rem; |
|||
color: #333; |
|||
} |
|||
|
|||
.upload-form { |
|||
background: white; |
|||
border-radius: 12px; |
|||
padding: 2rem; |
|||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
|||
} |
|||
|
|||
.form-section { |
|||
margin-bottom: 2rem; |
|||
padding-bottom: 2rem; |
|||
border-bottom: 1px solid #eee; |
|||
} |
|||
|
|||
.form-section:last-child { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
.form-section h2 { |
|||
font-size: 1.25rem; |
|||
margin-bottom: 1rem; |
|||
color: #333; |
|||
} |
|||
|
|||
.form-group { |
|||
margin-bottom: 1.5rem; |
|||
} |
|||
|
|||
.form-group label { |
|||
display: block; |
|||
margin-bottom: 0.5rem; |
|||
font-weight: 500; |
|||
color: #666; |
|||
} |
|||
|
|||
.form-input, |
|||
.form-textarea { |
|||
width: 100%; |
|||
padding: 0.75rem; |
|||
border: 1px solid #ddd; |
|||
border-radius: 8px; |
|||
font-size: 1rem; |
|||
outline: none; |
|||
transition: border-color 0.2s; |
|||
font-family: inherit; |
|||
} |
|||
|
|||
.form-input:focus, |
|||
.form-textarea:focus { |
|||
border-color: #667eea; |
|||
} |
|||
|
|||
.file-upload { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 1rem; |
|||
} |
|||
|
|||
.file-input { |
|||
display: none; |
|||
} |
|||
|
|||
.file-display { |
|||
padding: 1rem; |
|||
background: #f9f9f9; |
|||
border-radius: 8px; |
|||
min-height: 3rem; |
|||
display: flex; |
|||
align-items: center; |
|||
} |
|||
|
|||
.placeholder { |
|||
color: #999; |
|||
} |
|||
|
|||
.file-list { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 0.5rem; |
|||
} |
|||
|
|||
.file-item { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 0.5rem; |
|||
background: #f9f9f9; |
|||
border-radius: 6px; |
|||
} |
|||
|
|||
.btn-remove { |
|||
background: #e74c3c; |
|||
color: white; |
|||
border: none; |
|||
border-radius: 4px; |
|||
width: 24px; |
|||
height: 24px; |
|||
cursor: pointer; |
|||
font-size: 1.2rem; |
|||
line-height: 1; |
|||
} |
|||
|
|||
.btn-select { |
|||
padding: 0.75rem 1.5rem; |
|||
background: #667eea; |
|||
color: white; |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
font-weight: 500; |
|||
align-self: flex-start; |
|||
} |
|||
|
|||
.btn-select:hover { |
|||
background: #5568d3; |
|||
} |
|||
|
|||
.progress { |
|||
margin: 1rem 0; |
|||
background: #f0f0f0; |
|||
border-radius: 8px; |
|||
height: 32px; |
|||
position: relative; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.progress-bar { |
|||
height: 100%; |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
transition: width 0.3s; |
|||
} |
|||
|
|||
.progress-text { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 50%; |
|||
transform: translate(-50%, -50%); |
|||
font-weight: 600; |
|||
color: #333; |
|||
} |
|||
|
|||
.error-message { |
|||
color: #e74c3c; |
|||
padding: 1rem; |
|||
background: #fee; |
|||
border-radius: 8px; |
|||
margin-bottom: 1rem; |
|||
} |
|||
|
|||
.form-actions { |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
gap: 1rem; |
|||
margin-top: 2rem; |
|||
} |
|||
|
|||
.btn-cancel { |
|||
padding: 0.75rem 2rem; |
|||
background: #f0f0f0; |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.btn-cancel:hover { |
|||
background: #e0e0e0; |
|||
} |
|||
|
|||
.btn-submit { |
|||
padding: 0.75rem 2rem; |
|||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|||
color: white; |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
font-weight: 600; |
|||
transition: transform 0.2s; |
|||
} |
|||
|
|||
.btn-submit:hover:not(:disabled) { |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
.btn-submit:disabled { |
|||
opacity: 0.6; |
|||
cursor: not-allowed; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,8 @@ |
|||
/// <reference types="vite/client" />
|
|||
|
|||
declare module '*.vue' { |
|||
import type { DefineComponent } from 'vue' |
|||
const component: DefineComponent<{}, {}, any> |
|||
export default component |
|||
} |
|||
|
|||
@ -0,0 +1,25 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "ES2020", |
|||
"useDefineForClassFields": true, |
|||
"module": "ESNext", |
|||
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
|||
"skipLibCheck": true, |
|||
"moduleResolution": "bundler", |
|||
"allowImportingTsExtensions": true, |
|||
"resolveJsonModule": true, |
|||
"isolatedModules": true, |
|||
"noEmit": true, |
|||
"jsx": "preserve", |
|||
"strict": true, |
|||
"noUnusedLocals": true, |
|||
"noUnusedParameters": true, |
|||
"noFallthroughCasesInSwitch": true, |
|||
"paths": { |
|||
"@/*": ["./src/*"] |
|||
} |
|||
}, |
|||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], |
|||
"references": [{ "path": "./tsconfig.node.json" }] |
|||
} |
|||
|
|||
@ -0,0 +1,11 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"composite": true, |
|||
"skipLibCheck": true, |
|||
"module": "ESNext", |
|||
"moduleResolution": "bundler", |
|||
"allowSyntheticDefaultImports": true |
|||
}, |
|||
"include": ["vite.config.ts"] |
|||
} |
|||
|
|||
@ -0,0 +1,23 @@ |
|||
import { defineConfig } from 'vite'; |
|||
import vue from '@vitejs/plugin-vue'; |
|||
import path from 'path'; |
|||
|
|||
export default defineConfig({ |
|||
base: './', |
|||
plugins: [vue()], |
|||
resolve: { |
|||
alias: { |
|||
'@': path.resolve(__dirname, './src') |
|||
} |
|||
}, |
|||
server: { |
|||
port: 5173, |
|||
proxy: { |
|||
'/api': { |
|||
target: 'http://localhost:3000', |
|||
changeOrigin: true |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
|
|||
@ -0,0 +1,11 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8"> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|||
<title>Document</title> |
|||
</head> |
|||
<body> |
|||
dsads |
|||
</body> |
|||
</html> |
|||
@ -0,0 +1,20 @@ |
|||
{ |
|||
"name": "just-demo", |
|||
"version": "1.0.0", |
|||
"description": "前端简易部署项目", |
|||
"private": true, |
|||
"scripts": { |
|||
"dev": "concurrently \"pnpm run dev:backend\" \"pnpm run dev:frontend\"", |
|||
"dev:backend": "cd backend && pnpm run dev", |
|||
"dev:frontend": "cd frontend && pnpm run dev", |
|||
"start": "cd backend && pnpm run start:p", |
|||
"build": "pnpm run build:backend && pnpm run build:frontend", |
|||
"build:backend": "cd backend && pnpm run build", |
|||
"build:frontend": "cd frontend && pnpm run build", |
|||
"install:all": "pnpm install && cd backend && pnpm install && cd ../frontend && pnpm install" |
|||
}, |
|||
"devDependencies": { |
|||
"concurrently": "^8.2.2" |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,233 @@ |
|||
lockfileVersion: '9.0' |
|||
|
|||
settings: |
|||
autoInstallPeers: true |
|||
excludeLinksFromLockfile: false |
|||
|
|||
importers: |
|||
|
|||
.: |
|||
devDependencies: |
|||
concurrently: |
|||
specifier: ^8.2.2 |
|||
version: 8.2.2 |
|||
|
|||
packages: |
|||
|
|||
'@babel/runtime@7.28.4': |
|||
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} |
|||
engines: {node: '>=6.9.0'} |
|||
|
|||
ansi-regex@5.0.1: |
|||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} |
|||
engines: {node: '>=8'} |
|||
|
|||
ansi-styles@4.3.0: |
|||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} |
|||
engines: {node: '>=8'} |
|||
|
|||
chalk@4.1.2: |
|||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} |
|||
engines: {node: '>=10'} |
|||
|
|||
cliui@8.0.1: |
|||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} |
|||
engines: {node: '>=12'} |
|||
|
|||
color-convert@2.0.1: |
|||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} |
|||
engines: {node: '>=7.0.0'} |
|||
|
|||
color-name@1.1.4: |
|||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} |
|||
|
|||
concurrently@8.2.2: |
|||
resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} |
|||
engines: {node: ^14.13.0 || >=16.0.0} |
|||
hasBin: true |
|||
|
|||
date-fns@2.30.0: |
|||
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} |
|||
engines: {node: '>=0.11'} |
|||
|
|||
emoji-regex@8.0.0: |
|||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} |
|||
|
|||
escalade@3.2.0: |
|||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} |
|||
engines: {node: '>=6'} |
|||
|
|||
get-caller-file@2.0.5: |
|||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} |
|||
engines: {node: 6.* || 8.* || >= 10.*} |
|||
|
|||
has-flag@4.0.0: |
|||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} |
|||
engines: {node: '>=8'} |
|||
|
|||
is-fullwidth-code-point@3.0.0: |
|||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} |
|||
engines: {node: '>=8'} |
|||
|
|||
lodash@4.17.21: |
|||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} |
|||
|
|||
require-directory@2.1.1: |
|||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} |
|||
engines: {node: '>=0.10.0'} |
|||
|
|||
rxjs@7.8.2: |
|||
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} |
|||
|
|||
shell-quote@1.8.3: |
|||
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} |
|||
engines: {node: '>= 0.4'} |
|||
|
|||
spawn-command@0.0.2: |
|||
resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} |
|||
|
|||
string-width@4.2.3: |
|||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} |
|||
engines: {node: '>=8'} |
|||
|
|||
strip-ansi@6.0.1: |
|||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} |
|||
engines: {node: '>=8'} |
|||
|
|||
supports-color@7.2.0: |
|||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} |
|||
engines: {node: '>=8'} |
|||
|
|||
supports-color@8.1.1: |
|||
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} |
|||
engines: {node: '>=10'} |
|||
|
|||
tree-kill@1.2.2: |
|||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} |
|||
hasBin: true |
|||
|
|||
tslib@2.8.1: |
|||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} |
|||
|
|||
wrap-ansi@7.0.0: |
|||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} |
|||
engines: {node: '>=10'} |
|||
|
|||
y18n@5.0.8: |
|||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} |
|||
engines: {node: '>=10'} |
|||
|
|||
yargs-parser@21.1.1: |
|||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} |
|||
engines: {node: '>=12'} |
|||
|
|||
yargs@17.7.2: |
|||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} |
|||
engines: {node: '>=12'} |
|||
|
|||
snapshots: |
|||
|
|||
'@babel/runtime@7.28.4': {} |
|||
|
|||
ansi-regex@5.0.1: {} |
|||
|
|||
ansi-styles@4.3.0: |
|||
dependencies: |
|||
color-convert: 2.0.1 |
|||
|
|||
chalk@4.1.2: |
|||
dependencies: |
|||
ansi-styles: 4.3.0 |
|||
supports-color: 7.2.0 |
|||
|
|||
cliui@8.0.1: |
|||
dependencies: |
|||
string-width: 4.2.3 |
|||
strip-ansi: 6.0.1 |
|||
wrap-ansi: 7.0.0 |
|||
|
|||
color-convert@2.0.1: |
|||
dependencies: |
|||
color-name: 1.1.4 |
|||
|
|||
color-name@1.1.4: {} |
|||
|
|||
concurrently@8.2.2: |
|||
dependencies: |
|||
chalk: 4.1.2 |
|||
date-fns: 2.30.0 |
|||
lodash: 4.17.21 |
|||
rxjs: 7.8.2 |
|||
shell-quote: 1.8.3 |
|||
spawn-command: 0.0.2 |
|||
supports-color: 8.1.1 |
|||
tree-kill: 1.2.2 |
|||
yargs: 17.7.2 |
|||
|
|||
date-fns@2.30.0: |
|||
dependencies: |
|||
'@babel/runtime': 7.28.4 |
|||
|
|||
emoji-regex@8.0.0: {} |
|||
|
|||
escalade@3.2.0: {} |
|||
|
|||
get-caller-file@2.0.5: {} |
|||
|
|||
has-flag@4.0.0: {} |
|||
|
|||
is-fullwidth-code-point@3.0.0: {} |
|||
|
|||
lodash@4.17.21: {} |
|||
|
|||
require-directory@2.1.1: {} |
|||
|
|||
rxjs@7.8.2: |
|||
dependencies: |
|||
tslib: 2.8.1 |
|||
|
|||
shell-quote@1.8.3: {} |
|||
|
|||
spawn-command@0.0.2: {} |
|||
|
|||
string-width@4.2.3: |
|||
dependencies: |
|||
emoji-regex: 8.0.0 |
|||
is-fullwidth-code-point: 3.0.0 |
|||
strip-ansi: 6.0.1 |
|||
|
|||
strip-ansi@6.0.1: |
|||
dependencies: |
|||
ansi-regex: 5.0.1 |
|||
|
|||
supports-color@7.2.0: |
|||
dependencies: |
|||
has-flag: 4.0.0 |
|||
|
|||
supports-color@8.1.1: |
|||
dependencies: |
|||
has-flag: 4.0.0 |
|||
|
|||
tree-kill@1.2.2: {} |
|||
|
|||
tslib@2.8.1: {} |
|||
|
|||
wrap-ansi@7.0.0: |
|||
dependencies: |
|||
ansi-styles: 4.3.0 |
|||
string-width: 4.2.3 |
|||
strip-ansi: 6.0.1 |
|||
|
|||
y18n@5.0.8: {} |
|||
|
|||
yargs-parser@21.1.1: {} |
|||
|
|||
yargs@17.7.2: |
|||
dependencies: |
|||
cliui: 8.0.1 |
|||
escalade: 3.2.0 |
|||
get-caller-file: 2.0.5 |
|||
require-directory: 2.1.1 |
|||
string-width: 4.2.3 |
|||
y18n: 5.0.8 |
|||
yargs-parser: 21.1.1 |
|||
Loading…
Reference in new issue