Compare commits
4 Commits
bc9cc18142
...
7b5846b489
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b5846b489 | |||
| d0776f1036 | |||
| 22c7d352d6 | |||
| 95057dedf5 |
@ -47,9 +47,16 @@ def download_template():
|
|||||||
@jwt_required()
|
@jwt_required()
|
||||||
@permission_required('material_list:operation')
|
@permission_required('material_list:operation')
|
||||||
def preview_import():
|
def preview_import():
|
||||||
"""Dry-Run 预览 + 验证(不写入数据库)"""
|
"""Dry-Run 预览 + 验证(不写入数据库)
|
||||||
|
|
||||||
|
FormData:
|
||||||
|
type: 'material' | 'bom'
|
||||||
|
file: Excel 文件
|
||||||
|
mode: 'skip' (默认) | 'update' — update 模式下 DB 重复行显示为「将被更新」而非错误
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
import_type = request.form.get('type', 'material').strip()
|
import_type = request.form.get('type', 'material').strip()
|
||||||
|
mode = request.form.get('mode', 'skip').strip()
|
||||||
file = request.files.get('file')
|
file = request.files.get('file')
|
||||||
if not file:
|
if not file:
|
||||||
return jsonify({'code': 400, 'msg': '请上传文件'}), 400
|
return jsonify({'code': 400, 'msg': '请上传文件'}), 400
|
||||||
@ -60,11 +67,11 @@ def preview_import():
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
if import_type == 'material':
|
if import_type == 'material':
|
||||||
results = preview_material(BytesIO(file_stream))
|
results = preview_material(BytesIO(file_stream), mode=mode)
|
||||||
else:
|
else:
|
||||||
results = preview_bom(BytesIO(file_stream))
|
results = preview_bom(BytesIO(file_stream))
|
||||||
|
|
||||||
success_count = sum(1 for r in results if r['status'] == 'success')
|
success_count = sum(1 for r in results if r['status'] in ('success', 'update'))
|
||||||
error_count = sum(1 for r in results if r['status'] == 'error')
|
error_count = sum(1 for r in results if r['status'] == 'error')
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@ -85,24 +92,33 @@ def preview_import():
|
|||||||
@jwt_required()
|
@jwt_required()
|
||||||
@permission_required('material_list:operation')
|
@permission_required('material_list:operation')
|
||||||
def execute_import():
|
def execute_import():
|
||||||
"""确认导入执行(事务保护)"""
|
"""确认导入执行
|
||||||
|
|
||||||
|
Body:
|
||||||
|
type: 'material' | 'bom'
|
||||||
|
rows: 待导入行列表
|
||||||
|
mode: 'skip' (跳过重复,默认) | 'update' (覆盖更新已有记录)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
import_type = data.get('type', 'material').strip()
|
import_type = data.get('type', 'material').strip()
|
||||||
rows = data.get('rows', [])
|
rows = data.get('rows', [])
|
||||||
|
mode = data.get('mode', 'skip').strip()
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
return jsonify({'code': 400, 'msg': '导入数据不能为空'}), 400
|
return jsonify({'code': 400, 'msg': '导入数据不能为空'}), 400
|
||||||
if import_type not in ('material', 'bom'):
|
if import_type not in ('material', 'bom'):
|
||||||
return jsonify({'code': 400, 'msg': 'type 必须为 material 或 bom'}), 400
|
return jsonify({'code': 400, 'msg': 'type 必须为 material 或 bom'}), 400
|
||||||
|
if mode not in ('skip', 'update'):
|
||||||
|
return jsonify({'code': 400, 'msg': 'mode 必须为 skip 或 update'}), 400
|
||||||
|
|
||||||
# 仅导入状态为 success 的行
|
# 仅导入状态为 success 或 update 的行,error 行不参与导入
|
||||||
valid_rows = [r for r in rows if r.get('status') == 'success']
|
valid_rows = [r for r in rows if r.get('status') in ('success', 'update')]
|
||||||
if not valid_rows:
|
if not valid_rows:
|
||||||
return jsonify({'code': 400, 'msg': '没有可导入的有效数据(全部为error状态)'}), 400
|
return jsonify({'code': 400, 'msg': '没有可导入的有效数据'}), 400
|
||||||
|
|
||||||
if import_type == 'material':
|
if import_type == 'material':
|
||||||
result = execute_material_import(valid_rows)
|
result = execute_material_import(valid_rows, mode=mode)
|
||||||
else:
|
else:
|
||||||
result = execute_bom_import(valid_rows)
|
result = execute_bom_import(valid_rows)
|
||||||
|
|
||||||
|
|||||||
@ -116,8 +116,14 @@ def generate_template(import_type: str) -> io.BytesIO:
|
|||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
def preview_material(file_stream) -> list:
|
def preview_material(file_stream, mode: str = 'skip') -> list:
|
||||||
"""Dry-Run 预览基础信息导入"""
|
"""Dry-Run 预览基础信息导入(含 Excel 内部重复检测 + 空规格去重)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_stream: Excel 文件流
|
||||||
|
mode: 'skip' — DB 重复视为错误(默认)
|
||||||
|
'update' — DB 重复标记为 'update' 状态(将被覆盖更新,不视为错误)
|
||||||
|
"""
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
df = pd.read_excel(file_stream, header=0)
|
df = pd.read_excel(file_stream, header=0)
|
||||||
@ -138,15 +144,19 @@ def preview_material(file_stream) -> list:
|
|||||||
}
|
}
|
||||||
df.rename(columns=col_map, inplace=True)
|
df.rename(columns=col_map, inplace=True)
|
||||||
|
|
||||||
# 批量预查已存在的 name+spec 组合(用于重复检测)
|
# 批量预查已存在的 spec_model(作为唯一货号,用于重复检测)
|
||||||
existing = db.session.query(MaterialBase.name, MaterialBase.spec_model).all()
|
existing = db.session.query(MaterialBase.spec_model).all()
|
||||||
existing_set = {(r.name, r.spec_model or '') for r in existing}
|
existing_set = {r.spec_model for r in existing if r.spec_model}
|
||||||
|
|
||||||
|
# Excel 文件内部去重映射(spec → 首次出现行号)
|
||||||
|
excel_seen_map = {}
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for idx, row in df.iterrows():
|
for idx, row in df.iterrows():
|
||||||
row_num = idx + 2 # Excel 行号
|
row_num = idx + 2 # Excel 行号
|
||||||
errors = []
|
errors = []
|
||||||
name = str(row.get('name', '')).strip() if pd.notna(row.get('name')) else ''
|
name = str(row.get('name', '')).strip() if pd.notna(row.get('name')) else ''
|
||||||
|
# spec_model 作为唯一货号,空值严格拦截
|
||||||
spec = str(row.get('spec', '')).strip() if pd.notna(row.get('spec')) else ''
|
spec = str(row.get('spec', '')).strip() if pd.notna(row.get('spec')) else ''
|
||||||
category = str(row.get('category', '')).strip() if pd.notna(row.get('category')) else ''
|
category = str(row.get('category', '')).strip() if pd.notna(row.get('category')) else ''
|
||||||
mat_type = str(row.get('type', '')).strip() if pd.notna(row.get('type')) else ''
|
mat_type = str(row.get('type', '')).strip() if pd.notna(row.get('type')) else ''
|
||||||
@ -155,7 +165,7 @@ def preview_material(file_stream) -> list:
|
|||||||
if not name:
|
if not name:
|
||||||
errors.append("名称不能为空")
|
errors.append("名称不能为空")
|
||||||
if not spec:
|
if not spec:
|
||||||
errors.append("规格型号不能为空")
|
errors.append("缺少必填项: 规格型号 (作为唯一货号不能为空)")
|
||||||
if not category:
|
if not category:
|
||||||
errors.append("类别不能为空")
|
errors.append("类别不能为空")
|
||||||
if not mat_type:
|
if not mat_type:
|
||||||
@ -163,8 +173,40 @@ def preview_material(file_stream) -> list:
|
|||||||
if not unit:
|
if not unit:
|
||||||
errors.append("计量单位不能为空")
|
errors.append("计量单位不能为空")
|
||||||
|
|
||||||
if name and spec and (name, spec) in existing_set:
|
# ★ 新增:字段长度校验(预防 DB commit 时因字段超长回滚)
|
||||||
errors.append(f"已存在相同名称和规格的数据: {name} / {spec}")
|
MAX_LENS = {
|
||||||
|
'名称': (name, 255), '规格型号': (spec, 255),
|
||||||
|
'所属公司': (str(row.get('company_name', '')) if pd.notna(row.get('company_name')) else '', 255),
|
||||||
|
'专业名称': (str(row.get('common_name', '')) if pd.notna(row.get('common_name')) else '', 255),
|
||||||
|
'类别': (category, 100), '类型': (mat_type, 100),
|
||||||
|
'计量单位': (unit, 50),
|
||||||
|
}
|
||||||
|
for field_label, (val, max_len) in MAX_LENS.items():
|
||||||
|
if len(val) > max_len:
|
||||||
|
errors.append(f"{field_label}超出数据库限制({len(val)}>{max_len}字符)")
|
||||||
|
|
||||||
|
# ★ 去重检测(仅依据 spec_model 作为唯一货号)
|
||||||
|
if spec:
|
||||||
|
is_db_dup = spec in existing_set
|
||||||
|
|
||||||
|
if is_db_dup:
|
||||||
|
if mode == 'update':
|
||||||
|
# 覆盖更新模式:DB 重复不视为错误
|
||||||
|
if spec in excel_seen_map:
|
||||||
|
first_row = excel_seen_map[spec]
|
||||||
|
errors.append(f"与表格内第 {first_row} 行的规格型号重复: {spec}")
|
||||||
|
else:
|
||||||
|
excel_seen_map[spec] = row_num
|
||||||
|
else:
|
||||||
|
# 跳过模式:DB 重复是错误
|
||||||
|
errors.append(f"数据库已存在相同规格型号 (货号) 的数据: {spec}")
|
||||||
|
else:
|
||||||
|
# 不在 DB 中 → 检查是否与 Excel 内前面行重复
|
||||||
|
if spec in excel_seen_map:
|
||||||
|
first_row = excel_seen_map[spec]
|
||||||
|
errors.append(f"与表格内第 {first_row} 行的规格型号重复: {spec}")
|
||||||
|
else:
|
||||||
|
excel_seen_map[spec] = row_num
|
||||||
|
|
||||||
company = str(row.get('company_name', '')).strip() if pd.notna(row.get('company_name')) else ''
|
company = str(row.get('company_name', '')).strip() if pd.notna(row.get('company_name')) else ''
|
||||||
common = str(row.get('common_name', '')).strip() if pd.notna(row.get('common_name')) else ''
|
common = str(row.get('common_name', '')).strip() if pd.notna(row.get('common_name')) else ''
|
||||||
@ -173,10 +215,18 @@ def preview_material(file_stream) -> list:
|
|||||||
is_enabled = parse_boolean(row.get('is_enabled', True))
|
is_enabled = parse_boolean(row.get('is_enabled', True))
|
||||||
is_inspection = parse_boolean(row.get('is_inspection_required', False))
|
is_inspection = parse_boolean(row.get('is_inspection_required', False))
|
||||||
|
|
||||||
|
# 决定行状态:update 模式下 DB 重复但不含其他错误的行 → 'update'
|
||||||
|
if mode == 'update' and not errors and spec and spec in existing_set:
|
||||||
|
row_status = 'update'
|
||||||
|
elif errors:
|
||||||
|
row_status = 'error'
|
||||||
|
else:
|
||||||
|
row_status = 'success'
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
'row': row_num,
|
'row': row_num,
|
||||||
'status': 'error' if errors else 'success',
|
'status': row_status,
|
||||||
'error_msg': '; '.join(errors) if errors else '',
|
'error_msg': '; '.join(errors) if errors else ('将被覆盖更新' if row_status == 'update' else ''),
|
||||||
'data': {
|
'data': {
|
||||||
'company_name': company,
|
'company_name': company,
|
||||||
'name': name,
|
'name': name,
|
||||||
@ -221,14 +271,14 @@ def preview_bom(file_stream) -> list:
|
|||||||
}
|
}
|
||||||
df.rename(columns=col_map, inplace=True)
|
df.rename(columns=col_map, inplace=True)
|
||||||
|
|
||||||
# 批量预查所有 MaterialBase
|
# 批量预查所有 MaterialBase(仅依据 spec_model 查找)
|
||||||
all_materials = db.session.query(
|
all_materials = db.session.query(
|
||||||
MaterialBase.id, MaterialBase.name, MaterialBase.spec_model
|
MaterialBase.id, MaterialBase.name, MaterialBase.spec_model
|
||||||
).all()
|
).all()
|
||||||
mat_lookup = {}
|
mat_lookup = {}
|
||||||
for m in all_materials:
|
for m in all_materials:
|
||||||
key = (m.name, m.spec_model or '')
|
if m.spec_model:
|
||||||
mat_lookup[key] = m.id
|
mat_lookup[m.spec_model] = m.id
|
||||||
|
|
||||||
# 预查所有已存在的 (bom_no, version, parent_id, child_id) 组合
|
# 预查所有已存在的 (bom_no, version, parent_id, child_id) 组合
|
||||||
all_bom_pairs = db.session.query(
|
all_bom_pairs = db.session.query(
|
||||||
@ -259,15 +309,13 @@ def preview_bom(file_stream) -> list:
|
|||||||
if pd.isna(dosage) or float(dosage) <= 0:
|
if pd.isna(dosage) or float(dosage) <= 0:
|
||||||
errors.append("用量必须大于0")
|
errors.append("用量必须大于0")
|
||||||
|
|
||||||
parent_key = (parent_name, parent_spec)
|
parent_id = mat_lookup.get(parent_spec) if parent_spec else None
|
||||||
parent_id = mat_lookup.get(parent_key) if parent_name else None
|
|
||||||
if parent_name and parent_id is None:
|
if parent_name and parent_id is None:
|
||||||
errors.append(f"父件不存在: {parent_name} / {parent_spec}")
|
errors.append(f"父件不存在 (规格型号): {parent_spec}")
|
||||||
|
|
||||||
child_key = (child_name, child_spec)
|
child_id = mat_lookup.get(child_spec) if child_spec else None
|
||||||
child_id = mat_lookup.get(child_key) if child_name else None
|
|
||||||
if child_name and child_id is None:
|
if child_name and child_id is None:
|
||||||
errors.append(f"子件不存在: {child_name} / {child_spec}")
|
errors.append(f"子件不存在 (规格型号): {child_spec}")
|
||||||
|
|
||||||
if parent_id and child_id and parent_id == child_id:
|
if parent_id and child_id and parent_id == child_id:
|
||||||
errors.append("父件与子件不能是同一物料")
|
errors.append("父件与子件不能是同一物料")
|
||||||
@ -298,21 +346,75 @@ def preview_bom(file_stream) -> list:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def execute_material_import(rows: list) -> dict:
|
def execute_material_import(rows: list, mode: str = 'skip') -> dict:
|
||||||
"""执行基础信息批量导入(事务保护)"""
|
"""执行基础信息批量导入
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: 待导入行数据
|
||||||
|
mode: 'skip' - 跳过重复行,继续导入其余数据(默认)
|
||||||
|
'update' - 匹配到重复时覆盖更新已有记录
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
def _has_value(val) -> bool:
|
||||||
|
"""判断值是否有效(Excel 单元格非空、非 NaN)"""
|
||||||
|
if val is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if pd.isna(val):
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
s = str(val).strip()
|
||||||
|
return s != '' and s != 'nan' and s != 'None'
|
||||||
|
|
||||||
inserted = 0
|
inserted = 0
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for i, item in enumerate(rows):
|
for i, item in enumerate(rows):
|
||||||
d = item.get('data', item)
|
d = item.get('data', item)
|
||||||
|
spec = d.get('spec_model', '')
|
||||||
try:
|
try:
|
||||||
existing = MaterialBase.query.filter_by(
|
# ★ 仅依据 spec_model(唯一货号)查找已有记录
|
||||||
name=d['name'], spec_model=d['spec_model']
|
existing = MaterialBase.query.filter_by(spec_model=spec).first() if spec else None
|
||||||
).first()
|
|
||||||
if existing:
|
if existing:
|
||||||
errors.append({'row': i + 2, 'msg': f"已存在: {d['name']} / {d['spec_model']}"})
|
if mode == 'update':
|
||||||
continue
|
# ★ 覆盖更新模式:用 Excel 数据更新已有记录(含名称)
|
||||||
|
# 仅当 Excel 单元格有实际值时覆盖,空值不覆盖已有数据
|
||||||
|
if _has_value(d.get('name')):
|
||||||
|
existing.name = str(d['name']).strip()
|
||||||
|
if _has_value(d.get('company_name')):
|
||||||
|
existing.company_name = str(d['company_name']).strip()
|
||||||
|
if _has_value(d.get('common_name')):
|
||||||
|
existing.common_name = str(d['common_name']).strip()
|
||||||
|
if _has_value(d.get('category')):
|
||||||
|
existing.category = str(d['category']).strip()
|
||||||
|
if _has_value(d.get('material_type')):
|
||||||
|
existing.material_type = str(d['material_type']).strip()
|
||||||
|
if _has_value(d.get('spec_model')):
|
||||||
|
existing.spec_model = str(d['spec_model']).strip()
|
||||||
|
if _has_value(d.get('unit')):
|
||||||
|
existing.unit = str(d['unit']).strip()
|
||||||
|
if d.get('reference_price') is not None and _has_value(d.get('reference_price')):
|
||||||
|
try:
|
||||||
|
existing.reference_price = float(d['reference_price'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if _has_value(d.get('is_inspection_required')):
|
||||||
|
existing.is_inspection_required = parse_boolean(d.get('is_inspection_required'))
|
||||||
|
if _has_value(d.get('is_enabled')):
|
||||||
|
existing.is_enabled = parse_boolean(d.get('is_enabled'))
|
||||||
|
updated += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# ★ 跳过模式(默认):记录跳过,继续导入其余行
|
||||||
|
skipped += 1
|
||||||
|
errors.append({'row': i + 2, 'msg': f"重复数据已跳过 (货号): {spec}"})
|
||||||
|
continue
|
||||||
|
|
||||||
mat = MaterialBase(
|
mat = MaterialBase(
|
||||||
company_name=d.get('company_name', ''),
|
company_name=d.get('company_name', ''),
|
||||||
@ -331,18 +433,43 @@ def execute_material_import(rows: list) -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append({'row': i + 2, 'msg': str(e)})
|
errors.append({'row': i + 2, 'msg': str(e)})
|
||||||
|
|
||||||
if errors:
|
# ★ 改造:不再"一刀切"回滚,只提交成功的行
|
||||||
|
# 防御:捕获 DB 级异常(字段超长、约束冲突等),优雅返回而非 500
|
||||||
|
try:
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as commit_err:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return {'success': False, 'inserted': 0, 'errors': errors,
|
return {
|
||||||
'msg': f'导入失败,{len(errors)} 条数据校验未通过'}
|
'success': False,
|
||||||
|
'inserted': 0,
|
||||||
|
'updated': 0,
|
||||||
|
'skipped': 0,
|
||||||
|
'errors': [{'row': 0, 'msg': f'数据库写入失败: {str(commit_err)}'}],
|
||||||
|
'msg': f'导入失败,数据库写入异常(可能字段超长或约束冲突): {str(commit_err)}',
|
||||||
|
}
|
||||||
|
|
||||||
db.session.commit()
|
# 构建结果消息
|
||||||
return {'success': True, 'inserted': inserted, 'errors': [],
|
parts = []
|
||||||
'msg': f'成功导入 {inserted} 条基础信息'}
|
if inserted > 0:
|
||||||
|
parts.append(f"新增 {inserted} 条")
|
||||||
|
if updated > 0:
|
||||||
|
parts.append(f"更新 {updated} 条")
|
||||||
|
if skipped > 0:
|
||||||
|
parts.append(f"跳过 {skipped} 条重复")
|
||||||
|
msg = "成功: " + ",".join(parts) if parts else "没有数据被导入"
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'inserted': inserted,
|
||||||
|
'updated': updated,
|
||||||
|
'skipped': skipped,
|
||||||
|
'errors': errors,
|
||||||
|
'msg': msg,
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return {'success': False, 'inserted': 0,
|
return {'success': False, 'inserted': 0, 'updated': 0, 'skipped': 0,
|
||||||
'errors': [{'row': 0, 'msg': str(e)}], 'msg': f'导入异常: {str(e)}'}
|
'errors': [{'row': 0, 'msg': str(e)}], 'msg': f'导入异常: {str(e)}'}
|
||||||
|
|
||||||
|
|
||||||
@ -354,7 +481,8 @@ def execute_bom_import(rows: list) -> dict:
|
|||||||
).all()
|
).all()
|
||||||
mat_lookup = {}
|
mat_lookup = {}
|
||||||
for m in all_materials:
|
for m in all_materials:
|
||||||
mat_lookup[(m.name, m.spec_model or '')] = m.id
|
if m.spec_model:
|
||||||
|
mat_lookup[m.spec_model] = m.id
|
||||||
|
|
||||||
inserted = 0
|
inserted = 0
|
||||||
errors = []
|
errors = []
|
||||||
@ -369,11 +497,11 @@ def execute_bom_import(rows: list) -> dict:
|
|||||||
version = d.get('version', 'V1.0')
|
version = d.get('version', 'V1.0')
|
||||||
|
|
||||||
if not parent_id:
|
if not parent_id:
|
||||||
pk = (d.get('parent_name', ''), d.get('parent_spec', ''))
|
parent_spec = d.get('parent_spec', '')
|
||||||
parent_id = mat_lookup.get(pk)
|
parent_id = mat_lookup.get(parent_spec) if parent_spec else None
|
||||||
if not child_id:
|
if not child_id:
|
||||||
ck = (d.get('child_name', ''), d.get('child_spec', ''))
|
child_spec = d.get('child_spec', '')
|
||||||
child_id = mat_lookup.get(ck)
|
child_id = mat_lookup.get(child_spec) if child_spec else None
|
||||||
|
|
||||||
if not parent_id or not child_id:
|
if not parent_id or not child_id:
|
||||||
errors.append({'row': i + 2, 'msg': '父件或子件在系统中不存在'})
|
errors.append({'row': i + 2, 'msg': '父件或子件在系统中不存在'})
|
||||||
|
|||||||
@ -239,7 +239,7 @@ const handleLogout = () => {
|
|||||||
<footer v-if="!isLoginPage" class="app-footer">
|
<footer v-if="!isLoginPage" class="app-footer">
|
||||||
<span class="version-tag">
|
<span class="version-tag">
|
||||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||||
当前版本:V3.67
|
当前版本:V3.68
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,22 @@
|
|||||||
</el-upload>
|
</el-upload>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ 导入策略选择(仅基础信息) ═══ -->
|
||||||
|
<div v-if="importType === 'material'" class="import-strategy">
|
||||||
|
<el-divider />
|
||||||
|
<span class="step-label">3. 导入策略</span>
|
||||||
|
<el-radio-group v-model="importMode" class="strategy-group">
|
||||||
|
<el-radio value="skip">
|
||||||
|
<span class="strategy-label">跳过重复行</span>
|
||||||
|
<span class="strategy-desc">仅新增不重复的数据,已存在的直接跳过</span>
|
||||||
|
</el-radio>
|
||||||
|
<el-radio value="update">
|
||||||
|
<span class="strategy-label">覆盖更新</span>
|
||||||
|
<span class="strategy-desc">用 Excel 中的新数据覆盖更新数据库已有记录</span>
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="previewLoading" class="preview-loading">
|
<div v-if="previewLoading" class="preview-loading">
|
||||||
<el-icon class="is-loading" style="font-size:20px"><Loading /></el-icon>
|
<el-icon class="is-loading" style="font-size:20px"><Loading /></el-icon>
|
||||||
<span style="margin-left:8px">正在解析和验证数据...</span>
|
<span style="margin-left:8px">正在解析和验证数据...</span>
|
||||||
@ -60,11 +76,11 @@
|
|||||||
|
|
||||||
<el-table :data="previewRows" border stripe max-height="450" style="width:100%">
|
<el-table :data="previewRows" border stripe max-height="450" style="width:100%">
|
||||||
<el-table-column type="index" label="#" width="50" align="center" />
|
<el-table-column type="index" label="#" width="50" align="center" />
|
||||||
<el-table-column label="状态" width="90" align="center">
|
<el-table-column label="状态" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
|
<el-tag v-if="row.status === 'success'" type="success" size="small">通过</el-tag>
|
||||||
{{ row.status === 'success' ? '通过' : '失败' }}
|
<el-tag v-else-if="row.status === 'update'" type="warning" size="small">将被更新</el-tag>
|
||||||
</el-tag>
|
<el-tag v-else type="danger" size="small">失败</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="错误信息" min-width="200" show-overflow-tooltip>
|
<el-table-column label="错误信息" min-width="200" show-overflow-tooltip>
|
||||||
@ -91,12 +107,24 @@
|
|||||||
<!-- ═══ Footer ═══ -->
|
<!-- ═══ Footer ═══ -->
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="handleClose" :disabled="executing">取消</el-button>
|
<el-button @click="handleClose" :disabled="executing">取消</el-button>
|
||||||
|
|
||||||
|
<!-- Step 1: 预览按钮 -->
|
||||||
<el-button v-if="step === 1" type="primary" @click="handlePreview" :loading="previewLoading" :disabled="!fileReady">
|
<el-button v-if="step === 1" type="primary" @click="handlePreview" :loading="previewLoading" :disabled="!fileReady">
|
||||||
预览验证
|
预览验证
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="step === 2" type="primary" @click="handleExecute" :loading="executing" :disabled="errorCount > 0 || executing">
|
|
||||||
{{ executing ? '导入中...' : `确认导入 (${successCount} 条)` }}
|
<!-- Step 2: 全部通过/将被更新 → 直接导入 -->
|
||||||
|
<el-button v-if="step === 2 && errorCount === 0" type="primary" @click="handleExecute" :loading="executing">
|
||||||
|
{{ executing ? '导入中...' : `全部导入 (${importableCount} 条)` }}
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
|
<!-- Step 2: 部分通过 → 允许跳过错误行导入 -->
|
||||||
|
<el-button v-else-if="step === 2 && importableCount > 0" type="warning" @click="handleExecute" :loading="executing">
|
||||||
|
{{ executing ? '导入中...' : `忽略错误,仅导入正确的 ${importableCount} 条` }}
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<!-- Step 2: 全部失败 → 禁用 -->
|
||||||
|
<el-button v-else-if="step === 2" disabled>无可导入数据</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
@ -138,12 +166,21 @@ const fileList = ref<any[]>([])
|
|||||||
const fileReady = ref(false)
|
const fileReady = ref(false)
|
||||||
const rawFile = ref<File | null>(null)
|
const rawFile = ref<File | null>(null)
|
||||||
|
|
||||||
|
// ★ 新增:导入策略(仅 material 类型生效)
|
||||||
|
const importMode = ref<'skip' | 'update'>('skip')
|
||||||
|
|
||||||
const previewRows = ref<any[]>([])
|
const previewRows = ref<any[]>([])
|
||||||
const successCount = computed(() => previewRows.value.filter(r => r.status === 'success').length)
|
const successCount = computed(() => previewRows.value.filter(r => r.status === 'success').length)
|
||||||
|
const updateCount = computed(() => previewRows.value.filter(r => r.status === 'update').length)
|
||||||
const errorCount = computed(() => previewRows.value.filter(r => r.status === 'error').length)
|
const errorCount = computed(() => previewRows.value.filter(r => r.status === 'error').length)
|
||||||
const previewSummary = computed(() =>
|
const importableCount = computed(() => successCount.value + updateCount.value)
|
||||||
`共 ${previewRows.value.length} 条: ${successCount.value} 条通过, ${errorCount.value} 条失败`
|
const previewSummary = computed(() => {
|
||||||
)
|
const parts = [`共 ${previewRows.value.length} 条`]
|
||||||
|
if (successCount.value > 0) parts.push(`${successCount.value} 条通过`)
|
||||||
|
if (updateCount.value > 0) parts.push(`${updateCount.value} 条将被更新`)
|
||||||
|
if (errorCount.value > 0) parts.push(`${errorCount.value} 条失败`)
|
||||||
|
return parts.join(',')
|
||||||
|
})
|
||||||
|
|
||||||
// ═══ Preview Columns (depends on type) ═══
|
// ═══ Preview Columns (depends on type) ═══
|
||||||
const materialColumns = [
|
const materialColumns = [
|
||||||
@ -218,6 +255,7 @@ const handlePreview = async () => {
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', rawFile.value)
|
formData.append('file', rawFile.value)
|
||||||
formData.append('type', props.importType)
|
formData.append('type', props.importType)
|
||||||
|
formData.append('mode', importMode.value) // ★ 预览时传递导入策略
|
||||||
|
|
||||||
const res: any = await request({
|
const res: any = await request({
|
||||||
url: '/v1/import/preview',
|
url: '/v1/import/preview',
|
||||||
@ -240,7 +278,7 @@ const handlePreview = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleExecute = async () => {
|
const handleExecute = async () => {
|
||||||
if (errorCount.value > 0) return
|
if (importableCount.value === 0) return
|
||||||
executing.value = true
|
executing.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await request({
|
const res: any = await request({
|
||||||
@ -248,12 +286,19 @@ const handleExecute = async () => {
|
|||||||
method: 'post',
|
method: 'post',
|
||||||
data: {
|
data: {
|
||||||
type: props.importType,
|
type: props.importType,
|
||||||
rows: previewRows.value
|
rows: previewRows.value,
|
||||||
|
mode: importMode.value,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
ElMessage.success(res.msg || '导入成功')
|
const detail = res.data || {}
|
||||||
|
// ★ 构建详细结果提示
|
||||||
|
const parts: string[] = []
|
||||||
|
if (detail.inserted > 0) parts.push(`新增 ${detail.inserted} 条`)
|
||||||
|
if (detail.updated > 0) parts.push(`更新 ${detail.updated} 条`)
|
||||||
|
if (detail.skipped > 0) parts.push(`跳过 ${detail.skipped} 条重复`)
|
||||||
|
ElMessage.success(parts.length > 0 ? parts.join(',') : (res.msg || '导入完成'))
|
||||||
emit('success')
|
emit('success')
|
||||||
handleClose()
|
handleClose()
|
||||||
} else {
|
} else {
|
||||||
@ -272,6 +317,7 @@ const handleClose = () => {
|
|||||||
rawFile.value = null
|
rawFile.value = null
|
||||||
fileReady.value = false
|
fileReady.value = false
|
||||||
previewRows.value = []
|
previewRows.value = []
|
||||||
|
importMode.value = 'skip'
|
||||||
visible.value = false
|
visible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -283,6 +329,7 @@ watch(visible, (val) => {
|
|||||||
rawFile.value = null
|
rawFile.value = null
|
||||||
fileReady.value = false
|
fileReady.value = false
|
||||||
previewRows.value = []
|
previewRows.value = []
|
||||||
|
importMode.value = 'skip'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@ -309,6 +356,38 @@ watch(visible, (val) => {
|
|||||||
color: #909399;
|
color: #909399;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
.import-strategy {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.strategy-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.strategy-group .el-radio {
|
||||||
|
margin-right: 0;
|
||||||
|
height: auto;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #DCDFE6;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.strategy-group .el-radio.is-checked {
|
||||||
|
border-color: #409EFF;
|
||||||
|
background: #ECF5FF;
|
||||||
|
}
|
||||||
|
.strategy-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.strategy-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
:deep(.el-upload-dragger) {
|
:deep(.el-upload-dragger) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user