Browse Source

all code

main
dash 2 weeks ago
parent
commit
20ac9b08ea
  1. 10
      .gitignore
  2. 55
      README.md
  3. 177
      SETUP.md
  4. 7
      backend/.gitignore
  5. BIN
      backend/database/dev.sqlite3
  6. BIN
      backend/database/prod.sqlite3
  7. 42
      backend/knexfile.js
  8. 49
      backend/package.json
  9. 2803
      backend/pnpm-lock.yaml
  10. 31
      backend/scripts/fix-generator.js
  11. 51
      backend/src/app.ts
  12. 14
      backend/src/config/config.ts
  13. 30
      backend/src/config/database.ts
  14. 131
      backend/src/controllers/authController.ts
  15. 149
      backend/src/controllers/fileController.ts
  16. 404
      backend/src/controllers/projectController.ts
  17. 34
      backend/src/controllers/settingController.ts
  18. 72
      backend/src/middleware/auth.ts
  19. 39
      backend/src/middleware/errorHandler.ts
  20. 101
      backend/src/middleware/upload.ts
  21. 17
      backend/src/migrations/001_create_users.ts
  22. 25
      backend/src/migrations/002_create_projects.ts
  23. 19
      backend/src/migrations/003_create_attachments.ts
  24. 19
      backend/src/migrations/004_create_documents.ts
  25. 16
      backend/src/migrations/005_create_settings.ts
  26. 22
      backend/src/migrations/006_seed_settings.ts
  27. 30
      backend/src/models/Attachment.ts
  28. 35
      backend/src/models/Document.ts
  29. 97
      backend/src/models/Project.ts
  30. 34
      backend/src/models/Setting.ts
  31. 32
      backend/src/models/User.ts
  32. 13
      backend/src/routes/auth.ts
  33. 13
      backend/src/routes/files.ts
  34. 15
      backend/src/routes/index.ts
  35. 27
      backend/src/routes/projects.ts
  36. 11
      backend/src/routes/settings.ts
  37. 51
      backend/src/start.ts
  38. 324
      backend/src/utils/fileUtils.ts
  39. 25
      backend/src/utils/jwt.ts
  40. 12
      backend/src/utils/password.ts
  41. 21
      backend/tsconfig.json
  42. 6
      frontend/.gitignore
  43. 14
      frontend/index.html
  44. 29
      frontend/package.json
  45. 2264
      frontend/pnpm-lock.yaml
  46. 2
      frontend/public/vite.svg
  47. 43
      frontend/src/App.vue
  48. 20
      frontend/src/api/auth.ts
  49. 60
      frontend/src/api/index.ts
  50. 68
      frontend/src/api/project.ts
  51. 16
      frontend/src/api/setting.ts
  52. 126
      frontend/src/components/Drawer.vue
  53. 154
      frontend/src/components/NavBar.vue
  54. 192
      frontend/src/components/ProjectCard.vue
  55. 12
      frontend/src/main.ts
  56. 78
      frontend/src/router/index.ts
  57. 27
      frontend/src/store/index.ts
  58. 293
      frontend/src/views/Home.vue
  59. 170
      frontend/src/views/Login.vue
  60. 380
      frontend/src/views/Project.vue
  61. 180
      frontend/src/views/Register.vue
  62. 198
      frontend/src/views/Settings.vue
  63. 491
      frontend/src/views/Upload.vue
  64. 8
      frontend/src/vite-env.d.ts
  65. 25
      frontend/tsconfig.json
  66. 11
      frontend/tsconfig.node.json
  67. 23
      frontend/vite.config.ts
  68. 11
      index.html
  69. 20
      package.json
  70. 233
      pnpm-lock.yaml

10
.gitignore

