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

首先我们先设计存储上传工具文件的数据文件表,表结构如下
/*
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')
},
重新启动前端和后端服务,上传一个工具,验证此时点击下载,是不是可以下载成功

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