【Flask】测试平台开发,工具模块开发 第二十二篇

概述:

开发这个功能的需求是希望本地的工具可以有一个快速查找的地方,这样,其他人员可以直接删除工具使用,另外附加工具的说明书和导出说明书的功能,方便快速使用

最终效果如下

首先我们先设计存储上传工具文件的数据文件表,表结构如下

复制代码
/*
 Navicat Premium Dump SQL

 Source Server         : rebort-测试开发平台开发
 Source Server Type    : MySQL
 Source Server Version : 80042 (8.0.42)
 Source Host           : localhost:3306
 Source Schema         : tpmdatas

 Target Server Type    : MySQL
 Target Server Version : 80042 (8.0.42)
 File Encoding         : 65001

 Date: 08/09/2025 09:08:21
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for file
-- ----------------------------
DROP TABLE IF EXISTS `file`;
CREATE TABLE `file`  (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '文件ID',
  `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '原始文件名',
  `file_path` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '服务器存储路径',
  `file_size` bigint NOT NULL COMMENT '文件大小(字节)',
  `file_type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件类型(MIME类型)',
  `md5` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '文件MD5(防重复上传)',
  `upload_user_id` int NOT NULL COMMENT '上传人ID',
  `upload_time` datetime NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `uk_md5`(`md5` ASC) USING BTREE COMMENT 'MD5唯一索引(避免重复上传)'
) ENGINE = InnoDB AUTO_INCREMENT = 39 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '文件存储表' ROW_FORMAT = Dynamic;

SET FOREIGN_KEY_CHECKS = 1;

我们采取直传的方式实现,这样有利于上传大文件,为什么不采用分片上传,后续在合并,这样比较复杂,会有很多维护逻辑,这个功能页面主要是存储一些工具,怎么快速实现,提高效率才是我们考虑的方案

建立文件数据模型

模型中的源码如下

复制代码
from extensions import db
from datetime import datetime

class File(db.Model):
    __tablename__ = 'file'

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    file_name = db.Column(db.String(255), nullable=False)
    file_path = db.Column(db.String(512), nullable=False)
    file_size = db.Column(db.BigInteger, nullable=False)
    file_type = db.Column(db.String(50))
    md5 = db.Column(db.String(32), unique=True)
    upload_user_id = db.Column(db.Integer, nullable=False)
    upload_time = db.Column(db.DateTime, default=datetime.now)

    md5 = db.Column(db.String(32), unique=True, nullable=False)

    def to_dict(self):
        return {
            'id': self.id,
            'file_name': self.file_name,
            'file_path': self.file_path,
            'file_size': self.file_size,
            'file_type': self.file_type,
            'md5': self.md5,
            'upload_user_id': self.upload_user_id,
            'upload_time': self.upload_time.strftime('%Y-%m-%d %H:%M:%S')
        }

定时后端创建上传文件的接口

复制代码
@upload_bp.post('/simple')
def simple_upload():
    try:
        # 1. 基础校验:是否有文件
        if 'file' not in request.files:
            return jsonify({"code": 400, "msg": "未选择文件"}), 400
        file = request.files['file']
        if file.filename == '':
            return jsonify({"code": 400, "msg": "文件名不能为空"}), 400

        # 2. 文件格式校验
        if not allowed_file(file.filename):
            allowed_str = ', '.join(ALLOWED_EXTENSIONS)
            return jsonify({
                "code": 400,
                "msg": f"不支持的文件格式,仅允许:{allowed_str}"
            }), 400

        # 3. 保存文件到服务器(按日期分类)
        date_dir = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
        save_dir = os.path.join(UPLOAD_FOLDER, date_dir)
        os.makedirs(save_dir, exist_ok=True)
        file_path = os.path.join(save_dir, file.filename)
        file.save(file_path)

        # 4. 记录到数据库
        file_size = os.path.getsize(file_path)
        new_file = File(
            file_name=file.filename,
            file_path=os.path.join(date_dir, file.filename),  # 相对路径
            file_size=file_size,
            upload_user_id=1  # 实际从登录态获取
        )
        db.session.add(new_file)
        db.session.commit()

        # 5. 返回成功结果
        return jsonify({
            "code": 200,
            "data": {
                "file_id": new_file.id,
                "file_name": new_file.file_name,
                "file_size": file_size
            },
            "msg": "上传成功"
        })

    except Exception as e:
        return jsonify({"code": 500, "msg": f"上传失败: {str(e)}"}), 500

我们还需要一个工具创建完成后,列表页面获取数据展示的接口,同时需要直接搜索,支持分页

复制代码
@tool_bp.get('/list')
def get_tool_list():
    """获取工具列表(含主文件ID,用于下载)"""
    page = request.args.get('page', 1, type=int)
    limit = request.args.get('limit', 10, type=int)
    search_key = request.args.get('searchKey', '').strip()

    # 1. 基础查询(仅返回ID和main_file_id有效且为正整数的工具)
    query = Tool.query.filter(
        Tool.id > 0,  # 过滤无效工具ID
        Tool.main_file_id > 0  # 过滤无效文件ID
    ).order_by(Tool.created_at.desc())

    # 2. 搜索功能
    if search_key:
        query = query.filter(
            db.or_(
                Tool.name.ilike(f'%{search_key}%'),
                Tool.version.ilike(f'%{search_key}%')
            )
        )

    # 3. 分页查询
    pagination = query.paginate(page=page, per_page=limit)
    tools = pagination.items

    # 4. 格式化响应(确保ID为数字)
    tool_list = []
    for tool in tools:
        main_file = File.query.get(tool.main_file_id)
        tool_list.append({
            "id": tool.id,  # 工具ID(数字)
            "name": tool.name,
            "version": tool.version,
            "main_file_id": tool.main_file_id,  # 文件ID(数字)
            "main_file_name": main_file.file_name if main_file else "未知文件",
            "file_size": main_file.file_size if main_file else 0,
            "created_at": tool.created_at.strftime("%Y-%m-%d %H:%M:%S")
        })

    return jsonify({
        "code": 20000,
        "data": {
            "items": tool_list,
            "total": pagination.total,
            "page": page,
            "limit": limit
        }
    })

另外还需要创建工具上传,此时需要一个创建工具的接口

复制代码
@tool_bp.post('/create')
def create_tool():
    """创建工具接口"""
    data = request.json

    # 1. 提取参数(增加默认值处理,避免KeyError)
    name = data.get('name', '').strip()
    version = data.get('version', '').strip()
    description = data.get('description', '').strip()
    main_file_id = data.get('main_file_id')  # 主文件ID(必填)
    user_id = data.get('user_id', 1)  # 用户ID(实际项目中从登录态获取)

    # 2. 参数校验(必须在return之前执行!)
    if not all([name, version, main_file_id]):
        return jsonify({"code": 40000, "message": "工具名称、版本和主文件ID为必填项"}), 400

    # 3. 主文件ID转换为整数
    try:
        main_file_id = int(main_file_id)
    except (ValueError, TypeError):
        return jsonify({"code": 40001, "message": "主文件ID必须为整数"}), 400

    # 4. 校验主文件是否存在
    main_file = File.query.get(main_file_id)
    if not main_file:
        return jsonify({"code": 40400, "message": f"主文件不存在(ID: {main_file_id})"}), 404

    # 5. 业务逻辑:创建工具记录(核心逻辑)
    try:
        new_tool = Tool(
            name=name,
            version=version,
            description=description,
            main_file_id=main_file_id,
            create_user_id=user_id,  # 关联创建者ID(用于列表接口权限过滤)
            created_at=datetime.now()  # 显式设置创建时间(确保排序正确)
        )
        db.session.add(new_tool)  # 添加到会话
        db.session.commit()       # 提交事务(关键:写入数据库)
        return jsonify({
            "code": 20000,
            "message": "工具创建成功",
            "data": new_tool.to_dict()  # 返回创建的工具详情(可选)
        })
    except Exception as e:
        db.session.rollback()  # 异常时回滚事务(避免数据不一致)
        current_app.logger.error(f"创建工具失败:{str(e)}")  # 记录错误日志
        return jsonify({"code": 50000, "message": f"创建失败:{str(e)}"}), 500

此时后端接口定义完成,并且我们对接口进行了测试,接口流程目前是通的,可以进行使用

接下来编写前端的代码

分别是

工具列表页面

复制代码
<template>
  <el-container>
    <!-- 侧边栏(保留原有宽度5px) -->
    <el-aside width="5px">
      <!-- 侧边导航内容(原样式保留) -->
    </el-aside>
    <!-- 主内容区(保留原有样式结构) -->
    <el-main class="main-content">
      <div class="tool-list-container">
        <!-- 顶部按钮和搜索区(保留原有布局) -->
        <el-row :gutter="20" class="header-row">
          <el-col :span="8">
            <el-input
              v-model="searchKey"
              placeholder="搜索工具名称/版本"
              clearable
              @keyup.enter.native="fetchTools"
              size="small"
            />
          </el-col>
          <el-col :span="4">
            <el-button
              type="primary"
              size="small"
              @click="fetchTools"
            >搜索</el-button>
          </el-col>
          <el-col :span="4" :offset="8" style="text-align: right;">
            <el-button
              type="primary"
              size="small"
              @click="goToCreate"
            >创建工具</el-button>
          </el-col>
        </el-row>

        <!-- 工具表格(保留原有列和样式) -->
        <el-table
          :data="toolList"
          style="width: 100%; margin-top: 20px;"
          border
          stripe
          size="small"
        >
          <el-table-column prop="name" label="工具名称" min-width="150" />
          <el-table-column prop="version" label="版本" min-width="80" />
          <el-table-column prop="main_file_name" label="主文件" min-width="180" />
          <el-table-column label="文件大小" min-width="100">
            <template slot-scope="scope">
              {{ formatFileSize(scope.row.file_size) }}
            </template>
          </el-table-column>
          <el-table-column prop="created_at" label="创建时间" min-width="160" />
          <el-table-column label="操作" min-width="180" fixed="right">
            <template slot-scope="scope">
              <!-- 下载按钮(修复:传递main_file_id,保留原有样式) -->
              <el-button
                type="text"
                size="small"
                @click="downloadTool(scope.row.main_file_id)"
                :disabled="!isValidId(scope.row.main_file_id)">
                <i class="el-icon-download" /> 下载  <!-- 保留原有图标 -->
              </el-button>
              <!-- 查看说明书按钮(修复:传递tool_id,保留原有样式) -->
              <el-button
                type="text"
                size="small"
                @click="viewManual(scope.row.id)"
                :disabled="!isValidId(scope.row.id)">
                <i class="el-icon-document" /> 查看说明书</el-button>
            </template>
          </el-table-column>
        </el-table>

        <!-- 分页控件(保留原有样式和事件) -->
        <el-pagination
          @current-change="handlePageChange"
          @size-change="handleSizeChange"
          :current-page="page"
          :page-size="limit"
          :total="total"
          layout="total, sizes, prev, pager, next, jumper"
          size="small"
          style="margin-top: 15px; text-align: right;"
        />
      </div>
    </el-main>
  </el-container>
</template>

<script>
import { getToolList } from '@/api/tool'// 保留原有接口导入

export default {
  data() {
    return {
      toolList: [], // 工具列表数据(原有)
      searchKey: '', // 搜索关键词(原有)
      page: 1, // 当前页码(原有)
      limit: 10, // 每页条数(原有)
      total: 0 // 总条数(原有)
    }
  },
  watch: {
    // 保留路由刷新监听(原有)
    '$route.query.refresh'(newVal) {
      if (newVal === 'true') {
        this.refreshList()
        this.$router.replace({ query: {}})
      }
    }
  },
  mounted() {
    this.fetchTools() // 页面加载时获取工具列表(原有)
  },
  methods: {
    /** 获取工具列表数据(保留原有逻辑) */
    async fetchTools() {
      try {
        const res = await getToolList({
          page: this.page,
          limit: this.limit,
          searchKey: this.searchKey
        })
        if (res.code === 20000) {
          this.toolList = res.data.items
          this.total = res.data.total
          // 调试:打印工具数据,确认ID是否有效
          console.log('工具列表数据:', this.toolList)
        } else {
          this.$message.error('获取工具列表失败')
        }
      } catch (e) {
        this.$message.error(`接口请求失败: ${e.message}`)
      }
    },
    refreshList() { // 保留原有刷新逻辑
      this.page = 1
      this.fetchTools()
    },
    formatFileSize(size) { // 保留原有文件大小格式化
      if (size <= 0) return '0 B'
      const units = ['B', 'KB', 'MB', 'GB']
      const i = Math.floor(Math.log(size) / Math.log(1024))
      return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
    },
    // 分页相关方法(全部保留原有)
    handlePageChange(page) {
      this.page = page
      this.fetchTools()
    },
    handleSizeChange(limit) {
      this.limit = limit
      this.page = 1
      this.fetchTools()
    },
    goToCreate() { // 保留跳转创建工具页
      this.$router.push('/tool/create')
    },

    /** 🌟 修复:下载功能(校验文件ID有效性) */
    downloadTool(fileId) {
      if (!this.isValidId(fileId)) {
        this.$message.warning('无效的文件ID,无法下载')
        return
      }
      window.open(`/api/tool/download/${fileId}`, '_self')
    },

    /** 🌟 修复:查看说明书(校验工具ID有效性) */
    viewManual(toolId) {
      if (!this.isValidId(toolId)) {
        this.$message.warning('无效的工具ID,无法查看说明书')
        return
      }
      this.$router.push(`/tool/manual/${toolId}`) // 传递有效toolId
    },

    /** 🌟 新增:通用ID校验(正整数判断) */
    isValidId(id) {
      return typeof id === 'number' && !isNaN(id) && id > 0
    }
  }
}
</script>