@ -0,0 +1,10 @@
node_modules/
dist/
*.log
.env
.DS_Store
database/*.sqlite3
uploads/
.vscode/
.idea/

55
README.md

@ -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文件夹)
- 项目展示和筛选
- 沙盒预览
- 文档管理
- 权限控制

177
SETUP.md

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

7
backend/.gitignore

@ -0,0 +1,7 @@
node_modules/
dist/
uploads/
*.log
.env
.DS_Store

BIN
backend/database/dev.sqlite3

Binary file not shown.

BIN
backend/database/prod.sqlite3

Binary file not shown.

42
backend/knexfile.js

@ -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);
}
}
}
};

49
backend/package.json

@ -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"
}
}

2803
backend/pnpm-lock.yaml

File diff suppressed because it is too large

31
backend/scripts/fix-generator.js

@ -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 文件,可能需要先安装依赖');
}

51
backend/src/app.ts

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

14
backend/src/config/config.ts

@ -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')
};

30
backend/src/config/database.ts

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

131
backend/src/controllers/authController.ts

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

149
backend/src/controllers/fileController.ts

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

404
backend/src/controllers/projectController.ts

@ -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' } };
}
}
}

34
backend/src/controllers/settingController.ts

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

72
backend/src/middleware/auth.ts

@ -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();
}

39
backend/src/middleware/errorHandler.ts

@ -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);
}
}

101
backend/src/middleware/upload.ts

@ -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();
}

17
backend/src/migrations/001_create_users.ts

@ -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');
}

25
backend/src/migrations/002_create_projects.ts

@ -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');
}

19
backend/src/migrations/003_create_attachments.ts

@ -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');
}

19
backend/src/migrations/004_create_documents.ts

@ -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');
}

16
backend/src/migrations/005_create_settings.ts

@ -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');
}

22
backend/src/migrations/006_seed_settings.ts

@ -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();
}

30
backend/src/models/Attachment.ts

@ -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();
}
}

35
backend/src/models/Document.ts

@ -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();
}
}

97
backend/src/models/Project.ts

@ -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) : []
};
}
}

34
backend/src/models/Setting.ts

@ -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';
}
}

32
backend/src/models/User.ts

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

13
backend/src/routes/auth.ts

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

13
backend/src/routes/files.ts

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

15
backend/src/routes/index.ts

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

27
backend/src/routes/projects.ts

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

11
backend/src/routes/settings.ts

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

51
backend/src/start.ts

@ -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';

324
backend/src/utils/fileUtils.ts

@ -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}`;
}

25
backend/src/utils/jwt.ts

@ -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');
}
}

12
backend/src/utils/password.ts

@ -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);
}

21
backend/tsconfig.json

@ -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"]
}

6
frontend/.gitignore

@ -0,0 +1,6 @@
node_modules
dist
dist-ssr
*.local
.DS_Store

14
frontend/index.html

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

29
frontend/package.json

@ -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"
}
}

2264
frontend/pnpm-lock.yaml

File diff suppressed because it is too large

2
frontend/public/vite.svg

@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

43
frontend/src/App.vue

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

20
frontend/src/api/auth.ts

@ -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')
};

60
frontend/src/api/index.ts

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

68
frontend/src/api/project.ts

@ -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' })
};

16
frontend/src/api/setting.ts

@ -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 })
};

126
frontend/src/components/Drawer.vue

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

154
frontend/src/components/NavBar.vue

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

192
frontend/src/components/ProjectCard.vue

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

12
frontend/src/main.ts

@ -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');

78
frontend/src/router/index.ts

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

27
frontend/src/store/index.ts

@ -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');
}
}
});

293
frontend/src/views/Home.vue

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

170
frontend/src/views/Login.vue

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

380
frontend/src/views/Project.vue

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

180
frontend/src/views/Register.vue

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

198
frontend/src/views/Settings.vue

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

491
frontend/src/views/Upload.vue

@ -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);
// HTMLZIP
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>

8
frontend/src/vite-env.d.ts

@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

25
frontend/tsconfig.json

@ -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" }]
}

11
frontend/tsconfig.node.json

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

23
frontend/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
}
}
}
});

11
index.html

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

20
package.json

@ -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"
}
}

233
pnpm-lock.yaml

@ -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…
Cancel
Save