Compare commits
17 Commits
ba21a1736c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 54130d4401 | |||
| a8e24fad63 | |||
| 97c0e0db0d | |||
| c4086b9f46 | |||
| d7c1d00ba1 | |||
| ddcd490575 | |||
| c71f8f7740 | |||
| de0af70958 | |||
| 2630405dac | |||
| 98925f4cce | |||
| becca344bf | |||
| 527cb1dc3e | |||
| 066475c487 | |||
| fabc106396 | |||
| d4bcf322d7 | |||
| 82c17639d7 | |||
| f7e529c711 |
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Virtual environments
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
.virtualenv/
|
||||||
|
virtualenv/
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Media files
|
||||||
|
media/
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
static/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# Build
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Other
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# 任务中心管理系统最终实现计划
|
# 任务中心管理系统最终实现计划
|
||||||
|
|
||||||
## 项目结构设计
|
## 项目结构设计
|
||||||
|
|
||||||
1. 创建Django项目:`task_center`
|
1. 创建Django项目:`task_center`
|
||||||
2. 创建任务管理应用:`tasks`
|
2. 创建任务管理应用:`tasks`
|
||||||
3. 配置数据库为SQLite
|
3. 配置数据库为SQLite
|
||||||
@@ -10,41 +11,45 @@
|
|||||||
## 数据库模型设计
|
## 数据库模型设计
|
||||||
|
|
||||||
### Client模型
|
### Client模型
|
||||||
| 字段名 | 类型 | 描述 |
|
|
||||||
|-------|------|------|
|
| 字段名 | 类型 | 描述 |
|
||||||
| id | AutoField | 客户端ID |
|
| ----------- | -------------------------- | --------- |
|
||||||
| name | CharField(unique=True) | 客户端标识 |
|
| id | AutoField | 客户端ID |
|
||||||
| token | CharField(max_length=128) | API Token |
|
| name | CharField(unique=True) | 客户端标识 |
|
||||||
| last_seen | DateTimeField | 最后活跃时间 |
|
| token | CharField(max\_length=128) | API Token |
|
||||||
| created_at | DateTimeField | 创建时间 |
|
| last\_seen | DateTimeField | 最后活跃时间 |
|
||||||
|
| created\_at | DateTimeField | 创建时间 |
|
||||||
|
|
||||||
### Task模型
|
### Task模型
|
||||||
| 字段名 | 类型 | 描述 |
|
|
||||||
|-------|------|------|
|
| 字段名 | 类型 | 描述 |
|
||||||
| id | AutoField | 任务ID |
|
| ---------------- | ----------------------------------------------------- | ------------------ |
|
||||||
| name | CharField | 任务名称 |
|
| id | AutoField | 任务ID |
|
||||||
| client_name | CharField(null=True, blank=True) | 指定执行客户端 |
|
| name | CharField | 任务名称 |
|
||||||
| script | TextField(null=True, blank=True) | 执行脚本 |
|
| client\_name | CharField(null=True, blank=True) | 指定执行客户端 |
|
||||||
| status | CharField(choices=STATUS_CHOICES, default='pending') | 任务状态 |
|
| script | TextField(null=True, blank=True) | 执行脚本 |
|
||||||
| timeout_seconds | IntegerField(default=259200) | 超时时间(默认3天=259200秒) |
|
| status | CharField(choices=STATUS\_CHOICES, default='pending') | 任务状态 |
|
||||||
| created_at | DateTimeField | 创建时间 |
|
| timeout\_seconds | IntegerField(default=259200) | 超时时间(默认3天=259200秒) |
|
||||||
| updated_at | DateTimeField | 更新时间 |
|
| created\_at | DateTimeField | 创建时间 |
|
||||||
| assigned_to | CharField(null=True, blank=True) | 实际执行客户端 |
|
| updated\_at | DateTimeField | 更新时间 |
|
||||||
| started_at | DateTimeField(null=True, blank=True) | 开始执行时间 |
|
| assigned\_to | CharField(null=True, blank=True) | 实际执行客户端 |
|
||||||
| completed_at | DateTimeField(null=True, blank=True) | 完成时间 |
|
| started\_at | DateTimeField(null=True, blank=True) | 开始执行时间 |
|
||||||
|
| completed\_at | DateTimeField(null=True, blank=True) | 完成时间 |
|
||||||
|
|
||||||
### TaskResult模型
|
### TaskResult模型
|
||||||
| 字段名 | 类型 | 描述 |
|
|
||||||
|-------|------|------|
|
| 字段名 | 类型 | 描述 |
|
||||||
| id | AutoField | 结果ID |
|
| ------------ | -------------------------------------- | ----- |
|
||||||
| task | ForeignKey(Task) | 关联任务 |
|
| id | AutoField | 结果ID |
|
||||||
| client | ForeignKey(Client) | 执行客户端 |
|
| task | ForeignKey(Task) | 关联任务 |
|
||||||
| result_file | FileField(upload_to='task_results/') | 结果文件 |
|
| client | ForeignKey(Client) | 执行客户端 |
|
||||||
| status | CharField(choices=STATUS_CHOICES) | 执行状态 |
|
| result\_file | FileField(upload\_to='task\_results/') | 结果文件 |
|
||||||
| message | TextField(null=True, blank=True) | 执行消息 |
|
| status | CharField(choices=STATUS\_CHOICES) | 执行状态 |
|
||||||
| created_at | DateTimeField | 创建时间 |
|
| message | TextField(null=True, blank=True) | 执行消息 |
|
||||||
|
| created\_at | DateTimeField | 创建时间 |
|
||||||
|
|
||||||
## 状态定义
|
## 状态定义
|
||||||
|
|
||||||
```python
|
```python
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('pending', '待分配'),
|
('pending', '待分配'),
|
||||||
@@ -60,35 +65,53 @@ STATUS_CHOICES = [
|
|||||||
## API接口设计
|
## API接口设计
|
||||||
|
|
||||||
### 认证机制
|
### 认证机制
|
||||||
- **所有API端点均需token认证**
|
|
||||||
- 客户端通过HTTP头`Authorization: Token <token>`进行身份验证
|
* **所有API端点均需token认证**
|
||||||
- 使用Django REST Framework的TokenAuthentication
|
|
||||||
- 未提供有效token的请求将返回401 Unauthorized
|
* 客户端通过HTTP头`Authorization: Token <token>`进行身份验证
|
||||||
|
|
||||||
|
* 使用Django REST Framework的TokenAuthentication
|
||||||
|
|
||||||
|
* 未提供有效token的请求将返回401 Unauthorized
|
||||||
|
|
||||||
### 任务管理API
|
### 任务管理API
|
||||||
- `GET /api/tasks/` - 获取任务列表(需认证)
|
|
||||||
- `GET /api/tasks/<id>/` - 获取任务详情(需认证)
|
* `GET /api/tasks/` - 获取任务列表(需认证)
|
||||||
- `POST /api/tasks/` - 创建任务(需认证)
|
|
||||||
- `PUT /api/tasks/<id>/` - 更新任务(需认证)
|
* `GET /api/tasks/<id>/` - 获取任务详情(需认证)
|
||||||
- `DELETE /api/tasks/<id>/` - 删除任务(需认证)
|
|
||||||
|
* `POST /api/tasks/` - 创建任务(需认证)
|
||||||
|
|
||||||
|
* `PUT /api/tasks/<id>/` - 更新任务(需认证)
|
||||||
|
|
||||||
|
* `DELETE /api/tasks/<id>/` - 删除任务(需认证)
|
||||||
|
|
||||||
### 客户端API
|
### 客户端API
|
||||||
- `POST /api/tasks/claim/` - 客户端原子认领任务(需认证)
|
|
||||||
- `POST /api/tasks/<id>/start/` - 客户端开始执行任务(需认证)
|
* `POST /api/tasks/claim/` - 客户端原子认领任务(需认证)
|
||||||
- `POST /api/tasks/<id>/complete/` - 客户端完成任务(需认证)
|
|
||||||
- `POST /api/task_results/` - 上传任务结果(需认证,支持文件上传)
|
* `POST /api/tasks/<id>/start/` - 客户端开始执行任务(需认证)
|
||||||
|
|
||||||
|
* `POST /api/tasks/<id>/complete/` - 客户端完成任务(需认证)
|
||||||
|
|
||||||
|
* `POST /api/task_results/` - 上传任务结果(需认证,支持文件上传)
|
||||||
|
|
||||||
### 客户端管理API
|
### 客户端管理API
|
||||||
- `GET /api/clients/` - 获取客户端列表(需认证)
|
|
||||||
- `POST /api/clients/` - 创建客户端(需认证)
|
* `GET /api/clients/` - 获取客户端列表(需认证)
|
||||||
- `GET /api/clients/<id>/` - 获取客户端详情(需认证)
|
|
||||||
|
* `POST /api/clients/` - 创建客户端(需认证)
|
||||||
|
|
||||||
|
* `GET /api/clients/<id>/` - 获取客户端详情(需认证)
|
||||||
|
|
||||||
### 文件下载API
|
### 文件下载API
|
||||||
- `GET /api/task_results/<id>/download/` - 下载任务结果文件(需认证)
|
|
||||||
|
* `GET /api/task_results/<id>/download/` - 下载任务结果文件(需认证)
|
||||||
|
|
||||||
## 文件上传实现细节
|
## 文件上传实现细节
|
||||||
|
|
||||||
### 1. Django媒体文件配置
|
### 1. Django媒体文件配置
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# settings.py
|
# settings.py
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
@@ -96,28 +119,43 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. 文件上传API实现
|
### 2. 文件上传API实现
|
||||||
- 使用Django REST Framework的`MultiPartParser`和`FormParser`
|
|
||||||
- API端点:`POST /api/task_results/`
|
* 使用Django REST Framework的`MultiPartParser`和`FormParser`
|
||||||
- 支持`multipart/form-data`格式上传文件
|
|
||||||
- 上传字段:`task_id`、`status`、`message`、`result_file`
|
* API端点:`POST /api/task_results/`
|
||||||
|
|
||||||
|
* 支持`multipart/form-data`格式上传文件
|
||||||
|
|
||||||
|
* 上传字段:`task_id`、`status`、`message`、`result_file`
|
||||||
|
|
||||||
### 3. 前端文件上传实现
|
### 3. 前端文件上传实现
|
||||||
- 使用HTML5的`input type="file"`元素
|
|
||||||
- 表单设置`enctype="multipart/form-data"`
|
* 使用HTML5的`input type="file"`元素
|
||||||
- 使用Bootstrap样式美化文件上传控件
|
|
||||||
- 支持进度条显示(可选)
|
* 表单设置`enctype="multipart/form-data"`
|
||||||
|
|
||||||
|
* 使用Bootstrap样式美化文件上传控件
|
||||||
|
|
||||||
|
* 支持进度条显示(可选)
|
||||||
|
|
||||||
### 4. 文件存储策略
|
### 4. 文件存储策略
|
||||||
- 本地文件系统存储:`media/task_results/`目录
|
|
||||||
- 文件名自动生成,避免冲突
|
* 本地文件系统存储:`media/task_results/`目录
|
||||||
- 支持大文件上传(通过Django默认配置)
|
|
||||||
|
* 文件名自动生成,避免冲突
|
||||||
|
|
||||||
|
* 支持大文件上传(通过Django默认配置)
|
||||||
|
|
||||||
### 5. 文件下载实现
|
### 5. 文件下载实现
|
||||||
- API端点:`GET /api/task_results/<id>/download/`
|
|
||||||
- 返回`Content-Disposition: attachment`头,触发浏览器下载
|
* API端点:`GET /api/task_results/<id>/download/`
|
||||||
- 支持断点续传(通过Django默认配置)
|
|
||||||
|
* 返回`Content-Disposition: attachment`头,触发浏览器下载
|
||||||
|
|
||||||
|
* 支持断点续传(通过Django默认配置)
|
||||||
|
|
||||||
## 前端页面设计
|
## 前端页面设计
|
||||||
|
|
||||||
1. 任务列表页:展示所有任务,支持筛选和搜索
|
1. 任务列表页:展示所有任务,支持筛选和搜索
|
||||||
2. 任务创建页:表单创建新任务
|
2. 任务创建页:表单创建新任务
|
||||||
3. 任务详情页:查看任务详情和执行结果历史
|
3. 任务详情页:查看任务详情和执行结果历史
|
||||||
@@ -128,26 +166,37 @@ MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
|||||||
## 核心功能实现
|
## 核心功能实现
|
||||||
|
|
||||||
### 1. 全API Token认证
|
### 1. 全API Token认证
|
||||||
- 所有API视图均使用`TokenAuthentication`
|
|
||||||
- 配置`DEFAULT_AUTHENTICATION_CLASSES`和`DEFAULT_PERMISSION_CLASSES`
|
* 所有API视图均使用`TokenAuthentication`
|
||||||
|
|
||||||
|
* 配置`DEFAULT_AUTHENTICATION_CLASSES`和`DEFAULT_PERMISSION_CLASSES`
|
||||||
|
|
||||||
### 2. 原子任务认领机制
|
### 2. 原子任务认领机制
|
||||||
- 使用数据库事务确保任务认领的原子性
|
|
||||||
- 客户端调用`/api/tasks/claim/`时,系统自动查找并分配可用任务
|
* 使用数据库事务确保任务认领的原子性
|
||||||
|
|
||||||
|
* 客户端调用`/api/tasks/claim/`时,系统自动查找并分配可用任务
|
||||||
|
|
||||||
### 3. 任务超时自动回收
|
### 3. 任务超时自动回收
|
||||||
- 使用Django管理命令定期检查超时任务
|
|
||||||
- 超过`timeout_seconds`的任务自动设置为`timeout`状态
|
* 使用Django管理命令定期检查超时任务
|
||||||
- 管理命令:`python manage.py check_task_timeouts`
|
|
||||||
|
* 超过`timeout_seconds`的任务自动设置为`timeout`状态
|
||||||
|
|
||||||
|
* 管理命令:`python manage.py check_task_timeouts`
|
||||||
|
|
||||||
### 4. 任务结果版本管理
|
### 4. 任务结果版本管理
|
||||||
- TaskResult模型记录每次执行结果
|
|
||||||
- 支持查看任务的完整执行历史
|
* TaskResult模型记录每次执行结果
|
||||||
- 支持下载不同版本的结果文件
|
|
||||||
|
* 支持查看任务的完整执行历史
|
||||||
|
|
||||||
|
* 支持下载不同版本的结果文件
|
||||||
|
|
||||||
## 测试用例设计
|
## 测试用例设计
|
||||||
|
|
||||||
### 目录结构
|
### 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
task_center/
|
task_center/
|
||||||
├── tasks/
|
├── tasks/
|
||||||
@@ -164,14 +213,16 @@ task_center/
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 测试类型
|
### 测试类型
|
||||||
|
|
||||||
1. **模型测试**:测试模型字段、方法和关系
|
1. **模型测试**:测试模型字段、方法和关系
|
||||||
2. **API测试**:测试所有API端点的功能和认证机制
|
2. **API测试**:测试所有API端点的功能和认证机制
|
||||||
3. **文件上传测试**:测试文件上传和下载功能
|
3. **文件上传测试**:测试文件上传和下载功能
|
||||||
4. **视图测试**:测试前端视图渲染
|
4. **视图测试**:测试前端视图渲染
|
||||||
5. **集成测试**:测试完整的任务流程
|
5. **集成测试**:测试完整的任务流程
|
||||||
6. **工厂测试**:使用factory_boy创建测试数据
|
6. **工厂测试**:使用factory\_boy创建测试数据
|
||||||
|
|
||||||
## 实现步骤
|
## 实现步骤
|
||||||
|
|
||||||
1. 创建Django项目和应用
|
1. 创建Django项目和应用
|
||||||
2. 配置项目设置(数据库、REST Framework、认证、媒体文件等)
|
2. 配置项目设置(数据库、REST Framework、认证、媒体文件等)
|
||||||
3. 实现数据库模型
|
3. 实现数据库模型
|
||||||
@@ -183,18 +234,32 @@ task_center/
|
|||||||
9. 测试API接口和功能
|
9. 测试API接口和功能
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
- 后端:Django 5.0.6 + Django REST Framework
|
|
||||||
- 前端:HTML + Bootstrap 5
|
* 后端:Django 5.0.6 + Django REST Framework
|
||||||
- 数据库:SQLite
|
|
||||||
- 文件存储:本地文件系统
|
* 前端:HTML + Bootstrap 5
|
||||||
- 测试:pytest + factory_boy
|
|
||||||
|
* 数据库:SQLite
|
||||||
|
|
||||||
|
* 文件存储:本地文件系统
|
||||||
|
|
||||||
|
* 测试:pytest + factory\_boy
|
||||||
|
|
||||||
## 预期效果
|
## 预期效果
|
||||||
- 所有API端点均需要token认证
|
|
||||||
- 支持结果文件的上传和下载功能
|
* 所有API端点均需要token认证
|
||||||
- 后台可通过admin或API创建和管理任务
|
|
||||||
- 客户端通过API进行身份验证,原子认领任务
|
* 支持结果文件的上传和下载功能
|
||||||
- 支持任务超时自动回收
|
|
||||||
- 完整的任务执行历史记录
|
* 后台可通过admin或API创建和管理任务
|
||||||
- 简单大方的Bootstrap前端界面
|
|
||||||
- 全面的测试用例覆盖
|
* 客户端通过API进行身份验证,原子认领任务
|
||||||
|
|
||||||
|
* 支持任务超时自动回收
|
||||||
|
|
||||||
|
* 完整的任务执行历史记录
|
||||||
|
|
||||||
|
* 简单大方的Bootstrap前端界面
|
||||||
|
|
||||||
|
* 全面的测试用例覆盖
|
||||||
|
|
||||||
|
|||||||
89
README.md
89
README.md
@@ -9,21 +9,30 @@
|
|||||||
- 设置任务执行客户端(指定或自动分配)
|
- 设置任务执行客户端(指定或自动分配)
|
||||||
- 配置任务超时时间
|
- 配置任务超时时间
|
||||||
- 支持任务脚本存储
|
- 支持任务脚本存储
|
||||||
|
- 完整的任务生命周期管理
|
||||||
|
|
||||||
### 2. 客户端管理
|
### 2. 客户端管理
|
||||||
- 客户端注册和身份验证
|
- 客户端注册和身份验证
|
||||||
- 自动生成API Token
|
- 自动生成安全的API Token
|
||||||
- 客户端活跃状态跟踪
|
- 客户端活跃状态跟踪
|
||||||
|
|
||||||
### 3. 任务执行流程
|
### 3. 任务执行流程
|
||||||
- **原子任务认领**:避免竞态条件
|
- **原子任务认领**:使用数据库事务确保并发安全,避免竞态条件
|
||||||
- 任务状态跟踪:待分配、已分配、执行中、成功、失败、重试中、超时
|
- 任务状态跟踪:待分配、已分配、执行中、成功、失败、重试中、超时
|
||||||
- 任务结果上传和下载
|
- 任务结果上传和下载
|
||||||
|
- 完整的任务执行API:认领、开始、完成
|
||||||
|
|
||||||
### 4. 系统管理
|
### 4. 系统管理
|
||||||
- 完整的后台管理界面
|
- 完整的后台管理界面
|
||||||
- 任务超时自动回收
|
- 任务超时自动回收
|
||||||
- 结果文件管理
|
- 结果文件管理
|
||||||
|
- API权限控制:所有API端点均需要认证
|
||||||
|
|
||||||
|
### 5. 前端界面
|
||||||
|
- 响应式设计,基于Bootstrap 5
|
||||||
|
- 任务管理:列表、创建、详情
|
||||||
|
- 客户端管理:列表、创建
|
||||||
|
- 结果查看和下载
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -91,7 +100,7 @@ python manage.py runserver
|
|||||||
#### 1.2 管理客户端
|
#### 1.2 管理客户端
|
||||||
- 在左侧菜单栏点击 "客户端"
|
- 在左侧菜单栏点击 "客户端"
|
||||||
- 点击 "添加客户端" 创建新客户端
|
- 点击 "添加客户端" 创建新客户端
|
||||||
- 系统会自动生成API Token
|
- 系统会自动生成安全的API Token
|
||||||
|
|
||||||
#### 1.3 管理任务
|
#### 1.3 管理任务
|
||||||
- 在左侧菜单栏点击 "任务"
|
- 在左侧菜单栏点击 "任务"
|
||||||
@@ -127,19 +136,30 @@ python manage.py runserver
|
|||||||
Authorization: Token <your-token>
|
Authorization: Token <your-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**注意**:所有API端点均需要认证,使用 `IsAuthenticated` 权限类控制。
|
||||||
|
|
||||||
#### 3.2 主要API端点
|
#### 3.2 主要API端点
|
||||||
|
|
||||||
| 端点 | 方法 | 功能 |
|
| 端点 | 方法 | 功能 | 权限 |
|
||||||
|------|------|------|
|
|------|------|------|------|
|
||||||
| `/api/clients/` | GET | 获取客户端列表 |
|
| `/api/clients/` | GET | 获取客户端列表 | 需要认证 |
|
||||||
| `/api/clients/` | POST | 创建客户端 |
|
| `/api/clients/` | POST | 创建客户端 | 需要认证 |
|
||||||
| `/api/tasks/` | GET | 获取任务列表 |
|
| `/api/clients/<id>/` | GET | 获取客户端详情 | 需要认证 |
|
||||||
| `/api/tasks/` | POST | 创建任务 |
|
| `/api/clients/<id>/` | PUT | 更新客户端 | 需要认证 |
|
||||||
| `/api/tasks/claim/` | POST | 客户端认领任务 |
|
| `/api/clients/<id>/` | DELETE | 删除客户端 | 需要认证 |
|
||||||
| `/api/tasks/<id>/start/` | POST | 开始执行任务 |
|
| `/api/tasks/` | GET | 获取任务列表 | 需要认证 |
|
||||||
| `/api/tasks/<id>/complete/` | POST | 完成任务 |
|
| `/api/tasks/` | POST | 创建任务 | 需要认证 |
|
||||||
| `/api/task_results/` | POST | 上传任务结果 |
|
| `/api/tasks/<id>/` | GET | 获取任务详情 | 需要认证 |
|
||||||
| `/api/task_results/<id>/download/` | GET | 下载结果文件 |
|
| `/api/tasks/<id>/` | PUT | 更新任务 | 需要认证 |
|
||||||
|
| `/api/tasks/<id>/` | DELETE | 删除任务 | 需要认证 |
|
||||||
|
| `/api/tasks/claim/` | POST | 客户端认领任务 | 需要认证 |
|
||||||
|
| `/api/tasks/<id>/start/` | POST | 开始执行任务 | 需要认证 |
|
||||||
|
| `/api/tasks/<id>/complete/` | POST | 完成任务 | 需要认证 |
|
||||||
|
| `/api/task_results/` | GET | 获取任务结果列表 | 需要认证 |
|
||||||
|
| `/api/task_results/` | POST | 上传任务结果 | 需要认证 |
|
||||||
|
| `/api/task_results/<id>/` | GET | 获取任务结果详情 | 需要认证 |
|
||||||
|
| `/api/task_results/<id>/` | DELETE | 删除任务结果 | 需要认证 |
|
||||||
|
| `/api/task_results/<id>/download/` | GET | 下载结果文件 | 需要认证 |
|
||||||
|
|
||||||
#### 3.3 API使用示例
|
#### 3.3 API使用示例
|
||||||
|
|
||||||
@@ -159,6 +179,20 @@ curl -X POST http://127.0.0.1:8000/api/tasks/claim/ \
|
|||||||
-d '{"client_name":"client1"}'
|
-d '{"client_name":"client1"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
##### 开始执行任务
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/tasks/1/start/ \
|
||||||
|
-H "Authorization: Token <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 完成任务
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/tasks/1/complete/ \
|
||||||
|
-H "Authorization: Token <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"status":"success","message":"任务执行成功"}'
|
||||||
|
```
|
||||||
|
|
||||||
##### 上传任务结果
|
##### 上传任务结果
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:8000/api/task_results/ \
|
curl -X POST http://127.0.0.1:8000/api/task_results/ \
|
||||||
@@ -170,22 +204,31 @@ curl -X POST http://127.0.0.1:8000/api/task_results/ \
|
|||||||
-F "result_file=@result.txt"
|
-F "result_file=@result.txt"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
##### 下载任务结果文件
|
||||||
|
```bash
|
||||||
|
curl -X GET http://127.0.0.1:8000/api/task_results/1/download/ \
|
||||||
|
-H "Authorization: Token <token>" \
|
||||||
|
-o result.txt
|
||||||
|
```
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
task_center/
|
任务中心/
|
||||||
├── task_center/ # 项目配置
|
├── task_center/ # 项目配置
|
||||||
│ ├── settings.py # 项目设置
|
│ ├── settings.py # 项目设置
|
||||||
│ └── urls.py # 主URL配置
|
│ └── urls.py # 主URL配置
|
||||||
├── tasks/ # 任务应用
|
├── tasks/ # 任务应用
|
||||||
│ ├── models.py # 数据库模型
|
│ ├── models.py # 数据库模型
|
||||||
│ ├── serializers.py # API序列化器
|
│ ├── serializers.py # API序列化器
|
||||||
│ ├── views.py # API视图
|
│ ├── views.py # API视图(使用IsAuthenticated权限)
|
||||||
│ ├── views_frontend.py # 前端视图
|
│ ├── views_frontend.py # 前端视图
|
||||||
│ ├── urls.py # 应用URL配置
|
│ ├── urls.py # 应用URL配置
|
||||||
│ ├── templates/ # 前端模板
|
│ ├── templates/ # 前端模板
|
||||||
│ ├── tests/ # 测试用例
|
│ ├── tests/ # 测试用例
|
||||||
│ └── management/ # 管理命令
|
│ └── management/ # 管理命令
|
||||||
|
├── media/ # 媒体文件存储
|
||||||
|
│ └── task_results/ # 任务结果文件
|
||||||
└── manage.py # 项目入口
|
└── manage.py # 项目入口
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -199,15 +242,20 @@ python manage.py check_task_timeouts
|
|||||||
|
|
||||||
建议将此命令添加到系统定时任务中,例如每小时执行一次。
|
建议将此命令添加到系统定时任务中,例如每小时执行一次。
|
||||||
|
|
||||||
|
### 命令功能说明
|
||||||
|
- **check_task_timeouts**:检查所有执行中任务,将超过超时时间的任务标记为超时状态,使其可以被重新分配执行。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
1. **文件存储**:结果文件存储在 `media/task_results/` 目录下,请确保该目录有写入权限
|
1. **文件存储**:结果文件存储在 `media/task_results/` 目录下,请确保该目录有写入权限
|
||||||
2. **API Token安全**:请妥善保管客户端API Token,避免泄露
|
2. **API Token安全**:请妥善保管客户端API Token,避免泄露
|
||||||
3. **生产环境配置**:
|
3. **API权限控制**:所有API端点均需要认证,使用 `IsAuthenticated` 权限类控制
|
||||||
|
4. **生产环境配置**:
|
||||||
- 建议使用PostgreSQL或MySQL数据库
|
- 建议使用PostgreSQL或MySQL数据库
|
||||||
- 配置合适的文件存储后端(如S3)
|
- 配置合适的文件存储后端(如S3)
|
||||||
- 启用HTTPS
|
- 启用HTTPS
|
||||||
- 配置适当的日志记录
|
- 配置适当的日志记录
|
||||||
|
- 实现更细粒度的权限控制(如基于角色的权限)
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
@@ -233,6 +281,13 @@ python -m pytest --ds=task_center.settings
|
|||||||
|
|
||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
|
### v1.1.0
|
||||||
|
- 增强API权限控制:所有API端点均使用 `IsAuthenticated` 权限类
|
||||||
|
- 完善API文档:更新API端点描述和使用示例
|
||||||
|
- 优化前端界面:改进任务和客户端管理功能
|
||||||
|
- 增强任务执行流程:完善认领、开始、完成的API实现
|
||||||
|
- 改进项目结构文档:添加媒体文件存储目录说明
|
||||||
|
|
||||||
### v1.0.0
|
### v1.0.0
|
||||||
- 初始版本发布
|
- 初始版本发布
|
||||||
- 完成任务管理核心功能
|
- 完成任务管理核心功能
|
||||||
|
|||||||
568
client/windows-client.py
Normal file
568
client/windows-client.py
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget,
|
||||||
|
QGroupBox, QLabel, QLineEdit, QPushButton, QTextEdit, QTableWidget,
|
||||||
|
QTableWidgetItem, QComboBox, QSpinBox, QFileDialog, QMessageBox,
|
||||||
|
QStatusBar, QHeaderView, QFormLayout, QCheckBox
|
||||||
|
)
|
||||||
|
from PySide6.QtCore import Qt, QTimer, QThread, Signal
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
|
||||||
|
# API通信类
|
||||||
|
class ApiClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = "http://127.0.0.1:8000/api"
|
||||||
|
self.token = ""
|
||||||
|
|
||||||
|
def set_server(self, base_url):
|
||||||
|
self.base_url = base_url.rstrip('/')
|
||||||
|
|
||||||
|
def set_token(self, token):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def _get_headers(self):
|
||||||
|
return {
|
||||||
|
"Authorization": f"Token {self.token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_tasks(self):
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{self.base_url}/tasks/", headers=self._get_headers())
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception(f"获取任务失败: {str(e)}")
|
||||||
|
|
||||||
|
def create_task(self, name, client_name, script, timeout_seconds):
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"client_name": client_name or None,
|
||||||
|
"script": script or None,
|
||||||
|
"timeout_seconds": timeout_seconds
|
||||||
|
}
|
||||||
|
response = requests.post(f"{self.base_url}/tasks/", headers=self._get_headers(), json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception(f"创建任务失败: {str(e)}")
|
||||||
|
|
||||||
|
def claim_task(self, client_name):
|
||||||
|
try:
|
||||||
|
data = {"client_name": client_name}
|
||||||
|
response = requests.post(f"{self.base_url}/tasks/claim/", headers=self._get_headers(), json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception(f"认领任务失败: {str(e)}")
|
||||||
|
|
||||||
|
def start_task(self, task_id):
|
||||||
|
try:
|
||||||
|
response = requests.post(f"{self.base_url}/tasks/{task_id}/start/", headers=self._get_headers())
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception(f"开始任务失败: {str(e)}")
|
||||||
|
|
||||||
|
def complete_task(self, task_id, status, message):
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
"status": status,
|
||||||
|
"message": message
|
||||||
|
}
|
||||||
|
response = requests.post(f"{self.base_url}/tasks/{task_id}/complete/", headers=self._get_headers(), json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception(f"完成任务失败: {str(e)}")
|
||||||
|
|
||||||
|
def upload_result(self, task_id, client_id, status, message, result_file=None):
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Token {self.token}"
|
||||||
|
}
|
||||||
|
data = {
|
||||||
|
"task": task_id,
|
||||||
|
"client": client_id,
|
||||||
|
"status": status,
|
||||||
|
"message": message
|
||||||
|
}
|
||||||
|
files = {}
|
||||||
|
if result_file:
|
||||||
|
files["result_file"] = open(result_file, "rb")
|
||||||
|
|
||||||
|
response = requests.post(f"{self.base_url}/task_results/", headers=headers, data=data, files=files)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise Exception(f"上传结果失败: {str(e)}")
|
||||||
|
|
||||||
|
# 任务列表更新线程
|
||||||
|
class TaskUpdateThread(QThread):
|
||||||
|
update_signal = Signal(list)
|
||||||
|
error_signal = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, api_client):
|
||||||
|
super().__init__()
|
||||||
|
self.api_client = api_client
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
tasks = self.api_client.get_tasks()
|
||||||
|
self.update_signal.emit(tasks)
|
||||||
|
except Exception as e:
|
||||||
|
self.error_signal.emit(str(e))
|
||||||
|
self.sleep(5) # 每5秒更新一次
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.running = False
|
||||||
|
self.wait()
|
||||||
|
|
||||||
|
# 主应用窗口
|
||||||
|
class TaskCenterClient(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.api_client = ApiClient()
|
||||||
|
self.task_update_thread = None
|
||||||
|
self.init_ui()
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
self.setWindowTitle("任务中心客户端")
|
||||||
|
self.setGeometry(100, 100, 1000, 700)
|
||||||
|
|
||||||
|
# 创建中心部件
|
||||||
|
central_widget = QWidget()
|
||||||
|
self.setCentralWidget(central_widget)
|
||||||
|
|
||||||
|
# 主布局
|
||||||
|
main_layout = QVBoxLayout(central_widget)
|
||||||
|
|
||||||
|
# 创建标签页
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
main_layout.addWidget(self.tabs)
|
||||||
|
|
||||||
|
# 添加配置标签页
|
||||||
|
self.create_config_tab()
|
||||||
|
|
||||||
|
# 添加任务列表标签页
|
||||||
|
self.create_task_list_tab()
|
||||||
|
|
||||||
|
# 添加任务创建标签页
|
||||||
|
self.create_task_create_tab()
|
||||||
|
|
||||||
|
# 添加任务操作标签页
|
||||||
|
self.create_task_operation_tab()
|
||||||
|
|
||||||
|
# 创建状态栏
|
||||||
|
self.status_bar = QStatusBar()
|
||||||
|
self.setStatusBar(self.status_bar)
|
||||||
|
self.status_bar.showMessage("已连接到服务器")
|
||||||
|
|
||||||
|
def create_config_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
# 服务器配置
|
||||||
|
server_group = QGroupBox("服务器配置")
|
||||||
|
server_layout = QFormLayout()
|
||||||
|
|
||||||
|
self.server_url_edit = QLineEdit("http://127.0.0.1:8000/api")
|
||||||
|
server_layout.addRow(QLabel("服务器地址:"), self.server_url_edit)
|
||||||
|
|
||||||
|
self.token_edit = QLineEdit()
|
||||||
|
self.token_edit.setEchoMode(QLineEdit.Password)
|
||||||
|
server_layout.addRow(QLabel("API Token:"), self.token_edit)
|
||||||
|
|
||||||
|
# 显示/隐藏Token按钮
|
||||||
|
self.token_visibility_btn = QPushButton("显示")
|
||||||
|
self.token_visibility_btn.clicked.connect(self.toggle_token_visibility)
|
||||||
|
server_layout.addRow(QLabel(" "), self.token_visibility_btn)
|
||||||
|
|
||||||
|
self.save_config_btn = QPushButton("保存配置")
|
||||||
|
self.save_config_btn.clicked.connect(self.save_config)
|
||||||
|
server_layout.addRow(QLabel(" "), self.save_config_btn)
|
||||||
|
|
||||||
|
server_group.setLayout(server_layout)
|
||||||
|
layout.addWidget(server_group)
|
||||||
|
|
||||||
|
# 测试连接按钮
|
||||||
|
self.test_connection_btn = QPushButton("测试连接")
|
||||||
|
self.test_connection_btn.clicked.connect(self.test_connection)
|
||||||
|
layout.addWidget(self.test_connection_btn)
|
||||||
|
|
||||||
|
# 连接状态
|
||||||
|
self.connection_status = QLabel("未连接")
|
||||||
|
self.connection_status.setStyleSheet("color: red;")
|
||||||
|
layout.addWidget(self.connection_status, alignment=Qt.AlignCenter)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "配置")
|
||||||
|
|
||||||
|
def create_task_list_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
# 刷新按钮
|
||||||
|
refresh_layout = QHBoxLayout()
|
||||||
|
self.refresh_tasks_btn = QPushButton("刷新任务列表")
|
||||||
|
self.refresh_tasks_btn.clicked.connect(self.refresh_tasks)
|
||||||
|
refresh_layout.addWidget(self.refresh_tasks_btn)
|
||||||
|
|
||||||
|
self.auto_refresh_checkbox = QCheckBox("自动刷新")
|
||||||
|
self.auto_refresh_checkbox.stateChanged.connect(self.toggle_auto_refresh)
|
||||||
|
refresh_layout.addWidget(self.auto_refresh_checkbox)
|
||||||
|
|
||||||
|
layout.addLayout(refresh_layout)
|
||||||
|
|
||||||
|
# 任务列表
|
||||||
|
self.task_table = QTableWidget()
|
||||||
|
self.task_table.setColumnCount(8)
|
||||||
|
self.task_table.setHorizontalHeaderLabels([
|
||||||
|
"ID", "名称", "指定客户端", "实际客户端", "状态",
|
||||||
|
"创建时间", "开始时间", "完成时间"
|
||||||
|
])
|
||||||
|
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
|
||||||
|
self.task_table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
self.task_table.cellClicked.connect(self.on_task_selected)
|
||||||
|
layout.addWidget(self.task_table)
|
||||||
|
|
||||||
|
# 任务详情
|
||||||
|
self.task_detail = QTextEdit()
|
||||||
|
self.task_detail.setReadOnly(True)
|
||||||
|
self.task_detail.setPlaceholderText("点击任务查看详情")
|
||||||
|
layout.addWidget(QLabel("任务详情:"))
|
||||||
|
layout.addWidget(self.task_detail)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "任务列表")
|
||||||
|
|
||||||
|
def create_task_create_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
# 任务创建表单
|
||||||
|
form_layout = QFormLayout()
|
||||||
|
|
||||||
|
self.task_name_edit = QLineEdit()
|
||||||
|
form_layout.addRow(QLabel("任务名称:"), self.task_name_edit)
|
||||||
|
|
||||||
|
self.task_client_edit = QLineEdit()
|
||||||
|
form_layout.addRow(QLabel("指定客户端:"), self.task_client_edit)
|
||||||
|
|
||||||
|
self.task_script_edit = QTextEdit()
|
||||||
|
self.task_script_edit.setPlaceholderText("输入执行脚本(可选)")
|
||||||
|
form_layout.addRow(QLabel("执行脚本:"), self.task_script_edit)
|
||||||
|
|
||||||
|
self.task_timeout_spin = QSpinBox()
|
||||||
|
self.task_timeout_spin.setMinimum(60)
|
||||||
|
self.task_timeout_spin.setMaximum(86400)
|
||||||
|
self.task_timeout_spin.setValue(3600)
|
||||||
|
form_layout.addRow(QLabel("超时时间(秒):"), self.task_timeout_spin)
|
||||||
|
|
||||||
|
self.create_task_btn = QPushButton("创建任务")
|
||||||
|
self.create_task_btn.clicked.connect(self.create_task)
|
||||||
|
form_layout.addRow(QLabel(" "), self.create_task_btn)
|
||||||
|
|
||||||
|
layout.addLayout(form_layout)
|
||||||
|
|
||||||
|
# 创建结果
|
||||||
|
self.create_result = QTextEdit()
|
||||||
|
self.create_result.setReadOnly(True)
|
||||||
|
self.create_result.setPlaceholderText("创建任务结果将显示在这里")
|
||||||
|
layout.addWidget(QLabel("创建结果:"))
|
||||||
|
layout.addWidget(self.create_result)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "创建任务")
|
||||||
|
|
||||||
|
def create_task_operation_tab(self):
|
||||||
|
tab = QWidget()
|
||||||
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
|
# 任务选择
|
||||||
|
task_select_layout = QHBoxLayout()
|
||||||
|
self.selected_task_id = QLineEdit()
|
||||||
|
self.selected_task_id.setPlaceholderText("输入任务ID")
|
||||||
|
task_select_layout.addWidget(QLabel("任务ID:"))
|
||||||
|
task_select_layout.addWidget(self.selected_task_id)
|
||||||
|
|
||||||
|
self.select_from_list_btn = QPushButton("从列表选择")
|
||||||
|
self.select_from_list_btn.clicked.connect(self.select_task_from_list)
|
||||||
|
task_select_layout.addWidget(self.select_from_list_btn)
|
||||||
|
|
||||||
|
layout.addLayout(task_select_layout)
|
||||||
|
|
||||||
|
# 操作按钮组
|
||||||
|
operations_layout = QHBoxLayout()
|
||||||
|
|
||||||
|
self.claim_task_btn = QPushButton("认领任务")
|
||||||
|
self.claim_task_btn.clicked.connect(self.claim_task)
|
||||||
|
operations_layout.addWidget(self.claim_task_btn)
|
||||||
|
|
||||||
|
self.start_task_btn = QPushButton("开始任务")
|
||||||
|
self.start_task_btn.clicked.connect(self.start_task)
|
||||||
|
operations_layout.addWidget(self.start_task_btn)
|
||||||
|
|
||||||
|
self.complete_task_btn = QPushButton("完成任务")
|
||||||
|
self.complete_task_btn.clicked.connect(self.complete_task)
|
||||||
|
operations_layout.addWidget(self.complete_task_btn)
|
||||||
|
|
||||||
|
layout.addLayout(operations_layout)
|
||||||
|
|
||||||
|
# 完成任务表单
|
||||||
|
complete_layout = QFormLayout()
|
||||||
|
|
||||||
|
self.complete_status_combo = QComboBox()
|
||||||
|
self.complete_status_combo.addItems(["success", "failed"])
|
||||||
|
complete_layout.addRow(QLabel("执行状态:"), self.complete_status_combo)
|
||||||
|
|
||||||
|
self.complete_message_edit = QTextEdit()
|
||||||
|
self.complete_message_edit.setPlaceholderText("输入执行结果消息")
|
||||||
|
complete_layout.addRow(QLabel("执行消息:"), self.complete_message_edit)
|
||||||
|
|
||||||
|
# 文件上传
|
||||||
|
self.result_file_edit = QLineEdit()
|
||||||
|
self.result_file_edit.setReadOnly(True)
|
||||||
|
browse_btn = QPushButton("浏览")
|
||||||
|
browse_btn.clicked.connect(self.browse_result_file)
|
||||||
|
complete_layout.addRow(QLabel("结果文件:"), self.result_file_edit)
|
||||||
|
complete_layout.addRow(QLabel(" "), browse_btn)
|
||||||
|
|
||||||
|
layout.addLayout(complete_layout)
|
||||||
|
|
||||||
|
# 操作结果
|
||||||
|
self.operation_result = QTextEdit()
|
||||||
|
self.operation_result.setReadOnly(True)
|
||||||
|
self.operation_result.setPlaceholderText("操作结果将显示在这里")
|
||||||
|
layout.addWidget(QLabel("操作结果:"))
|
||||||
|
layout.addWidget(self.operation_result)
|
||||||
|
|
||||||
|
self.tabs.addTab(tab, "任务操作")
|
||||||
|
|
||||||
|
def toggle_token_visibility(self):
|
||||||
|
if self.token_edit.echoMode() == QLineEdit.Password:
|
||||||
|
self.token_edit.setEchoMode(QLineEdit.Normal)
|
||||||
|
self.token_visibility_btn.setText("隐藏")
|
||||||
|
else:
|
||||||
|
self.token_edit.setEchoMode(QLineEdit.Password)
|
||||||
|
self.token_visibility_btn.setText("显示")
|
||||||
|
|
||||||
|
def save_config(self):
|
||||||
|
server_url = self.server_url_edit.text()
|
||||||
|
token = self.token_edit.text()
|
||||||
|
|
||||||
|
if not server_url or not token:
|
||||||
|
QMessageBox.warning(self, "警告", "服务器地址和API Token不能为空!")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 保存配置到文件
|
||||||
|
try:
|
||||||
|
config = {
|
||||||
|
"server_url": server_url,
|
||||||
|
"token": token
|
||||||
|
}
|
||||||
|
with open("client_config.json", "w") as f:
|
||||||
|
json.dump(config, f)
|
||||||
|
|
||||||
|
QMessageBox.information(self, "成功", "配置已保存!")
|
||||||
|
self.api_client.set_server(server_url)
|
||||||
|
self.api_client.set_token(token)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "错误", f"保存配置失败: {str(e)}")
|
||||||
|
|
||||||
|
def test_connection(self):
|
||||||
|
server_url = self.server_url_edit.text()
|
||||||
|
token = self.token_edit.text()
|
||||||
|
|
||||||
|
if not server_url or not token:
|
||||||
|
QMessageBox.warning(self, "警告", "服务器地址和API Token不能为空!")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 测试连接
|
||||||
|
temp_client = ApiClient()
|
||||||
|
temp_client.set_server(server_url)
|
||||||
|
temp_client.set_token(token)
|
||||||
|
tasks = temp_client.get_tasks()
|
||||||
|
|
||||||
|
QMessageBox.information(self, "成功", f"连接成功!找到 {len(tasks)} 个任务")
|
||||||
|
self.connection_status.setText("已连接")
|
||||||
|
self.connection_status.setStyleSheet("color: green;")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "错误", f"连接失败: {str(e)}")
|
||||||
|
self.connection_status.setText(f"连接失败: {str(e)}")
|
||||||
|
self.connection_status.setStyleSheet("color: red;")
|
||||||
|
|
||||||
|
def refresh_tasks(self):
|
||||||
|
try:
|
||||||
|
tasks = self.api_client.get_tasks()
|
||||||
|
self.update_task_table(tasks)
|
||||||
|
self.status_bar.showMessage(f"刷新完成,共 {len(tasks)} 个任务")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "错误", str(e))
|
||||||
|
self.status_bar.showMessage(f"刷新失败: {str(e)}")
|
||||||
|
|
||||||
|
def update_task_table(self, tasks):
|
||||||
|
self.task_table.setRowCount(len(tasks))
|
||||||
|
|
||||||
|
for row, task in enumerate(tasks):
|
||||||
|
self.task_table.setItem(row, 0, QTableWidgetItem(str(task["id"])))
|
||||||
|
self.task_table.setItem(row, 1, QTableWidgetItem(task["name"]))
|
||||||
|
self.task_table.setItem(row, 2, QTableWidgetItem(task["client_name"] or "未指定"))
|
||||||
|
self.task_table.setItem(row, 3, QTableWidgetItem(task["assigned_to"] or "未分配"))
|
||||||
|
self.task_table.setItem(row, 4, QTableWidgetItem(task["status"]))
|
||||||
|
self.task_table.setItem(row, 5, QTableWidgetItem(task["created_at"]))
|
||||||
|
self.task_table.setItem(row, 6, QTableWidgetItem(task["started_at"] or "-"))
|
||||||
|
self.task_table.setItem(row, 7, QTableWidgetItem(task["completed_at"] or "-"))
|
||||||
|
|
||||||
|
def toggle_auto_refresh(self, state):
|
||||||
|
if state == Qt.Checked:
|
||||||
|
# 启动自动刷新线程
|
||||||
|
self.task_update_thread = TaskUpdateThread(self.api_client)
|
||||||
|
self.task_update_thread.update_signal.connect(self.update_task_table)
|
||||||
|
self.task_update_thread.error_signal.connect(self.show_thread_error)
|
||||||
|
self.task_update_thread.start()
|
||||||
|
self.status_bar.showMessage("已启动自动刷新")
|
||||||
|
else:
|
||||||
|
# 停止自动刷新线程
|
||||||
|
if self.task_update_thread:
|
||||||
|
self.task_update_thread.stop()
|
||||||
|
self.task_update_thread = None
|
||||||
|
self.status_bar.showMessage("已停止自动刷新")
|
||||||
|
|
||||||
|
def show_thread_error(self, error_msg):
|
||||||
|
self.status_bar.showMessage(f"自动刷新失败: {error_msg}")
|
||||||
|
|
||||||
|
def on_task_selected(self, row, column):
|
||||||
|
task_id = self.task_table.item(row, 0).text()
|
||||||
|
self.selected_task_id.setText(task_id)
|
||||||
|
|
||||||
|
# 显示任务详情
|
||||||
|
task_name = self.task_table.item(row, 1).text()
|
||||||
|
client_name = self.task_table.item(row, 2).text()
|
||||||
|
assigned_to = self.task_table.item(row, 3).text()
|
||||||
|
status = self.task_table.item(row, 4).text()
|
||||||
|
created_at = self.task_table.item(row, 5).text()
|
||||||
|
started_at = self.task_table.item(row, 6).text()
|
||||||
|
completed_at = self.task_table.item(row, 7).text()
|
||||||
|
|
||||||
|
detail = f"任务ID: {task_id}\n"
|
||||||
|
detail += f"任务名称: {task_name}\n"
|
||||||
|
detail += f"指定客户端: {client_name}\n"
|
||||||
|
detail += f"实际客户端: {assigned_to}\n"
|
||||||
|
detail += f"状态: {status}\n"
|
||||||
|
detail += f"创建时间: {created_at}\n"
|
||||||
|
detail += f"开始时间: {started_at}\n"
|
||||||
|
detail += f"完成时间: {completed_at}\n"
|
||||||
|
|
||||||
|
self.task_detail.setPlainText(detail)
|
||||||
|
|
||||||
|
def toggle_token_visibility(self):
|
||||||
|
if self.token_edit.echoMode() == QLineEdit.Password:
|
||||||
|
self.token_edit.setEchoMode(QLineEdit.Normal)
|
||||||
|
self.token_visibility_btn.setText("隐藏")
|
||||||
|
else:
|
||||||
|
self.token_edit.setEchoMode(QLineEdit.Password)
|
||||||
|
self.token_visibility_btn.setText("显示")
|
||||||
|
|
||||||
|
def create_task(self):
|
||||||
|
name = self.task_name_edit.text()
|
||||||
|
client_name = self.task_client_edit.text()
|
||||||
|
script = self.task_script_edit.toPlainText()
|
||||||
|
timeout = self.task_timeout_spin.value()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
QMessageBox.warning(self, "警告", "任务名称不能为空!")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.api_client.create_task(name, client_name, script, timeout)
|
||||||
|
self.create_result.setPlainText(json.dumps(result, indent=2, ensure_ascii=False))
|
||||||
|
QMessageBox.information(self, "成功", "任务创建成功!")
|
||||||
|
|
||||||
|
# 清空表单
|
||||||
|
self.task_name_edit.clear()
|
||||||
|
self.task_client_edit.clear()
|
||||||
|
self.task_script_edit.clear()
|
||||||
|
self.task_timeout_spin.setValue(3600)
|
||||||
|
except Exception as e:
|
||||||
|
self.create_result.setPlainText(str(e))
|
||||||
|
QMessageBox.critical(self, "错误", str(e))
|
||||||
|
|
||||||
|
def claim_task(self):
|
||||||
|
task_id = self.selected_task_id.text()
|
||||||
|
if not task_id:
|
||||||
|
QMessageBox.warning(self, "警告", "请输入任务ID!")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.api_client.claim_task(self.task_client_edit.text() or "default_client")
|
||||||
|
self.operation_result.setPlainText(json.dumps(result, indent=2, ensure_ascii=False))
|
||||||
|
QMessageBox.information(self, "成功", "任务认领成功!")
|
||||||
|
except Exception as e:
|
||||||
|
self.operation_result.setPlainText(str(e))
|
||||||
|
QMessageBox.critical(self, "错误", str(e))
|
||||||
|
|
||||||
|
def start_task(self):
|
||||||
|
task_id = self.selected_task_id.text()
|
||||||
|
if not task_id:
|
||||||
|
QMessageBox.warning(self, "警告", "请输入任务ID!")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.api_client.start_task(task_id)
|
||||||
|
self.operation_result.setPlainText(json.dumps(result, indent=2, ensure_ascii=False))
|
||||||
|
QMessageBox.information(self, "成功", "任务开始成功!")
|
||||||
|
except Exception as e:
|
||||||
|
self.operation_result.setPlainText(str(e))
|
||||||
|
QMessageBox.critical(self, "错误", str(e))
|
||||||
|
|
||||||
|
def complete_task(self):
|
||||||
|
task_id = self.selected_task_id.text()
|
||||||
|
if not task_id:
|
||||||
|
QMessageBox.warning(self, "警告", "请输入任务ID!")
|
||||||
|
return
|
||||||
|
|
||||||
|
status = self.complete_status_combo.currentText()
|
||||||
|
message = self.complete_message_edit.toPlainText()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.api_client.complete_task(task_id, status, message)
|
||||||
|
self.operation_result.setPlainText(json.dumps(result, indent=2, ensure_ascii=False))
|
||||||
|
QMessageBox.information(self, "成功", "任务完成成功!")
|
||||||
|
except Exception as e:
|
||||||
|
self.operation_result.setPlainText(str(e))
|
||||||
|
QMessageBox.critical(self, "错误", str(e))
|
||||||
|
|
||||||
|
def select_task_from_list(self):
|
||||||
|
selected_rows = self.task_table.selectedItems()
|
||||||
|
if selected_rows:
|
||||||
|
task_id = selected_rows[0].text()
|
||||||
|
self.selected_task_id.setText(task_id)
|
||||||
|
|
||||||
|
def browse_result_file(self):
|
||||||
|
file_path, _ = QFileDialog.getOpenFileName(self, "选择结果文件")
|
||||||
|
if file_path:
|
||||||
|
self.result_file_edit.setText(file_path)
|
||||||
|
|
||||||
|
def load_config(self):
|
||||||
|
try:
|
||||||
|
with open("client_config.json", "r") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
self.server_url_edit.setText(config.get("server_url", "http://127.0.0.1:8000/api"))
|
||||||
|
self.token_edit.setText(config.get("token", ""))
|
||||||
|
self.api_client.set_server(config.get("server_url", "http://127.0.0.1:8000/api"))
|
||||||
|
self.api_client.set_token(config.get("token", ""))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # 配置文件不存在,使用默认值
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.warning(self, "警告", f"加载配置失败: {str(e)}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
window = TaskCenterClient()
|
||||||
|
window.load_config()
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec())
|
||||||
BIN
db.sqlite3
BIN
db.sqlite3
Binary file not shown.
15
requirements.txt
Normal file
15
requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Django and REST Framework
|
||||||
|
django==5.0.6
|
||||||
|
djangorestframework==3.15.2
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest==8.3.2
|
||||||
|
pytest-django==4.11.1
|
||||||
|
factory_boy==3.3.3
|
||||||
|
|
||||||
|
# Client dependencies
|
||||||
|
pyside6==6.9.1
|
||||||
|
requests==2.32.3
|
||||||
|
|
||||||
|
# Other dependencies
|
||||||
|
python-dotenv==1.0.1
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -10,7 +10,12 @@ For the full list of settings and their values, see
|
|||||||
https://docs.djangoproject.com/en/5.0/ref/settings/
|
https://docs.djangoproject.com/en/5.0/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# 加载.env文件
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
@@ -19,13 +24,14 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# 基础配置
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = 'django-insecure-&_@v3e5@!x+fa5273@v=^&02p@(#b89=p^06ifule17*p+g@u8'
|
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-&_@v3e5@!x+fa5273@v=^&02p@(#b89=p^06ifule17*p+g@u8')
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = os.getenv('DEBUG', 'True') == 'True'
|
||||||
|
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '').split(',')
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
@@ -105,9 +111,9 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
# https://docs.djangoproject.com/en/5.0/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = 'zh-hans'
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = 'Asia/Shanghai'
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
@@ -117,7 +123,8 @@ USE_TZ = True
|
|||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
# https://docs.djangoproject.com/en/5.0/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = '/static/'
|
||||||
|
STATIC_ROOT = os.getenv('STATIC_ROOT', BASE_DIR / 'static')
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
|
||||||
@@ -133,7 +140,7 @@ REST_FRAMEWORK = {
|
|||||||
|
|
||||||
# Media files settings
|
# Media files settings
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
MEDIA_ROOT = os.getenv('MEDIA_ROOT', BASE_DIR / 'media')
|
||||||
|
|
||||||
# Test settings
|
# Test settings
|
||||||
# TEST_RUNNER = 'pytest_django.runner.DjangoPytestTestRunner' # Commented out as it's not needed for pytest
|
# TEST_RUNNER = 'pytest_django.runner.DjangoPytestTestRunner' # Commented out as it's not needed for pytest
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ Including another URLconf
|
|||||||
1. Import the include() function: from django.urls import include, path
|
1. Import the include() function: from django.urls import include, path
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from tasks.views_frontend import index
|
from tasks.views_frontend import index
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('houtai/', admin.site.urls),
|
||||||
path('api/', include('tasks.urls')),
|
path('api/', include('tasks.urls')),
|
||||||
# Root URL points to home page
|
# Root URL points to home page
|
||||||
path('', index, name='index'),
|
path('', index, name='index'),
|
||||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,56 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from .models import Client, Task, TaskResult
|
||||||
# Register your models here.
|
|
||||||
|
|
||||||
|
@admin.register(Client)
|
||||||
|
class ClientAdmin(admin.ModelAdmin):
|
||||||
|
"""客户端管理类"""
|
||||||
|
list_display = ('name', 'token', 'last_seen', 'created_at')
|
||||||
|
list_filter = ('created_at', 'last_seen')
|
||||||
|
search_fields = ('name',)
|
||||||
|
readonly_fields = ('token', 'created_at', 'last_seen')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Task)
|
||||||
|
class TaskAdmin(admin.ModelAdmin):
|
||||||
|
"""任务管理类"""
|
||||||
|
list_display = ('name', 'client_name', 'status', 'timeout_seconds', 'created_at', 'updated_at', 'assigned_to')
|
||||||
|
list_filter = ('status', 'created_at', 'updated_at', 'started_at', 'completed_at')
|
||||||
|
search_fields = ('name', 'client_name', 'assigned_to')
|
||||||
|
readonly_fields = ('created_at', 'updated_at', 'started_at', 'completed_at')
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('name', 'client_name', 'script', 'status', 'timeout_seconds')
|
||||||
|
}),
|
||||||
|
('执行信息', {
|
||||||
|
'fields': ('assigned_to', 'started_at', 'completed_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('时间信息', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TaskResult)
|
||||||
|
class TaskResultAdmin(admin.ModelAdmin):
|
||||||
|
"""任务结果管理类"""
|
||||||
|
list_display = ('task', 'client', 'status', 'created_at')
|
||||||
|
list_filter = ('status', 'created_at', 'client')
|
||||||
|
search_fields = ('task__name', 'client__name', 'message')
|
||||||
|
readonly_fields = ('created_at',)
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('task', 'client', 'status', 'message')
|
||||||
|
}),
|
||||||
|
('结果文件', {
|
||||||
|
'fields': ('result_file',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('时间信息', {
|
||||||
|
'fields': ('created_at',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
import secrets
|
||||||
|
|
||||||
# Status choices for tasks
|
# Status choices for tasks
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
@@ -20,6 +21,13 @@ class Client(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Generate a unique token if this is a new client
|
||||||
|
if not self.pk and not self.token:
|
||||||
|
# Use secrets module to generate a secure random token
|
||||||
|
self.token = secrets.token_urlsafe(64) # 64 bytes -> ~86 characters
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = '客户端'
|
verbose_name = '客户端'
|
||||||
@@ -57,4 +65,4 @@ class TaskResult(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = '任务结果'
|
verbose_name = '任务结果'
|
||||||
verbose_name_plural = '任务结果'
|
verbose_name_plural = '任务结果'
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<a class="nav-link" href="{% url 'client_list' %}">客户端管理</a>
|
<a class="nav-link" href="{% url 'client_list' %}">客户端管理</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="/admin/">后台管理</a>
|
<a class="nav-link" href="/houtai/">后台管理</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<a class="btn btn-primary btn-lg" href="{% url 'task_list' %}" role="button">查看任务</a>
|
<a class="btn btn-primary btn-lg" href="{% url 'task_list' %}" role="button">查看任务</a>
|
||||||
<a class="btn btn-secondary btn-lg" href="/admin/" role="button">后台管理</a>
|
<a class="btn btn-secondary btn-lg" href="/houtai/" role="button">后台管理</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">后台管理</h5>
|
<h5 class="card-title">后台管理</h5>
|
||||||
<p class="card-text">使用Django Admin进行系统管理,包括用户、任务和客户端。</p>
|
<p class="card-text">使用Django Admin进行系统管理,包括用户、任务和客户端。</p>
|
||||||
<a href="/admin/" class="btn btn-primary">进入</a>
|
<a href="/houtai/" class="btn btn-primary">进入</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user