<style scoped>
/* 保留所有原有样式,未做任何修改 */
.main-content {
  padding: 20px;
  margin: 0;
  background-color: #f5f5f5;
}
.tool-list-container {
  background-color: white;
  padding: 20px;
  border-radius: 4px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.header-row {
  margin-bottom: 10px;
}
</style>

工具创建页面

复制代码
<template>
  <div class="tool-create">
    <el-card>
      <el-form :model="toolForm" :rules="rules" ref="toolForm" label-width="100px">
        <!-- 工具名称 -->
        <el-form-item label="工具名称" prop="name">
          <el-input v-model="toolForm.name" placeholder="请输入工具名称" />
        </el-form-item>

        <!-- 工具版本 -->
        <el-form-item label="工具版本" prop="version">
          <el-input v-model="toolForm.version" placeholder="请输入工具版本(如1.0.0)" />
        </el-form-item>

        <!-- 主文件上传 -->
        <el-form-item label="主文件上传" prop="mainFileId">
          <!-- 引入上传组件,传递后端接口地址 -->
          <file-upload
            :upload-url="uploadUrl"
            @upload-success="handleFileSuccess"
          />
          <!-- 显示已上传文件信息 -->
          <p v-if="mainFileInfo" class="file-info">
            已上传: {{ mainFileInfo.file_name }} ({{ formatFileSize(mainFileInfo.file_size) }})
          </p>
        </el-form-item>
<!--        <el-form-item label="说明书上传">-->
<!--          <file-upload-->
<!--            :upload-url="uploadUrl"-->
<!--            @upload-success="handleManualSuccess"-->
<!--          />-->
<!--          <p v-if="manualFileInfo" class="file-info">-->
<!--            已上传说明书: {{ manualFileInfo.file_name }} ({{ formatFileSize(manualFileInfo.file_size) }})-->
<!--          </p>-->
<!--        </el-form-item>-->

        <!-- 工具简介 -->
        <el-form-item label="工具简介" prop="description">
          <el-input type="textarea" v-model="toolForm.description" rows="4" placeholder="请输入工具简介(选填)" />
        </el-form-item>

        <!-- 提交按钮 -->
        <el-form-item>
          <el-button type="primary" @click="submitForm">创建工具</el-button>
          <el-button @click="resetForm">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<script>
import FileUpload from '@/views/tools/FileUpload' // 引入上传组件
import { createTool } from '@/api/tool' // 引入创建工具接口

export default {
  components: { FileUpload },
  data() {
    return {
      // 后端接口地址(统一管理,方便切换环境)
      uploadUrl: 'http://172.16.60.60:5000/api/upload/simple', // 文件上传接口
      createToolUrl: 'http://172.16.60.60:5000/api/tool/create', // 创建工具接口
      manualFileInfo: null, // 说明书文件信息
      // 表单数据
      toolForm: {
        name: '', // 工具名称(必填)
        version: '', // 工具版本(必填)
        description: '', // 工具简介(选填)
        mainFileId: null // 主文件ID(上传后由子组件赋值)
      },
      mainFileInfo: null, // 已上传文件信息
      manual_file_id: null, // 新增:说明书文件ID
      // 表单校验规则
      rules: {
        name: [
          { required: true, message: '请输入工具名称', trigger: 'blur' },
          { min: 2, max: 50, message: '名称长度必须在2-50之间', trigger: 'blur' }
        ],
        version: [
          { required: true, message: '请输入工具版本', trigger: 'blur' },
          { pattern: /^[\d.]+$/, message: '版本号只能包含数字和点', trigger: 'blur' }
        ],
        mainFileId: [
          { required: true, message: '请上传主文件', trigger: 'change' }
        ]
      }
    }
  },
  methods: {
    // 接收说明书上传成功事件
    handleManualSuccess(fileId, fileInfo) {
      this.toolForm.manual_file_id = Number(fileId) // 存储说明书文件ID
      this.manualFileInfo = fileInfo
    },
    // 接收子组件传递的文件ID
    handleFileSuccess(fileId, fileInfo) {
      this.toolForm.mainFileId = Number(fileId) // 强制转为数字(关键:避免字符串ID)
      this.mainFileInfo = fileInfo
      console.log('文件上传成功,mainFileId:', this.toolForm.mainFileId) // 调试日志
    },

    // 提交表单
    async submitForm() {
      this.$refs.toolForm.validate(async(valid) => {
        if (valid) {
          try {
            // 构造创建工具的参数
            const toolParams = {
              name: this.toolForm.name.trim(),
              version: this.toolForm.version.trim(),
              description: this.toolForm.description.trim() || '', // 空字符串代替null
              main_file_id: this.toolForm.mainFileId, // 确保是数字类型
              user_id: 1, // 实际项目中从登录态获取,如store.state.user.id
              manual_file_id: this.toolForm.manual_file_id // 新增:传递说明书ID
            }
            console.log('提交创建工具参数:', toolParams) // 调试日志

            // 调用创建工具接口
            const response = await createTool(toolParams)
            console.log('创建工具接口响应:', response) // 调试日志

            // 后端返回成功(根据后端实际响应格式调整)
            if (response.code === 20000) {
              this.$message.success('工具创建成功!')
              // this.$router.push({path:'/tool/list',query:{refresh:'true'}}),// 跳转到工具列表页
              this.$router.push({
                path: '/tool/list',
                query: { refresh: 'true' }})
            } else {
              this.$message.error(`创建失败: ${response.msg || '未知错误'}`)
            }
          } catch (e) {
            // 捕获网络错误或接口异常
            console.error('创建工具失败(异常):', {
              status: e.response?.status,
              data: e.response?.data,
              message: e.message
            })
            this.$message.error(`创建失败: ${e.response?.data?.msg || e.message || '网络错误'}`)
          }
        } else {
          this.$message.warning('表单填写不完整,请检查!')
        }
      })
    },

    // 重置表单
    resetForm() {
      this.$refs.toolForm.resetFields()
      this.mainFileInfo = null
      this.toolForm.mainFileId = null
    },

    // 格式化文件大小
    formatFileSize(size) {
      if (!size) return '0 KB'
      if (size < 1024 * 1024) {
        return (size / 1024).toFixed(2) + ' KB'
      } else {
        return (size / (1024 * 1024)).toFixed(2) + ' MB'
      }
    }
  }
}
</script>

<style scoped>
.file-info { margin-top: 8px; color: #606266; font-size: 14px; }
</style>

定义前端的接口和路由部分,这里我们就不写了,都是一样的操作,想想看过前面的文章,定义路由和接口,你已经很熟悉了

重新启动前端和后端,此时我们测试页面是不是正常,功能正常可以使用

上传工具后跳转工具列表页面,展示工具的名称和大小信息,其他人员在这个页面就可以下载和获取工具去使用,菜单栏中有个查看说明书的功能,点击后可以查看说明书信息,我们在前面章节讲解了vue中集成编辑器的功能,查看上一章,编辑器的作用主要用在这里进行说明书的编写,导出说明书

效果如下

可以看到说明书信息,也可以导出文件到本地发送给其他人员

工具上传后,当然我们还需要能将这个工具下载下来进行使用,别的同事可以在工具页面点击后直接可以下载使用

实现下载接口

复制代码
@tool_bp.get('/download/<int:file_id>')
def download_file(file_id):
    try:
        # 参数校验
        if file_id <= 0:
            return jsonify({"code": 40000, "message": "文件ID必须为正整数"}), 400

        # 查询文件记录
        file = File.query.get(file_id)
        if not file:
            return jsonify({"code": 40400, "message": "文件记录不存在"}), 404

        # 构建绝对路径(关键修改点)
        base_dir = "/workplaywright/Flask_vss_rebort/uploads"  # 必须与原上传接口存储根目录一致
        full_path = os.path.join(base_dir, file.file_path)  # 拼接绝对路径
        full_path = os.path.abspath(full_path)  # 标准化路径

        # 安全检查:防止路径穿越
        if not full_path.startswith(os.path.abspath(base_dir)):
            return jsonify({"code": 40300, "message": "非法文件路径"}), 403

        # 验证文件存在
        if not os.path.exists(full_path):
            current_app.logger.error(f"文件不存在: {full_path}")
            return jsonify({"code": 40400, "message": "文件不存在或已删除"}), 404

        # 发送文件
        return send_file(
            full_path,
            as_attachment=True,
            download_name=file.file_name,
            mimetype='application/octet-stream'
        )

    except Exception as e:
        current_app.logger.error(f"下载失败: {str(e)}")
        return jsonify({"code": 50000, "message": f"文件下载失败: {str(e)}"}), 500

定义前端下载调用

复制代码
downloadTool(fileId) {
      if (!this.isValidId(fileId)) {
        this.$message.warning('无效的文件ID,无法下载')
        return
      }
      window.open(`/api/tool/download/${fileId}`, '_self')
    },

重新启动前端和后端服务,上传一个工具,验证此时点击下载,是不是可以下载成功

前端点击下载后,可以下载文件,工具列表展示,下载,工具实现完成

相关推荐
浦信仿真大讲堂20 分钟前
从重复操作到自动化闭环:如何让 CST 与 Python 真正协同起来
python·自动化·cst·仿真软件·达索软件
summerkissyou198734 分钟前
Android 16 架构图
android
Gu Gu Study1 小时前
ScoutLoop开放域深度研究引擎(agent的初步设计想法)
人工智能·python
神龙天舞20011 小时前
MySQL 备库为什么会延迟好几个小时
android·数据库·mysql
卷无止境2 小时前
写代码这件事,到底该讲究点什么?
后端·python
卷无止境2 小时前
循环复杂度到底在算什么,Python 代码怎么才能写得让人一看就懂
后端·python
lpfasd1232 小时前
MediaCrawler 项目深度分析
chrome·python·chrome devtools
Dxy12393102162 小时前
Python项目打包成EXE完整教程(PyInstaller实战避坑)
开发语言·python
bamb003 小时前
一个项目带你入门AI应用开发01
python
0566463 小时前
Python康复训练——常用标准库
开发语言·python·学习