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