From 665612d2bf9b22d137b3b64dc14a33609733d1c8 Mon Sep 17 00:00:00 2001 From: python Date: Sun, 14 Dec 2025 16:56:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0.env=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=BB=A5=E5=8F=8D=E6=98=A0=E6=96=B0=E7=9A=84MinIO=E5=92=8CAI?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=90=8C=E6=97=B6?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=97=A0=E7=94=A8=E7=9A=84=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=92=8C=E6=A8=A1=E6=9D=BF=E6=96=87=E4=BB=B6=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E4=BA=86=E7=B3=BB=E7=BB=9F=E7=9A=84=E7=81=B5=E6=B4=BB?= =?UTF-8?q?=E6=80=A7=E5=92=8C=E5=8F=AF=E7=BB=B4=E6=8A=A4=E6=80=A7=E3=80=82?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=E6=95=B0=E6=8D=AE=E5=BA=93=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E5=92=8C=E6=81=A2=E5=A4=8D=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=80=9A=E8=BF=87API=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=A7=9F=E6=88=B7ID=E5=92=8C=E5=AD=97=E6=AE=B5=E5=85=B3?= =?UTF-8?q?=E8=81=94=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BA=86=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=AD=97=E6=AE=B5=E7=AE=A1=E7=90=86=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursorrules | 324 +++++++ .env | 84 +- app.py | 674 +++++++++++-- requirements.txt | 1 + static/template_field_manager.html | 913 +++++++++++++++--- .../MinIO迁移完成总结.md | 0 .../MinIO远程服务器测试结果.md | 0 .../MinIO问题分析和解决方案.md | 0 .../模板字段关联查询说明.md | 0 9 files changed, 1750 insertions(+), 246 deletions(-) create mode 100644 .cursorrules rename MinIO迁移完成总结.md => 技术文档/MinIO迁移完成总结.md (100%) rename MinIO远程服务器测试结果.md => 技术文档/MinIO远程服务器测试结果.md (100%) rename MinIO问题分析和解决方案.md => 技术文档/MinIO问题分析和解决方案.md (100%) rename 模板字段关联查询说明.md => 技术文档/模板字段关联查询说明.md (100%) diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..ce74289 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,324 @@ +# 智慧监督AI文书写作服务 - AI开发手册 + +## 项目背景 + +本项目是一个基于大模型的智能文书生成服务,主要功能包括: +- 从非结构化文本中提取结构化字段数据(使用AI大模型) +- 根据字段数据填充Word模板生成正式文书 +- 支持多种业务类型的文书模板管理 +- 文档存储和下载管理(MinIO对象存储) + +核心业务流程: +1. 接收输入的非结构化文本数据 +2. 使用AI大模型提取结构化字段 +3. 根据字段数据填充Word模板 +4. 生成文档并上传到MinIO +5. 返回文档下载链接 + +## 技术栈与编码标准 + +### 核心技术栈 +- **Python版本**: Python 3.8+ +- **Web框架**: Flask 3.0.0 +- **数据库**: MySQL (使用PyMySQL 1.1.2) +- **文档处理**: python-docx 1.1.0 +- **对象存储**: MinIO 7.2.3 +- **AI服务**: + - 华为大模型 (DeepSeek-R1-Distill-Llama-70B) + - 硅基流动 (DeepSeek-V3.2-Exp) +- **其他依赖**: flask-cors, flasgger, python-dotenv, requests, openpyxl, json-repair + +### 编码规范 +- **代码风格**: 遵循PEP 8规范 +- **命名规范**: + - 类名使用大驼峰命名(PascalCase):`AIService`, `DocumentService` + - 函数和变量使用小写下划线命名(snake_case):`get_connection`, `field_data` + - 常量使用全大写下划线命名:`AI_PROVIDER`, `DB_HOST` +- **注释要求**: + - 所有类和方法必须有docstring(使用三引号) + - 复杂逻辑必须添加行内注释 + - 使用中文注释(项目统一使用中文) +- **类型提示**: 建议使用类型提示(typing模块),提高代码可读性 +- **异常处理**: 必须使用try-except捕获异常,并提供有意义的错误信息 + +### 文件编码 +- 所有Python文件使用UTF-8编码 +- 文件开头不需要BOM标记 + +## 项目文件结构 + +``` +. +├── app.py # Flask主应用,定义所有API路由 +├── requirements.txt # Python依赖列表 +├── .env.example # 环境变量配置示例 +├── .cursorrules # AI开发手册(本文件) +├── services/ # 服务层(业务逻辑) +│ ├── __init__.py +│ ├── ai_service.py # AI服务:封装大模型调用逻辑 +│ ├── document_service.py # 文档服务:Word模板填充、MinIO上传 +│ ├── field_service.py # 字段服务:数据库字段配置查询 +│ └── ai_logger.py # AI日志记录器 +├── utils/ # 工具类 +│ ├── __init__.py +│ └── response.py # 统一API响应格式工具 +├── config/ # 配置文件 +│ ├── prompt_config.json # AI提示词配置 +│ └── field_defaults.json # 字段默认值配置 +├── static/ # 静态文件 +│ ├── index.html # 测试页面 +│ └── template_field_manager.html # 模板字段管理页面 +├── template/ # Word模板文件目录 +├── template_finish/ # 已完成的模板文件 +├── test_scripts/ # 测试脚本 +└── 技术文档/ # 技术文档目录 +``` + +## 架构约束与最佳实践 + +### 1. 分层架构 +项目采用分层架构,严格遵循以下层次: + +- **路由层** (`app.py`): 只负责接收HTTP请求、参数验证、调用服务层、返回响应 +- **服务层** (`services/`): 包含所有业务逻辑,服务类之间可以相互调用 +- **工具层** (`utils/`): 提供通用工具函数,不包含业务逻辑 +- **数据层**: 数据库操作封装在服务层中,不单独抽象 + +**重要原则**: +- 路由层不包含业务逻辑,只做参数验证和响应格式化 +- 服务层方法应该是可测试的,不依赖Flask的request对象 +- 数据库连接在使用后必须关闭(使用try-finally确保) + +### 2. 服务层设计规范 + +#### AI服务 (`services/ai_service.py`) +- 负责所有AI大模型调用 +- 支持多种AI服务提供商(华为、硅基流动),通过环境变量切换 +- 必须处理JSON解析失败的情况,提供多种修复机制 +- 字段名规范化:将AI返回的各种字段名格式映射到正确的字段编码 +- 日期格式规范化:统一转换为中文格式(YYYY年MM月 或 YYYY年MM月DD日) +- 后处理:从已有信息推断缺失字段(如从出生年月计算年龄) + +#### 文档服务 (`services/document_service.py`) +- 负责Word模板下载、填充、上传 +- 占位符格式:`{{field_code}}`(双大括号) +- 必须处理表格中的占位符(使用索引访问,避免迭代器bug) +- 提供XML备用方案处理特殊表格结构 +- 文档名称生成:从原始文件名提取基础名称,添加被核查人姓名后缀 +- MinIO路径格式:`/{tenant_id}/{timestamp}/{file_name}` + +#### 字段服务 (`services/field_service.py`) +- 负责从数据库查询字段配置 +- 构建AI提示词:根据输入数据和输出字段配置生成提示词 +- 支持从配置文件加载提示词模板和字段默认值 + +### 3. 数据库设计规范 + +#### 主要数据表 +- `f_polic_field`: 字段配置表 + - `id`: 主键 + - `name`: 字段名称 + - `filed_code`: 字段编码(注意:数据库字段名是filed_code,不是field_code) + - `field_type`: 字段类型(1=输入字段,2=输出字段) + - `state`: 状态(1=启用,0=禁用) + +- `f_polic_file_config`: 文件配置表(模板配置) + - `id`: 主键(作为fileId使用) + - `name`: 文件名称 + - `file_path`: MinIO中的文件路径 + - `input_data`: JSON格式的输入数据配置 + - `state`: 状态(1=启用,0=禁用) + +- `f_polic_file_field`: 文件字段关联表 + - `file_id`: 文件配置ID + - `filed_id`: 字段ID + - `state`: 状态(1=启用,0=禁用) + +#### 数据库操作规范 +- 所有数据库操作必须使用参数化查询,防止SQL注入 +- 使用`pymysql.cursors.DictCursor`获取字典格式结果 +- 数据库连接必须在使用后关闭(try-finally模式) +- 事务操作必须正确处理回滚 + +### 4. API设计规范 + +#### 统一响应格式 +所有API必须使用`utils/response.py`中的工具函数: + +**成功响应**: +```python +return success_response(data={'key': 'value'}, msg="ok") +``` + +**错误响应**: +```python +return error_response(code=400, error_msg="错误信息") +``` + +**响应结构**: +```json +{ + "code": 0, // 0表示成功,其他值表示错误码 + "data": {}, // 响应数据 + "msg": "ok", // 响应消息 + "timestamp": "1234567890", // 时间戳(毫秒) + "errorMsg": "", // 错误信息(成功时为空) + "isSuccess": true // 是否成功 +} +``` + +#### API路由规范 +- 使用`@app.route`装饰器定义路由 +- 支持Swagger文档(使用flasgger) +- 路由路径使用小写字母和连字符:`/api/ai/extract` +- 保留旧路径以兼容:`/ai/extract` 和 `/api/ai/extract` 同时支持 + +#### 错误码规范 +- `0`: 成功 +- `400`: 请求参数错误 +- `500`: 服务器内部错误 +- `1001`: 模板不存在 +- `2001`: AI解析超时 +- `2002`: AI解析失败 +- `3001`: 文件生成失败 +- `3002`: 文件保存失败 + +### 5. 环境变量配置 + +所有配置通过环境变量管理,使用`.env`文件(不要提交到版本控制): + +**必需配置**: +- `DB_HOST`: 数据库主机 +- `DB_PORT`: 数据库端口 +- `DB_USER`: 数据库用户名 +- `DB_PASSWORD`: 数据库密码 +- `DB_NAME`: 数据库名称 +- `MINIO_ENDPOINT`: MinIO服务地址 +- `MINIO_ACCESS_KEY`: MinIO访问密钥 +- `MINIO_SECRET_KEY`: MinIO密钥 +- `MINIO_BUCKET`: MinIO存储桶名称 +- `MINIO_SECURE`: 是否使用HTTPS(true/false) + +**AI服务配置**: +- `AI_PROVIDER`: AI服务提供商('huawei' 或 'siliconflow') +- `HUAWEI_API_ENDPOINT`: 华为API地址 +- `HUAWEI_API_KEY`: 华为API密钥 +- `HUAWEI_MODEL`: 华为模型名称 +- `HUAWEI_API_TIMEOUT`: 超时时间(秒) +- `HUAWEI_API_MAX_TOKENS`: 最大token数 +- `SILICONFLOW_URL`: 硅基流动API地址 +- `SILICONFLOW_API_KEY`: 硅基流动API密钥 +- `SILICONFLOW_MODEL`: 硅基流动模型名称 +- `SILICONFLOW_API_TIMEOUT`: 超时时间(秒) +- `SILICONFLOW_API_MAX_TOKENS`: 最大token数 + +**可选配置**: +- `PORT`: 服务端口(默认7500) +- `DEBUG`: 调试模式(true/false,默认false) +- `TENANT_ID`: 租户ID(用于MinIO路径) + +### 6. 错误处理规范 + +- 所有可能失败的操作必须使用try-except捕获异常 +- 异常信息要详细,包含上下文信息 +- 数据库操作异常必须回滚事务 +- 文件操作异常必须清理临时文件 +- 对外暴露的错误信息要友好,不泄露内部实现细节 + +**示例**: +```python +try: + # 业务逻辑 + result = some_operation() + return success_response(data=result) +except ValueError as e: + return error_response(400, f"参数错误: {str(e)}") +except Exception as e: + # 记录详细错误日志 + print(f"[ERROR] 操作失败: {str(e)}") + import traceback + print(traceback.format_exc()) + return error_response(500, "服务器内部错误") +``` + +### 7. 日志规范 + +- 使用`print`输出日志(项目当前使用print,不是logging模块) +- 日志格式:`[级别] 消息内容` +- 日志级别: + - `[DEBUG]`: 调试信息(详细的执行过程) + - `[INFO]`: 一般信息(正常流程) + - `[WARN]`: 警告信息(不影响功能但需要注意) + - `[ERROR]`: 错误信息(功能失败) +- 关键操作必须记录日志:AI调用、文件生成、数据库操作 + +### 8. 代码质量要求 + +- **可读性**: 代码要清晰易懂,变量名要有意义 +- **可维护性**: 避免重复代码,提取公共方法 +- **可测试性**: 服务层方法应该是纯函数,便于单元测试 +- **健壮性**: 处理边界情况,避免崩溃 +- **性能**: 数据库查询要优化,避免N+1查询 + +### 9. 特殊注意事项 + +#### Word模板处理 +- 使用`python-docx`库处理Word文档 +- 占位符格式:`{{field_code}}`(双大括号) +- 表格访问必须使用索引方式,避免迭代器导致的IndexError +- 处理跨run的占位符时,需要合并runs并保持格式 +- 某些复杂表格结构可能导致访问失败,提供XML备用方案 + +#### AI字段提取 +- AI返回的JSON可能格式不正确,需要多种修复机制 +- 字段名可能不规范(如`_source`、`target_organisation`),需要规范化映射 +- 日期格式需要统一转换为中文格式 +- 缺失字段需要从已有信息推断(如从出生年月计算年龄) + +#### MinIO文件管理 +- 文件路径使用相对路径(以`/`开头) +- 上传文件时自动生成时间戳路径 +- 支持生成预签名下载URL(7天有效期) +- 临时文件使用后必须清理 + +## 开发工作流 + +1. **添加新功能**: + - 在服务层添加业务逻辑方法 + - 在路由层添加API端点 + - 更新Swagger文档注释 + - 测试功能是否正常 + +2. **修改现有功能**: + - 理解现有代码逻辑 + - 保持API接口兼容性(如需要修改,考虑版本控制) + - 更新相关文档 + +3. **调试问题**: + - 查看日志输出(使用[DEBUG]级别) + - 检查数据库数据是否正确 + - 验证环境变量配置 + - 测试AI服务是否可用 + +## 常见问题处理 + +1. **AI解析失败**: 检查输入文本质量,查看AI日志,尝试修复JSON格式 +2. **模板填充失败**: 检查占位符格式是否正确,查看表格结构是否异常 +3. **MinIO上传失败**: 检查网络连接,验证MinIO配置和权限 +4. **数据库连接失败**: 检查数据库配置和网络连接 + +## 代码生成指导 + +当AI需要生成代码时,请遵循以下原则: + +1. **保持一致性**: 遵循项目现有的代码风格和架构模式 +2. **错误处理**: 所有可能失败的操作都要有异常处理 +3. **日志记录**: 关键操作要记录日志 +4. **参数验证**: API接口要验证输入参数 +5. **资源清理**: 文件、数据库连接等资源要正确清理 +6. **中文注释**: 使用中文编写注释和文档字符串 +7. **类型提示**: 建议使用类型提示提高代码可读性 + +## 更新日志 + +- 2025-12-13: 创建初始版本 diff --git a/.env b/.env index eef54bc..edf2747 100644 --- a/.env +++ b/.env @@ -1,26 +1,68 @@ -# MinIO配置 -MINIO_ENDPOINT=10.100.31.21:9000 -MINIO_ACCESS_KEY=minio_PC8dcY -MINIO_SECRET_KEY=minio_7k7RNJ -MINIO_BUCKET=finyx -MINIO_SECURE=false # 重要:新服务器使用HTTP,必须是false - -# 其他配置 +# ========== AI服务提供商配置 ========== +# 选择使用的AI服务提供商 +# 可选值: 'huawei' 或 'siliconflow' +# 默认值: 'siliconflow' AI_PROVIDER=siliconflow + +# ========== 华为大模型API配置 ========== +# 当 AI_PROVIDER=huawei 时使用以下配置 + +# API端点地址 +HUAWEI_API_ENDPOINT=http://10.100.31.26:3001/v1/chat/completions + +# API密钥 +HUAWEI_API_KEY=sk-PoeiV3qwyTIRqcVc84E8E24cD2904872859a87922e0d9186 + +# 模型名称 +HUAWEI_MODEL=DeepSeek-R1-Distill-Llama-70B + +# API超时配置(秒) +# 开启思考模式时,响应时间会显著增加,需要更长的超时时间 +# 默认180秒(3分钟) +HUAWEI_API_TIMEOUT=180 + +# API最大token数配置 +# 开启思考模式时,模型可能生成更长的响应,需要更多的token +# 默认12000 +HUAWEI_API_MAX_TOKENS=12000 + +# ========== 硅基流动API配置 ========== +# 当 AI_PROVIDER=siliconflow 时使用以下配置 + +# API端点地址(默认值,通常不需要修改) +SILICONFLOW_URL=https://api.siliconflow.cn/v1/chat/completions + +# API密钥(必需) +SILICONFLOW_API_KEY=sk-pgujibohpenkomkwlufexmqzyckglgogdiubfplgqxkfqgfu + +# 模型名称(默认值,通常不需要修改) +SILICONFLOW_MODEL=Qwen/Qwen2.5-72B-Instruct + +# API超时配置(秒) +# 默认120秒 +SILICONFLOW_API_TIMEOUT=120 + +# API最大token数配置 +# 默认2000 +SILICONFLOW_API_MAX_TOKENS=2000 + +# ========== 数据库配置 ========== DB_HOST=152.136.177.240 -DB_NAME=finyx -DB_PASSWORD=6QsGK6MpePZDE57Z DB_PORT=5012 DB_USER=finyx -DEBUG=False -HUAWEI_API_ENDPOINT=http://10.100.31.26:3001/v1/chat/completions -HUAWEI_API_KEY=sk-PoeiV3qwyTIRqcVc84E8E24cD2904872859a87922e0d9186 -HUAWEI_API_MAX_TOKENS=12000 -HUAWEI_API_TIMEOUT=180 -HUAWEI_MODEL=DeepSeek-R1-Distill-Llama-70B +DB_PASSWORD=6QsGK6MpePZDE57Z +DB_NAME=finyx + +# ========== MinIO配置(可选,文档生成功能需要) ========== +MINIO_ENDPOINT=minio.datacubeworld.com:9000 +MINIO_ACCESS_KEY=JOLXFXny3avFSzB0uRA5 +MINIO_SECRET_KEY=G1BR8jStNfovkfH5ou39EmPl34E4l7dGrnd3Cz0I +MINIO_BUCKET=finyx +MINIO_SECURE=true + +# ========== 服务配置 ========== +# 服务端口 PORT=7500 -SILICONFLOW_API_KEY=sk-pgujibohpenkomkwlufexmqzyckglgogdiubfplgqxkfqgfu -SILICONFLOW_API_MAX_TOKENS=2000 -SILICONFLOW_API_TIMEOUT=120 -SILICONFLOW_MODEL=Qwen/Qwen2.5-72B-Instruct -SILICONFLOW_URL=https://api.siliconflow.cn/v1/chat/completions + +# 调试模式(true/false) +DEBUG=False diff --git a/app.py b/app.py index d039e85..575add9 100644 --- a/app.py +++ b/app.py @@ -6,8 +6,12 @@ from flask_cors import CORS from flasgger import Swagger import os import pymysql +import json +import tempfile +import zipfile from datetime import datetime from dotenv import load_dotenv +from flask import send_file from services.ai_service import AIService from services.field_service import FieldService @@ -839,54 +843,104 @@ def template_field_manager(): return send_from_directory('static', 'template_field_manager.html') -@app.route('/api/template-field-relations', methods=['GET']) -def get_template_field_relations(): +def generate_id(): + """生成ID(使用时间戳+随机数的方式,模拟雪花算法)""" + import time + import random + timestamp = int(time.time() * 1000) + random_part = random.randint(100000, 999999) + return timestamp * 1000 + random_part + + +@app.route('/api/tenant-ids', methods=['GET']) +def get_tenant_ids(): """ - 获取所有模板和字段的关联关系 - 用于模板字段关联管理页面 + 获取数据库中所有已存在的 tenant_id + 用于模板字段关联管理页面选择租户 """ try: conn = document_service.get_connection() cursor = conn.cursor(pymysql.cursors.DictCursor) try: - # 获取所有启用的模板 + # 从 f_polic_file_config 表中获取所有不同的 tenant_id + cursor.execute(""" + SELECT DISTINCT tenant_id + FROM f_polic_file_config + WHERE tenant_id IS NOT NULL + ORDER BY tenant_id + """) + tenant_ids = [row['tenant_id'] for row in cursor.fetchall()] + + return success_response({'tenant_ids': tenant_ids}) + + finally: + cursor.close() + conn.close() + + except Exception as e: + return error_response(500, f"获取租户ID列表失败: {str(e)}") + + +@app.route('/api/template-field-relations', methods=['GET']) +def get_template_field_relations(): + """ + 获取指定 tenant_id 下的所有模板和字段的关联关系 + 用于模板字段关联管理页面 + 查询参数: tenant_id (必填) + """ + try: + tenant_id = request.args.get('tenant_id') + + if not tenant_id: + return error_response(400, "tenant_id参数不能为空") + + try: + tenant_id = int(tenant_id) + except ValueError: + return error_response(400, "tenant_id必须是数字") + + conn = document_service.get_connection() + cursor = conn.cursor(pymysql.cursors.DictCursor) + + try: + # 获取指定 tenant_id 下所有启用的模板 cursor.execute(""" SELECT id, name, template_code FROM f_polic_file_config - WHERE state = 1 + WHERE tenant_id = %s AND state = 1 ORDER BY name - """) + """, (tenant_id,)) templates = cursor.fetchall() - # 获取所有启用的输入字段 + # 获取指定 tenant_id 下所有启用的输入字段 cursor.execute(""" SELECT id, name, filed_code, field_type FROM f_polic_field - WHERE field_type = 1 AND state = 1 + WHERE tenant_id = %s AND field_type = 1 AND state = 1 ORDER BY name - """) + """, (tenant_id,)) input_fields = cursor.fetchall() - # 获取所有启用的输出字段 + # 获取指定 tenant_id 下所有启用的输出字段 cursor.execute(""" SELECT id, name, filed_code, field_type FROM f_polic_field - WHERE field_type = 2 AND state = 1 + WHERE tenant_id = %s AND field_type = 2 AND state = 1 ORDER BY name - """) + """, (tenant_id,)) output_fields = cursor.fetchall() - # 获取现有的关联关系 + # 获取指定 tenant_id 下现有的关联关系 cursor.execute(""" - SELECT file_id, filed_id - FROM f_polic_file_field - WHERE state = 1 - """) + SELECT fff.file_id, fff.filed_id + FROM f_polic_file_field fff + INNER JOIN f_polic_file_config fc ON fff.file_id = fc.id + WHERE fff.tenant_id = %s AND fff.state = 1 + """, (tenant_id,)) relations = cursor.fetchall() # 构建关联关系映射 (file_id -> list of filed_id) - # 注意:JSON不支持set,所以转换为list relation_map = {} for rel in relations: file_id = rel['file_id'] @@ -896,6 +950,7 @@ def get_template_field_relations(): relation_map[file_id].append(filed_id) return success_response({ + 'tenant_id': tenant_id, 'templates': templates, 'input_fields': input_fields, 'output_fields': output_fields, @@ -915,6 +970,7 @@ def save_template_field_relations(): """ 保存模板和字段的关联关系 请求体格式: { + "tenant_id": 123, "template_id": 123, "input_field_ids": [1, 2, 3], "output_field_ids": [4, 5, 6] @@ -926,87 +982,74 @@ def save_template_field_relations(): if not data: return error_response(400, "请求参数不能为空") + tenant_id = data.get('tenant_id') template_id = data.get('template_id') input_field_ids = data.get('input_field_ids', []) output_field_ids = data.get('output_field_ids', []) + if not tenant_id: + return error_response(400, "tenant_id参数不能为空") if not template_id: return error_response(400, "template_id参数不能为空") + try: + tenant_id = int(tenant_id) + except ValueError: + return error_response(400, "tenant_id必须是数字") + conn = document_service.get_connection() cursor = conn.cursor() try: - # 验证模板是否存在 + # 验证模板是否存在且属于该 tenant_id cursor.execute(""" SELECT id FROM f_polic_file_config - WHERE id = %s AND state = 1 - """, (template_id,)) + WHERE id = %s AND tenant_id = %s AND state = 1 + """, (template_id, tenant_id)) if not cursor.fetchone(): - return error_response(400, f"模板ID {template_id} 不存在或未启用") + return error_response(400, f"模板ID {template_id} 不存在或不属于该租户") # 合并所有字段ID all_field_ids = set(input_field_ids + output_field_ids) - # 验证字段是否存在 + # 验证字段是否存在且属于该 tenant_id if all_field_ids: placeholders = ','.join(['%s'] * len(all_field_ids)) cursor.execute(f""" SELECT id FROM f_polic_field - WHERE id IN ({placeholders}) AND state = 1 - """, list(all_field_ids)) + WHERE id IN ({placeholders}) AND tenant_id = %s AND state = 1 + """, list(all_field_ids) + [tenant_id]) existing_field_ids = {row[0] for row in cursor.fetchall()} invalid_field_ids = all_field_ids - existing_field_ids if invalid_field_ids: - return error_response(400, f"字段ID {list(invalid_field_ids)} 不存在或未启用") + return error_response(400, f"字段ID {list(invalid_field_ids)} 不存在或不属于该租户") - # 删除该模板的所有现有关联关系 + # 删除该模板的所有现有关联关系(仅限该 tenant_id) cursor.execute(""" DELETE FROM f_polic_file_field - WHERE file_id = %s - """, (template_id,)) + WHERE file_id = %s AND tenant_id = %s + """, (template_id, tenant_id)) # 插入新的关联关系 current_time = datetime.now() created_by = 655162080928945152 # 默认创建者ID - # 从环境变量读取tenant_id(如果数据库表需要),如果不需要可以设置为NULL - tenant_id = os.getenv('TENANT_ID') - if all_field_ids: - # 如果tenant_id是必填字段,从环境变量读取;如果可以为NULL,则使用NULL - if tenant_id: - insert_sql = """ - INSERT INTO f_polic_file_field - (tenant_id, file_id, filed_id, created_time, created_by, updated_time, updated_by, state) - VALUES (%s, %s, %s, %s, %s, %s, %s, 1) - """ - for field_id in all_field_ids: - cursor.execute(insert_sql, ( - tenant_id, - template_id, - field_id, - current_time, - created_by, - current_time, - created_by - )) - else: - # 如果tenant_id可以为NULL,使用NULL - insert_sql = """ - INSERT INTO f_polic_file_field - (file_id, filed_id, created_time, created_by, updated_time, updated_by, state) - VALUES (%s, %s, %s, %s, %s, %s, 1) - """ - for field_id in all_field_ids: - cursor.execute(insert_sql, ( - template_id, - field_id, - current_time, - created_by, - current_time, - created_by - )) + insert_sql = """ + INSERT INTO f_polic_file_field + (tenant_id, file_id, filed_id, created_time, created_by, updated_time, updated_by, state) + VALUES (%s, %s, %s, %s, %s, %s, %s, 1) + """ + for field_id in all_field_ids: + cursor.execute(insert_sql, ( + tenant_id, + template_id, + field_id, + current_time, + created_by, + current_time, + created_by + )) conn.commit() @@ -1028,6 +1071,505 @@ def save_template_field_relations(): return error_response(500, f"保存关联关系失败: {str(e)}") +# 字段管理 API +@app.route('/api/fields', methods=['GET']) +def get_fields(): + """ + 获取字段列表 + 查询参数: tenant_id (必填), field_type (可选: 1=输入字段, 2=输出字段) + """ + try: + tenant_id = request.args.get('tenant_id') + field_type = request.args.get('field_type') + + if not tenant_id: + return error_response(400, "tenant_id参数不能为空") + + try: + tenant_id = int(tenant_id) + except ValueError: + return error_response(400, "tenant_id必须是数字") + + conn = document_service.get_connection() + cursor = conn.cursor(pymysql.cursors.DictCursor) + + try: + if field_type: + try: + field_type = int(field_type) + except ValueError: + return error_response(400, "field_type必须是数字") + cursor.execute(""" + SELECT id, name, filed_code, field_type, state + FROM f_polic_field + WHERE tenant_id = %s AND field_type = %s + ORDER BY field_type, name + """, (tenant_id, field_type)) + else: + cursor.execute(""" + SELECT id, name, filed_code, field_type, state + FROM f_polic_field + WHERE tenant_id = %s + ORDER BY field_type, name + """, (tenant_id,)) + + fields = cursor.fetchall() + return success_response({'fields': fields}) + + finally: + cursor.close() + conn.close() + + except Exception as e: + return error_response(500, f"获取字段列表失败: {str(e)}") + + +@app.route('/api/fields', methods=['POST']) +def create_field(): + """ + 创建新字段 + 请求体格式: { + "tenant_id": 123, + "name": "字段名称", + "filed_code": "field_code", + "field_type": 1 // 1=输入字段, 2=输出字段 + } + """ + try: + data = request.get_json() + + if not data: + return error_response(400, "请求参数不能为空") + + tenant_id = data.get('tenant_id') + name = data.get('name') + filed_code = data.get('filed_code') + field_type = data.get('field_type') + + if not all([tenant_id, name, filed_code, field_type]): + return error_response(400, "tenant_id, name, filed_code, field_type 参数不能为空") + + try: + tenant_id = int(tenant_id) + field_type = int(field_type) + except ValueError: + return error_response(400, "tenant_id 和 field_type 必须是数字") + + if field_type not in [1, 2]: + return error_response(400, "field_type 必须是 1(输入字段)或 2(输出字段)") + + conn = document_service.get_connection() + cursor = conn.cursor() + + try: + # 检查字段编码是否已存在(同一 tenant_id 下) + cursor.execute(""" + SELECT id FROM f_polic_field + WHERE tenant_id = %s AND filed_code = %s + """, (tenant_id, filed_code)) + if cursor.fetchone(): + return error_response(400, f"字段编码 {filed_code} 已存在") + + # 创建新字段 + field_id = generate_id() + current_time = datetime.now() + created_by = 655162080928945152 + + cursor.execute(""" + INSERT INTO f_polic_field + (id, tenant_id, name, filed_code, field_type, created_time, created_by, updated_time, updated_by, state) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 1) + """, (field_id, tenant_id, name, filed_code, field_type, current_time, created_by, current_time, created_by)) + + conn.commit() + + return success_response({'field_id': field_id}, "字段创建成功") + + except Exception as e: + conn.rollback() + raise e + finally: + cursor.close() + conn.close() + + except Exception as e: + return error_response(500, f"创建字段失败: {str(e)}") + + +@app.route('/api/fields/', methods=['PUT']) +def update_field(field_id): + """ + 更新字段 + 请求体格式: { + "tenant_id": 123, + "name": "字段名称", + "filed_code": "field_code", + "field_type": 1, + "state": 1 // 0=未启用, 1=启用 + } + """ + try: + data = request.get_json() + + if not data: + return error_response(400, "请求参数不能为空") + + tenant_id = data.get('tenant_id') + name = data.get('name') + filed_code = data.get('filed_code') + field_type = data.get('field_type') + state = data.get('state') + + if not tenant_id: + return error_response(400, "tenant_id参数不能为空") + + try: + tenant_id = int(tenant_id) + except ValueError: + return error_response(400, "tenant_id必须是数字") + + conn = document_service.get_connection() + cursor = conn.cursor() + + try: + # 验证字段是否存在且属于该 tenant_id + cursor.execute(""" + SELECT id FROM f_polic_field + WHERE id = %s AND tenant_id = %s + """, (field_id, tenant_id)) + if not cursor.fetchone(): + return error_response(404, "字段不存在或不属于该租户") + + # 如果更新 filed_code,检查是否与其他字段冲突 + if filed_code: + cursor.execute(""" + SELECT id FROM f_polic_field + WHERE tenant_id = %s AND filed_code = %s AND id != %s + """, (tenant_id, filed_code, field_id)) + if cursor.fetchone(): + return error_response(400, f"字段编码 {filed_code} 已被其他字段使用") + + # 构建更新语句 + update_fields = [] + update_values = [] + + if name is not None: + update_fields.append("name = %s") + update_values.append(name) + if filed_code is not None: + update_fields.append("filed_code = %s") + update_values.append(filed_code) + if field_type is not None: + if field_type not in [1, 2]: + return error_response(400, "field_type 必须是 1(输入字段)或 2(输出字段)") + update_fields.append("field_type = %s") + update_values.append(field_type) + if state is not None: + update_fields.append("state = %s") + update_values.append(state) + + if not update_fields: + return error_response(400, "没有需要更新的字段") + + update_fields.append("updated_time = %s") + update_values.append(datetime.now()) + update_fields.append("updated_by = %s") + update_values.append(655162080928945152) + + update_values.append(field_id) + update_values.append(tenant_id) + + update_sql = f""" + UPDATE f_polic_field + SET {', '.join(update_fields)} + WHERE id = %s AND tenant_id = %s + """ + + cursor.execute(update_sql, update_values) + conn.commit() + + return success_response({'field_id': field_id}, "字段更新成功") + + except Exception as e: + conn.rollback() + raise e + finally: + cursor.close() + conn.close() + + except Exception as e: + return error_response(500, f"更新字段失败: {str(e)}") + + +@app.route('/api/fields/', methods=['DELETE']) +def delete_field(field_id): + """ + 删除字段(软删除,将 state 设置为 0) + 查询参数: tenant_id (必填) + """ + try: + tenant_id = request.args.get('tenant_id') + + if not tenant_id: + return error_response(400, "tenant_id参数不能为空") + + try: + tenant_id = int(tenant_id) + except ValueError: + return error_response(400, "tenant_id必须是数字") + + conn = document_service.get_connection() + cursor = conn.cursor() + + try: + # 验证字段是否存在且属于该 tenant_id + cursor.execute(""" + SELECT id FROM f_polic_field + WHERE id = %s AND tenant_id = %s + """, (field_id, tenant_id)) + if not cursor.fetchone(): + return error_response(404, "字段不存在或不属于该租户") + + # 检查字段是否被模板关联 + cursor.execute(""" + SELECT COUNT(*) as count + FROM f_polic_file_field + WHERE filed_id = %s AND tenant_id = %s AND state = 1 + """, (field_id, tenant_id)) + result = cursor.fetchone() + if result and result[0] > 0: + return error_response(400, f"字段正在被 {result[0]} 个模板使用,无法删除") + + # 软删除字段 + cursor.execute(""" + UPDATE f_polic_field + SET state = 0, updated_time = %s, updated_by = %s + WHERE id = %s AND tenant_id = %s + """, (datetime.now(), 655162080928945152, field_id, tenant_id)) + + conn.commit() + + return success_response({'field_id': field_id}, "字段删除成功") + + except Exception as e: + conn.rollback() + raise e + finally: + cursor.close() + conn.close() + + except Exception as e: + return error_response(500, f"删除字段失败: {str(e)}") + + +# 数据库备份和恢复 API +@app.route('/api/database/backup', methods=['POST']) +def backup_database(): + """ + 备份数据库相关表格 + 请求体格式: { + "tenant_id": 123 // 可选,如果提供则只备份该 tenant_id 的数据 + } + """ + try: + data = request.get_json() or {} + tenant_id = data.get('tenant_id') + + conn = document_service.get_connection() + cursor = conn.cursor(pymysql.cursors.DictCursor) + + try: + # 要备份的表 + tables = ['f_polic_file_config', 'f_polic_field', 'f_polic_file_field'] + backup_data = {} + + for table in tables: + if tenant_id: + # 根据表结构决定如何过滤 tenant_id + if table == 'f_polic_file_config': + cursor.execute(f"SELECT * FROM {table} WHERE tenant_id = %s", (tenant_id,)) + elif table == 'f_polic_field': + cursor.execute(f"SELECT * FROM {table} WHERE tenant_id = %s", (tenant_id,)) + elif table == 'f_polic_file_field': + cursor.execute(f"SELECT * FROM {table} WHERE tenant_id = %s", (tenant_id,)) + else: + cursor.execute(f"SELECT * FROM {table}") + + backup_data[table] = cursor.fetchall() + + # 创建临时文件保存备份数据 + temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') + json.dump({ + 'backup_time': datetime.now().isoformat(), + 'tenant_id': tenant_id, + 'tables': backup_data + }, temp_file, ensure_ascii=False, indent=2, default=str) + temp_file.close() + + return send_file( + temp_file.name, + as_attachment=True, + download_name=f'db_backup_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json', + mimetype='application/json' + ) + + finally: + cursor.close() + conn.close() + + except Exception as e: + return error_response(500, f"备份数据库失败: {str(e)}") + + +@app.route('/api/database/restore', methods=['POST']) +def restore_database(): + """ + 恢复数据库相关表格 + 通过文件上传: file (multipart/form-data) + 查询参数: tenant_id (必填,恢复数据到该 tenant_id) + """ + try: + # 从查询参数获取 tenant_id + tenant_id = request.args.get('tenant_id') + if not tenant_id: + # 尝试从表单数据获取 + tenant_id = request.form.get('tenant_id') + + if not tenant_id: + return error_response(400, "tenant_id参数不能为空(可通过查询参数或表单数据提供)") + + try: + tenant_id = int(tenant_id) + except (ValueError, TypeError): + return error_response(400, "tenant_id必须是数字") + + backup_data = None + + # 检查是否有文件上传 + if 'file' in request.files: + file = request.files['file'] + if file.filename: + try: + backup_data = json.load(file) + except json.JSONDecodeError as e: + return error_response(400, f"备份文件格式错误: {str(e)}") + else: + return error_response(400, "请上传备份文件") + + if not backup_data: + return error_response(400, "备份数据不能为空") + + conn = document_service.get_connection() + cursor = conn.cursor() + + try: + # 验证备份数据格式 + if 'tables' not in backup_data: + return error_response(400, "备份数据格式错误:缺少 tables 字段") + + tables_data = backup_data['tables'] + required_tables = ['f_polic_file_config', 'f_polic_field', 'f_polic_file_field'] + + for table in required_tables: + if table not in tables_data: + return error_response(400, f"备份数据格式错误:缺少表 {table} 的数据") + + # 开始恢复(注意:这里只恢复指定 tenant_id 的数据) + # 先删除该 tenant_id 的现有数据 + cursor.execute("DELETE FROM f_polic_file_field WHERE tenant_id = %s", (tenant_id,)) + cursor.execute("DELETE FROM f_polic_field WHERE tenant_id = %s", (tenant_id,)) + cursor.execute("DELETE FROM f_polic_file_config WHERE tenant_id = %s", (tenant_id,)) + + # 恢复 f_polic_file_config + if tables_data['f_polic_file_config']: + insert_sql = """ + INSERT INTO f_polic_file_config + (id, tenant_id, parent_id, name, template_code, input_data, file_path, + created_time, created_by, updated_time, updated_by, state) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + for row in tables_data['f_polic_file_config']: + # 确保 tenant_id 正确 + row['tenant_id'] = tenant_id + cursor.execute(insert_sql, ( + row.get('id'), + row.get('tenant_id'), + row.get('parent_id'), + row.get('name'), + row.get('template_code'), + row.get('input_data'), + row.get('file_path'), + row.get('created_time'), + row.get('created_by'), + row.get('updated_time'), + row.get('updated_by'), + row.get('state', 1) + )) + + # 恢复 f_polic_field + if tables_data['f_polic_field']: + insert_sql = """ + INSERT INTO f_polic_field + (id, tenant_id, name, filed_code, field_type, + created_time, created_by, updated_time, updated_by, state) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + for row in tables_data['f_polic_field']: + # 确保 tenant_id 正确 + row['tenant_id'] = tenant_id + cursor.execute(insert_sql, ( + row.get('id'), + row.get('tenant_id'), + row.get('name'), + row.get('filed_code'), + row.get('field_type'), + row.get('created_time'), + row.get('created_by'), + row.get('updated_time'), + row.get('updated_by'), + row.get('state', 1) + )) + + # 恢复 f_polic_file_field + if tables_data['f_polic_file_field']: + insert_sql = """ + INSERT INTO f_polic_file_field + (tenant_id, file_id, filed_id, + created_time, created_by, updated_time, updated_by, state) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """ + for row in tables_data['f_polic_file_field']: + # 确保 tenant_id 正确 + row['tenant_id'] = tenant_id + cursor.execute(insert_sql, ( + row.get('tenant_id'), + row.get('file_id'), + row.get('filed_id'), + row.get('created_time'), + row.get('created_by'), + row.get('updated_time'), + row.get('updated_by'), + row.get('state', 1) + )) + + conn.commit() + + return success_response({ + 'tenant_id': tenant_id, + 'restored_tables': required_tables + }, "数据库恢复成功") + + except Exception as e: + conn.rollback() + raise e + finally: + cursor.close() + conn.close() + + except Exception as e: + return error_response(500, f"恢复数据库失败: {str(e)}") + + if __name__ == '__main__': # 确保static目录存在 os.makedirs('static', exist_ok=True) diff --git a/requirements.txt b/requirements.txt index 1d93615..813031e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ flask==3.0.0 flask-cors==4.0.0 pymysql==1.1.2 +cryptography>=41.0.0 python-dotenv==1.0.0 requests==2.31.0 flasgger==0.9.7.1 diff --git a/static/template_field_manager.html b/static/template_field_manager.html index 7184248..2340636 100644 --- a/static/template_field_manager.html +++ b/static/template_field_manager.html @@ -18,7 +18,7 @@ } .container { - max-width: 1400px; + max-width: 1600px; margin: 0 auto; background: white; border-radius: 8px; @@ -38,116 +38,46 @@ font-size: 14px; } - .template-selector { + .section { margin-bottom: 30px; - } - - .template-selector label { - display: block; - margin-bottom: 10px; - font-weight: 500; - color: #333; - } - - .template-selector select { - width: 100%; - padding: 10px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; - background: white; - } - - .fields-container { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 30px; - margin-bottom: 30px; - } - - .field-section { + padding: 20px; border: 1px solid #e0e0e0; border-radius: 6px; - padding: 20px; background: #fafafa; } - .field-section h2 { + .section h2 { font-size: 18px; margin-bottom: 15px; color: #333; padding-bottom: 10px; - border-bottom: 2px solid #4CAF50; + border-bottom: 2px solid #2196F3; } - .field-section.output h2 { - border-bottom-color: #2196F3; + .form-group { + margin-bottom: 15px; } - .field-count { - font-size: 12px; - color: #999; - font-weight: normal; - margin-left: 10px; - } - - .field-list { - max-height: 500px; - overflow-y: auto; - padding: 10px 0; - } - - .field-item { - display: flex; - align-items: center; - padding: 10px; - margin-bottom: 8px; - background: white; - border: 1px solid #e0e0e0; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s; - } - - .field-item:hover { - background: #f0f0f0; - border-color: #4CAF50; - } - - .field-item.checked { - background: #e8f5e9; - border-color: #4CAF50; - } - - .field-item input[type="checkbox"] { - margin-right: 10px; - width: 18px; - height: 18px; - cursor: pointer; - } - - .field-info { - flex: 1; - } - - .field-name { + .form-group label { + display: block; + margin-bottom: 5px; font-weight: 500; color: #333; - margin-bottom: 4px; } - .field-code { - font-size: 12px; - color: #999; - font-family: 'Courier New', monospace; + .form-group input, + .form-group select { + width: 100%; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; } - .actions { - display: flex; - justify-content: flex-end; - gap: 10px; - padding-top: 20px; - border-top: 1px solid #e0e0e0; + .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; } .btn { @@ -168,11 +98,6 @@ background: #45a049; } - .btn-primary:disabled { - background: #ccc; - cursor: not-allowed; - } - .btn-secondary { background: #f5f5f5; color: #333; @@ -183,6 +108,35 @@ background: #e0e0e0; } + .btn-danger { + background: #f44336; + color: white; + } + + .btn-danger:hover { + background: #da190b; + } + + .btn-info { + background: #2196F3; + color: white; + } + + .btn-info:hover { + background: #0b7dda; + } + + .btn:disabled { + background: #ccc; + cursor: not-allowed; + } + + .btn-group { + display: flex; + gap: 10px; + flex-wrap: wrap; + } + .message { padding: 12px; border-radius: 4px; @@ -212,6 +166,87 @@ color: #999; } + .fields-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 30px; + margin-bottom: 30px; + } + + .field-section { + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 20px; + background: #fafafa; + } + + .field-section h3 { + font-size: 16px; + margin-bottom: 15px; + color: #333; + padding-bottom: 10px; + border-bottom: 2px solid #4CAF50; + } + + .field-section.output h3 { + border-bottom-color: #2196F3; + } + + .field-list { + max-height: 500px; + overflow-y: auto; + padding: 10px 0; + } + + .field-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px; + margin-bottom: 8px; + background: white; + border: 1px solid #e0e0e0; + border-radius: 4px; + transition: all 0.2s; + } + + .field-item:hover { + background: #f0f0f0; + border-color: #4CAF50; + } + + .field-item.checked { + background: #e8f5e9; + border-color: #4CAF50; + } + + .field-info { + flex: 1; + cursor: pointer; + } + + .field-name { + font-weight: 500; + color: #333; + margin-bottom: 4px; + } + + .field-code { + font-size: 12px; + color: #999; + font-family: 'Courier New', monospace; + } + + .field-actions { + display: flex; + gap: 5px; + } + + .field-actions button { + padding: 5px 10px; + font-size: 12px; + } + .search-box { margin-bottom: 15px; } @@ -255,70 +290,279 @@ color: #666; margin-top: 5px; } + + .modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + } + + .modal-content { + background-color: white; + margin: 5% auto; + padding: 30px; + border-radius: 8px; + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow-y: auto; + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid #e0e0e0; + } + + .modal-header h2 { + margin: 0; + font-size: 20px; + } + + .close { + color: #aaa; + font-size: 28px; + font-weight: bold; + cursor: pointer; + } + + .close:hover { + color: #000; + } + + .tabs { + display: flex; + border-bottom: 2px solid #e0e0e0; + margin-bottom: 20px; + } + + .tab { + padding: 10px 20px; + cursor: pointer; + border: none; + background: none; + font-size: 14px; + color: #666; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + } + + .tab.active { + color: #2196F3; + border-bottom-color: #2196F3; + } + + .tab-content { + display: none; + } + + .tab-content.active { + display: block; + } + + table { + width: 100%; + border-collapse: collapse; + margin-top: 15px; + } + + table th, + table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #e0e0e0; + } + + table th { + background: #f5f5f5; + font-weight: 500; + } + + table tr:hover { + background: #f9f9f9; + }

模板字段关联管理

-

维护模板与输入字段、输出字段的关联关系

+

维护模板与输入字段、输出字段的关联关系,支持字段的增删改查和数据库备份恢复

-
- - -
- - - -