提交 ae32844b authored 作者: 陈泽健's avatar 陈泽健

feat(device-sim): Excel批量导入设备功能

- 新增 POST /devices/import 端点,支持xlsx文件上传
- 新增 GET /devices/import/template 模板下载端点
- Excel表头:环境配置名称、设备名称、设备ID、授权码(可选)、自动重连、启用定时上报、上报间隔
- 前端DeviceList.vue新增批量导入按钮和导入弹窗
- 导入结果逐行显示成功/失败及错误原因
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 3ef25056
...@@ -14,7 +14,7 @@ import json ...@@ -14,7 +14,7 @@ import json
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
...@@ -25,6 +25,7 @@ from app.schemas.device_sim import ( ...@@ -25,6 +25,7 @@ from app.schemas.device_sim import (
ManualReportRequest, TestConnectionResponse, ManualReportRequest, TestConnectionResponse,
TopicTemplateResponse, TopicTemplateListResponse, TopicTemplateResponse, TopicTemplateListResponse,
BatchOperationRequest, BatchOperationResponse, BatchOperationRequest, BatchOperationResponse,
DeviceImportResponse,
) )
from app.services.device_sim_service import DeviceSimService from app.services.device_sim_service import DeviceSimService
...@@ -252,6 +253,98 @@ async def create_simulator( ...@@ -252,6 +253,98 @@ async def create_simulator(
# 注意:批量操作路由必须放在 /devices/{device_id} 之前,避免 batch 被当作 device_id # 注意:批量操作路由必须放在 /devices/{device_id} 之前,避免 batch 被当作 device_id
@router.get("/devices/import/template")
async def download_import_template(
device_type: str = Query(..., pattern="^(door|paperless|central|client)$", description="设备类型"),
):
"""
下载设备导入模板
根据设备类型生成对应的 Excel 模板文件。
"""
from openpyxl import Workbook
from fastapi.responses import StreamingResponse
from io import BytesIO
wb = Workbook()
ws = wb.active
ws.title = "设备导入"
# 表头
headers = ["环境配置名称", "设备名称", "设备ID"]
if device_type == "door":
headers.append("授权码(app_token)")
headers.extend(["自动重连", "启用定时上报", "上报间隔(秒)"])
ws.append(headers)
# 示例行
example = ["测试环境", f"{device_type}_001", f"{device_type}_device_001"]
if device_type == "door":
example.append("AUTH-0001")
example.extend(["是", "是", "30"])
ws.append(example)
# 列宽
for idx, header in enumerate(headers, 1):
col_letter = chr(64 + idx) if idx <= 26 else None
if col_letter:
ws.column_dimensions[col_letter].width = max(len(header) * 2, 15)
# 保存到缓冲区
buffer = BytesIO()
wb.save(buffer)
buffer.seek(0)
device_type_label = {"door": "门口屏", "paperless": "无纸化", "central": "中控", "client": "集控客户端"}
filename = f"{device_type_label.get(device_type, device_type)}_设备导入模板.xlsx"
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"},
)
@router.post("/devices/import", response_model=DeviceImportResponse)
async def import_devices(
file: UploadFile = File(..., description="Excel 文件 (.xlsx)"),
device_type: str = Query(..., pattern="^(door|paperless|central|client)$", description="设备类型"),
service: DeviceSimService = Depends(get_device_sim_service),
):
"""
批量导入模拟设备
上传 Excel 文件批量创建设备。Excel 列:环境配置名称、设备名称、设备ID、
授权码(可选)、自动重连(可选)、启用定时上报(可选)、上报间隔(可选)。
device_type 由前端页面决定,不包含在 Excel 中。
"""
# 校验文件类型
if not file.filename or not file.filename.endswith('.xlsx'):
raise HTTPException(status_code=400, detail="仅支持 .xlsx 格式的 Excel 文件")
# 读取文件内容
try:
file_bytes = await file.read()
if len(file_bytes) == 0:
raise HTTPException(status_code=400, detail="文件为空")
if len(file_bytes) > 10 * 1024 * 1024: # 10MB 限制
raise HTTPException(status_code=400, detail="文件大小不能超过 10MB")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=f"读取文件失败: {str(e)}")
# 执行导入
try:
result = await service.import_simulators_from_excel(file_bytes, device_type)
return DeviceImportResponse(**result)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"批量导入设备失败: {e}")
raise HTTPException(status_code=500, detail=f"批量导入设备失败: {str(e)}")
@router.post("/devices/batch/start", response_model=BatchOperationResponse) @router.post("/devices/batch/start", response_model=BatchOperationResponse)
async def batch_start_simulators( async def batch_start_simulators(
data: BatchOperationRequest, data: BatchOperationRequest,
......
...@@ -300,3 +300,36 @@ class BatchOperationResponse(BaseModel): ...@@ -300,3 +300,36 @@ class BatchOperationResponse(BaseModel):
alias_generator=to_camel, alias_generator=to_camel,
populate_by_name=True, populate_by_name=True,
) )
# ==================== 批量导入 Schema ====================
class DeviceImportRowResult(BaseModel):
"""单行导入结果"""
row: int = Field(..., description="行号(从2开始,1是表头)")
device_name: str = Field(default="", description="设备名称")
device_id: str = Field(default="", description="设备ID")
success: bool = Field(..., description="是否成功")
error: str = Field(default="", description="错误信息")
created_id: Optional[str] = Field(default=None, description="创建的设备ID(成功时)")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
class DeviceImportResponse(BaseModel):
"""设备导入响应"""
total: int = Field(..., description="总行数")
success_count: int = Field(..., description="成功数量")
failed_count: int = Field(..., description="失败数量")
results: List[DeviceImportRowResult] = Field(default=[], description="逐行结果")
message: str = Field(..., description="汇总信息")
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
)
...@@ -456,6 +456,177 @@ class DeviceSimService: ...@@ -456,6 +456,177 @@ class DeviceSimService:
await self.db.flush() await self.db.flush()
return simulator return simulator
async def import_simulators_from_excel(
self,
file_bytes: bytes,
device_type: str,
) -> dict:
"""
从 Excel 文件批量导入模拟设备
Args:
file_bytes: Excel 文件字节内容
device_type: 设备类型(由前端页面决定)
Returns:
dict: 导入结果,符合 DeviceImportResponse 结构
"""
from io import BytesIO
from openpyxl import load_workbook
# 表头 → 字段名映射
COLUMN_MAP = {
"环境配置名称": "env_config_name",
"设备名称": "device_name",
"设备id": "device_id",
"授权码": "app_token",
"授权码(app_token)": "app_token",
"自动重连": "auto_reconnect",
"启用定时上报": "report_enabled",
"上报间隔(秒)": "report_interval",
"上报间隔": "report_interval",
}
# 解析 Excel
wb = load_workbook(BytesIO(file_bytes), read_only=True)
ws = wb.active
# 读取表头,构建列索引 → 字段名映射
header_row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True))
col_map = {} # col_index -> field_name
for idx, header in enumerate(header_row):
if header is None:
continue
header_str = str(header).strip()
field = COLUMN_MAP.get(header_str)
if not field:
field = COLUMN_MAP.get(header_str.lower())
if field:
col_map[idx] = field
# 检查必填列是否存在
required_fields = {"env_config_name", "device_name", "device_id"}
found_fields = set(col_map.values())
missing = required_fields - found_fields
if missing:
wb.close()
raise ValueError(f"Excel 缺少必要列: {', '.join(missing)}")
# 预加载环境配置,用于名称 → ID 查找
env_configs = {}
env_query = select(EnvConfig)
env_result = await self.db.execute(env_query)
for env in env_result.scalars().all():
env_configs[env.name] = env.id
# 逐行处理
results = []
success_count = 0
failed_count = 0
for row_idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
# 提取本行数据
row_data = {}
for col_idx, field_name in col_map.items():
if col_idx < len(row):
row_data[field_name] = row[col_idx]
# 构建结果对象
result = {
"row": row_idx,
"device_name": str(row_data.get("device_name", "") or ""),
"device_id": str(row_data.get("device_id", "") or ""),
"success": False,
"error": "",
"created_id": None,
}
# 跳过空行
if not row_data.get("device_name") and not row_data.get("device_id"):
continue
# 校验必填字段
if not row_data.get("env_config_name"):
result["error"] = "环境配置名称为空"
results.append(result)
failed_count += 1
continue
if not row_data.get("device_name"):
result["error"] = "设备名称为空"
results.append(result)
failed_count += 1
continue
if not row_data.get("device_id"):
result["error"] = "设备ID为空"
results.append(result)
failed_count += 1
continue
# 环境配置名称 → ID
env_name = str(row_data["env_config_name"]).strip()
env_config_id = env_configs.get(env_name)
if not env_config_id:
result["error"] = f"环境配置不存在: {env_name}"
results.append(result)
failed_count += 1
continue
# 解析可选字段
auto_reconnect = _parse_bool(row_data.get("auto_reconnect"), True)
report_enabled = _parse_bool(row_data.get("report_enabled"), True)
report_interval = _parse_int(row_data.get("report_interval"), 30)
# 构建 topic_params(授权码)
topic_params = {}
app_token = row_data.get("app_token")
if app_token:
topic_params["app_token"] = str(app_token).strip()
# 构建创建数据
try:
create_data = SimulatorCreate(
env_config_id=env_config_id,
device_type=device_type,
device_name=str(row_data["device_name"]).strip(),
device_id=str(row_data["device_id"]).strip(),
auto_reconnect=auto_reconnect,
topic_params=topic_params if topic_params else None,
report_config={
"enabled": report_enabled,
"interval": report_interval,
},
)
except Exception as e:
result["error"] = f"数据验证失败: {str(e)}"
results.append(result)
failed_count += 1
continue
# 调用现有 create_simulator 创建设备
try:
device = await self.create_simulator(create_data)
result["success"] = True
result["created_id"] = device.id
success_count += 1
except ValueError as e:
result["error"] = str(e)
failed_count += 1
except Exception as e:
result["error"] = f"创建失败: {str(e)}"
failed_count += 1
results.append(result)
wb.close()
return {
"total": success_count + failed_count,
"success_count": success_count,
"failed_count": failed_count,
"results": results,
"message": f"导入完成: 成功 {success_count}, 失败 {failed_count}",
}
async def update_simulator(self, device_id: str, data: SimulatorUpdate) -> Optional[DeviceSimulator]: async def update_simulator(self, device_id: str, data: SimulatorUpdate) -> Optional[DeviceSimulator]:
""" """
更新模拟设备 更新模拟设备
...@@ -960,3 +1131,29 @@ def get_queue_stats() -> dict: ...@@ -960,3 +1131,29 @@ def get_queue_stats() -> dict:
"processed": _queue_stats["processed"], "processed": _queue_stats["processed"],
"dropped": _queue_stats["dropped"], "dropped": _queue_stats["dropped"],
} }
# ==================== Excel 导入辅助函数 ====================
def _parse_bool(value, default: bool = True) -> bool:
"""Parse boolean from Excel cell value (supports Chinese 是/否)"""
if value is None:
return default
if isinstance(value, bool):
return value
s = str(value).strip().lower()
if s in ("是", "true", "1", "yes", "y"):
return True
if s in ("否", "false", "0", "no", "n"):
return False
return default
def _parse_int(value, default: int = 0) -> int:
"""Parse integer from Excel cell value"""
if value is None:
return default
try:
return int(value)
except (ValueError, TypeError):
return default
\ No newline at end of file
...@@ -10,3 +10,4 @@ playwright==1.40.0 ...@@ -10,3 +10,4 @@ playwright==1.40.0
websockets==12.0 websockets==12.0
requests==2.31.0 requests==2.31.0
paho-mqtt==2.1.0 paho-mqtt==2.1.0
openpyxl==3.1.2
\ No newline at end of file
...@@ -19,6 +19,7 @@ import type { ...@@ -19,6 +19,7 @@ import type {
ManualReportRequest, ManualReportRequest,
TestConnectionResponse, TestConnectionResponse,
TopicTemplateListResponse, TopicTemplateListResponse,
DeviceImportResponse,
} from '@/types/device' } from '@/types/device'
const BASE = '/api/device-sim' const BASE = '/api/device-sim'
...@@ -195,3 +196,24 @@ export function createMessageStream( ...@@ -195,3 +196,24 @@ export function createMessageStream(
return ws return ws
} }
// ==================== 批量导入 API ====================
/** 批量导入设备(Excel) */
export function importDevices(file: File, deviceType: string): Promise<DeviceImportResponse> {
const formData = new FormData()
formData.append('file', file)
return request.post(`${BASE}/devices/import`, formData, {
params: { device_type: deviceType },
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120000, // 2 minutes for large imports
})
}
/** 下载设备导入模板 */
export function downloadImportTemplate(deviceType: string): Promise<Blob> {
return request.get(`${BASE}/devices/import/template`, {
params: { device_type: deviceType },
responseType: 'blob',
})
}
\ No newline at end of file
...@@ -35,6 +35,9 @@ ...@@ -35,6 +35,9 @@
<el-button type="primary" @click="openCreateDialog"> <el-button type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>新增设备 <el-icon><Plus /></el-icon>新增设备
</el-button> </el-button>
<el-button @click="openImportDialog">
<el-icon><Upload /></el-icon>批量导入
</el-button>
</div> </div>
<!-- 设备列表 --> <!-- 设备列表 -->
...@@ -236,13 +239,84 @@ ...@@ -236,13 +239,84 @@
<el-button type="primary" :loading="editing" @click="handleEdit">保存</el-button> <el-button type="primary" :loading="editing" @click="handleEdit">保存</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 批量导入弹窗 -->
<el-dialog v-model="importDialogVisible" title="批量导入设备" width="700px" destroy-on-close>
<div class="import-content">
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
<template #title>
上传 Excel 文件批量创建{{ deviceTypeLabel }}设备。
<el-link type="primary" :underline="false" @click="handleDownloadTemplate" :loading="downloadingTemplate">
下载模板文件
</el-link>
</template>
</el-alert>
<el-upload
ref="importUploadRef"
:auto-upload="false"
accept=".xlsx"
:limit="1"
:on-change="handleImportFileChange"
:on-remove="() => { importFile = null; importFileList = [] }"
:file-list="importFileList"
drag
>
<el-icon style="font-size: 40px; color: #c0c4cc"><Upload /></el-icon>
<div style="margin-top: 8px">将 .xlsx 文件拖到此处,或 <em>点击上传</em></div>
</el-upload>
<!-- 导入结果 -->
<div v-if="importResult" style="margin-top: 16px">
<el-divider content-position="left">导入结果</el-divider>
<el-alert
:type="importResult.failedCount > 0 ? 'warning' : 'success'"
:closable="false"
style="margin-bottom: 12px"
>
<template #title>
{{ importResult.message }}
</template>
</el-alert>
<el-table v-if="importResult.results.length > 0" :data="importResult.results" max-height="300" size="small">
<el-table-column prop="row" label="行号" width="60" />
<el-table-column prop="deviceName" label="设备名称" min-width="120" />
<el-table-column prop="deviceId" label="设备ID" min-width="120" />
<el-table-column label="结果" width="80">
<template #default="{ row }">
<el-tag :type="row.success ? 'success' : 'danger'" size="small">
{{ row.success ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error" label="错误信息" min-width="200">
<template #default="{ row }">
<span v-if="row.error" style="color: #f56c6c">{{ row.error }}</span>
<span v-else>-</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
<template #footer>
<el-button @click="importDialogVisible = false">关闭</el-button>
<el-button
type="primary"
:loading="importing"
:disabled="!importFile"
@click="handleImport"
>
开始导入
</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue' import { Plus, Upload } from '@element-plus/icons-vue'
import { import {
listSimulators, listSimulators,
createSimulator, createSimulator,
...@@ -256,8 +330,10 @@ import { ...@@ -256,8 +330,10 @@ import {
batchStartDevices, batchStartDevices,
batchStopDevices, batchStopDevices,
batchDeleteDevices, batchDeleteDevices,
importDevices,
downloadImportTemplate,
} from '@/api/deviceSim' } from '@/api/deviceSim'
import type { Simulator, SimulatorCreate, EnvConfig, DeviceType, TopicTemplate } from '@/types/device' import type { Simulator, SimulatorCreate, EnvConfig, DeviceType, TopicTemplate, DeviceImportResponse } from '@/types/device'
import { DEVICE_TYPE_LABELS } from '@/types/device' import { DEVICE_TYPE_LABELS } from '@/types/device'
const props = defineProps<{ const props = defineProps<{
...@@ -355,6 +431,14 @@ const editForm = ref({ ...@@ -355,6 +431,14 @@ const editForm = ref({
topicParams: {} as Record<string, string>, topicParams: {} as Record<string, string>,
}) })
// 批量导入
const importDialogVisible = ref(false)
const importing = ref(false)
const importFile = ref<File | null>(null)
const importFileList = ref<any[]>([])
const importResult = ref<DeviceImportResponse | null>(null)
const downloadingTemplate = ref(false)
/** 加载设备列表 */ /** 加载设备列表 */
async function loadDevices() { async function loadDevices() {
loading.value = true loading.value = true
...@@ -640,6 +724,60 @@ async function handleBatchDelete() { ...@@ -640,6 +724,60 @@ async function handleBatchDelete() {
} }
} }
/** 打开导入弹窗 */
function openImportDialog() {
importFile.value = null
importFileList.value = []
importResult.value = null
importDialogVisible.value = true
}
/** 导入文件选择变化 */
function handleImportFileChange(file: any) {
importFile.value = file.raw
importFileList.value = [file]
}
/** 下载模板 */
async function handleDownloadTemplate() {
downloadingTemplate.value = true
try {
const blob = await downloadImportTemplate(props.deviceType)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${deviceTypeLabel.value}_设备导入模板.xlsx`
a.click()
window.URL.revokeObjectURL(url)
} catch (e: any) {
ElMessage.error('下载模板失败: ' + (e.message || ''))
} finally {
downloadingTemplate.value = false
}
}
/** 执行导入 */
async function handleImport() {
if (!importFile.value) {
ElMessage.warning('请先选择文件')
return
}
importing.value = true
try {
const result = await importDevices(importFile.value, props.deviceType)
importResult.value = result
if (result.successCount > 0) {
await loadDevices()
emit('device-changed')
}
} catch (e: any) {
ElMessage.error('导入失败: ' + (e.message || ''))
} finally {
importing.value = false
}
}
onMounted(() => { onMounted(() => {
loadEnvConfigs() loadEnvConfigs()
loadDevices() loadDevices()
......
...@@ -212,3 +212,22 @@ export interface TopicTemplateListResponse { ...@@ -212,3 +212,22 @@ export interface TopicTemplateListResponse {
templates: TopicTemplate[] templates: TopicTemplate[]
hasRealTopics: boolean hasRealTopics: boolean
} }
/** 设备导入行结果 */
export interface DeviceImportRowResult {
row: number
deviceName: string
deviceId: string
success: boolean
error: string
createdId: string | null
}
/** 设备导入响应 */
export interface DeviceImportResponse {
total: number
successCount: number
failedCount: number
results: DeviceImportRowResult[]
message: string
}
\ No newline at end of file
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论