diff --git a/browser_login/fetch_receipt_details_full.py b/browser_login/fetch_receipt_details_full.py index 30157da..6de64c5 100644 --- a/browser_login/fetch_receipt_details_full.py +++ b/browser_login/fetch_receipt_details_full.py @@ -19,7 +19,17 @@ SAVE_PATH = OUTPUT_DIR / "receipt_details_full_clean.json" def fetch_receipt_details_full(): log("INFO", "=== 🚚 启动收货明细报表全量抓取 (精简字段模式) ===") page = get_page(port=9222) + + # 尝试加载已有的存档,实现真正的断点累加 all_clean_items = [] + if SAVE_PATH.exists(): + try: + with open(SAVE_PATH, "r", encoding="utf-8") as f: + all_clean_items = json.load(f) + log("INFO", f"📦 已加载本地历史存档,包含 {len(all_clean_items)} 条数据。") + except Exception as e: + log("WARN", f"加载本地存档失败: {e},将从空列表开始。") + all_clean_items = [] try: log("INFO", f"正在回到主页起点: {HOME_URL}") @@ -75,43 +85,107 @@ def fetch_receipt_details_full(): return # ========================================================= - # 第一页数据处理 + # 第一页数据处理 (如果触发断点,则忽略第一页数据) # ========================================================= log("OK", f"🎉 成功拦截到第一页数据!HTTP: {packet.response.status}") body = packet.response.body data = body if isinstance(body, (dict, list)) else json.loads(body) + # 设定开始抓取的页码,1表示从头开始抓全量数据 + target_resume_page = 690 + total_count = 0 if isinstance(data, dict) and "result" in data: total_count = data["result"].get("totalCount", 0) items = data["result"].get("items", []) - for item in items: - all_clean_items.append({ - "采购订单号": item.get("purchaseOrderCode"), - "行号": item.get("rowsNum"), - "物料代码": item.get("materialCode"), - "物料名称": item.get("materialName"), - "物料规格": item.get("materialSpecification"), - "仓库代码": item.get("warehouseCode"), - "仓库名称": item.get("warehouseName"), - "供应商代码": item.get("supplierCode"), - "供应商名称": item.get("supplierName"), - "单位名称": item.get("unitName"), - "转换单位": item.get("convertUnitName"), - "收货单价": item.get("receivePrice"), - "收货时间": item.get("receiptTime"), - "进货数量": item.get("convertPlannedPurchaseQuantity") if item.get("convertPlannedPurchaseQuantity") is not None else item.get("plannedPurchaseQuantity"), - "收货数量": item.get("convertGoodsQuantity") if item.get("convertGoodsQuantity") is not None else item.get("goodsQuantity"), - "收货总金额": item.get("receiveAmount") - }) - log("OK", f"第一页清洗完成,提取了 {len(items)} 条数据。后端报告总条数: {total_count}") + + # 只有当不是断点续传(即从第1页开始)时,才把第一页的数据加入列表 + if target_resume_page <= 1: + for item in items: + all_clean_items.append({ + "采购订单号": item.get("purchaseOrderCode"), + "行号": item.get("rowsNum"), + "物料代码": item.get("materialCode"), + "物料名称": item.get("materialName"), + "物料规格": item.get("materialSpecification"), + "仓库代码": item.get("warehouseCode"), + "仓库名称": item.get("warehouseName"), + "供应商代码": item.get("supplierCode"), + "供应商名称": item.get("supplierName"), + "单位名称": item.get("unitName"), + "转换单位": item.get("convertUnitName"), + "收货单价": item.get("receivePrice"), + "收货时间": item.get("receiptTime"), + "进货数量": item.get("plannedPurchaseQuantity"), + "收货数量": item.get("convertGoodsQuantity") if item.get("convertGoodsQuantity") is not None else item.get("goodsQuantity"), + "收货总金额": item.get("receiveAmount") + }) + log("OK", f"第一页清洗完成,提取了 {len(items)} 条数据。后端报告总条数: {total_count}") + else: + log("INFO", f"触发断点续传,跳过第一页的数据保存。后端报告总条数: {total_count}") page_num = 1 + # ========================================================= + # 断点续传逻辑 (由于刚才中断在 711 页,我们需要跳到 712 页继续) + # ========================================================= + + if target_resume_page > 1: + log("INFO", f"🚀 触发断点续传机制!准备直接跳转到第 {target_resume_page} 页...") + # 尝试找页码输入框 + jumper_input_xpath = 'xpath://*[@id="app"]/div/div[1]/div[2]/div[2]/div[1]/div[2]/div/div[2]/div[1]/span[3]/div/div//input' + input_ele = page.ele(jumper_input_xpath, timeout=5) + + if not input_ele: + jumper_input_xpath = 'xpath://input[@type="number" and @aria-label="页"]' + input_ele = page.ele(jumper_input_xpath, timeout=5) + + if input_ele: + input_ele.clear() + input_ele.input(str(target_resume_page)) + time.sleep(0.5) + input_ele.input('\n') + + packet = page.listen.wait(timeout=15) + if not packet: + log("ERR", "断点跳转失败,未拦截到目标页的数据请求。") + return + + log("OK", f"✅ 成功跳转至第 {target_resume_page} 页并截获数据!") + page_num = target_resume_page + + # 读取并解析第 191 页的数据 + body = packet.response.body + data = body if isinstance(body, (dict, list)) else json.loads(body) + if isinstance(data, dict) and "result" in data: + items = data["result"].get("items", []) + for item in items: + all_clean_items.append({ + "采购订单号": item.get("purchaseOrderCode"), + "行号": item.get("rowsNum"), + "物料代码": item.get("materialCode"), + "物料名称": item.get("materialName"), + "物料规格": item.get("materialSpecification"), + "仓库代码": item.get("warehouseCode"), + "仓库名称": item.get("warehouseName"), + "供应商代码": item.get("supplierCode"), + "供应商名称": item.get("supplierName"), + "单位名称": item.get("unitName"), + "转换单位": item.get("convertUnitName"), + "收货单价": item.get("receivePrice"), + "收货时间": item.get("receiptTime"), + "进货数量": item.get("plannedPurchaseQuantity"), + "收货数量": item.get("convertGoodsQuantity") if item.get("convertGoodsQuantity") is not None else item.get("goodsQuantity"), + "收货总金额": item.get("receiveAmount") + }) + log("OK", f"第 {page_num} 页清洗完成,累计提取 {len(all_clean_items)} 条数据。") + else: + log("ERR", "找不到页码输入框,断点跳转失败,将从第 1 页继续!") + + # ========================================================= # 循环翻页抓取 # ========================================================= - next_btn_xpath = 'xpath://*[@id="app"]/div/div[1]/div[2]/div[2]/div[1]/div[2]/div/div[2]/div[1]/button[2]' while True: # 引入“类人”随机延迟(2.5 秒到 5.5 秒之间随机) @@ -125,10 +199,31 @@ def fetch_receipt_details_full(): log("INFO", f"☕️ 已经连续高强度翻了 {page_num} 页,触发风控规避机制,假装喝水休息 {long_delay:.2f} 秒...") time.sleep(long_delay) - next_btn = page.ele(next_btn_xpath, timeout=5) + # 兼容多种 ElementUI 翻页按钮的特征 + # 为了防止由于网络延迟导致的 DOM 元素短暂消失,我们加入重试机制 + next_btn = None + for _ in range(3): + next_btn = page.ele('xpath://button[contains(@class, "btn-next")]', timeout=3) + if next_btn: + break + time.sleep(1) + + # 【修复】当跳页页数大于 400 页时,某些页面的 ElementUI 分页组件会为了节省 DOM 而卸载 next_btn + # 或者被包裹在隐藏容器里。如果在页面底部直接寻找带有 "btn-next" 且不包含 disabled 的按钮 if not next_btn: - log("ERR", "找不到下一页按钮,翻页中止。") - break + # 尝试备用定位方式:直接找右箭头图标所在的按钮 + next_btn = page.ele('xpath://i[contains(@class, "el-icon-arrow-right")]/parent::button', timeout=3) + + if not next_btn: + log("ERR", "重试 3 次后仍然找不到下一页按钮,可能是页面崩溃或会话超时,尝试强制刷新页面...") + page.refresh() + page.wait.load_start() + time.sleep(5) + # 刷新后尝试重新找一次 + next_btn = page.ele('xpath://button[contains(@class, "btn-next")]', timeout=5) + if not next_btn: + log("ERR", "刷新后依然找不到下一页按钮,彻底中止。") + break # 检查按钮是否被禁用 class_str = str(next_btn.attr("class")) @@ -212,6 +307,13 @@ def fetch_receipt_details_full(): with open(rescue_path, "w", encoding="utf-8") as f: json.dump(all_clean_items, f, ensure_ascii=False, indent=2) log("INFO", f"🆘 触发异常保存,抢救了 {len(all_clean_items)} 条数据。") + finally: + # 无论脚本正常结束还是异常退出,都强制停止监听,防止成为僵尸爬虫 + try: + page.listen.stop() + log("INFO", "🛑 已释放浏览器监听资源。") + except: + pass if __name__ == "__main__": fetch_receipt_details_full() diff --git a/browser_login/fetch_receipt_details_incremental.py b/browser_login/fetch_receipt_details_incremental.py index 4d8637d..ecbfd6d 100644 --- a/browser_login/fetch_receipt_details_incremental.py +++ b/browser_login/fetch_receipt_details_incremental.py @@ -224,8 +224,17 @@ def fetch_receipt_details_incremental(): log("INFO", f"⏳ 停顿 {delay:.2f} 秒后点击下一页...") time.sleep(delay) - next_btn_xpath = 'xpath://*[@id="app"]/div/div[1]/div[2]/div[2]/div[1]/div[2]/div/div[2]/div[1]/button[2]' - next_btn = page.ele(next_btn_xpath, timeout=5) + # 同步全量脚本的优化:重试机制与兼容的类名匹配 + next_btn = None + for _ in range(3): + next_btn = page.ele('xpath://button[contains(@class, "btn-next")]', timeout=3) + if next_btn: + break + time.sleep(1) + + # 备用定位方式:直接找右箭头图标所在的按钮 + if not next_btn: + next_btn = page.ele('xpath://i[contains(@class, "el-icon-arrow-right")]/parent::button', timeout=3) if next_btn: try: next_btn.click() @@ -236,7 +245,7 @@ def fetch_receipt_details_incremental(): log("ERR", f"第 {current_page + 1} 页请求超时!") break else: - log("ERR", "找不到下一页按钮!") + log("ERR", "重试 3 次后仍然找不到下一页按钮!") break current_page += 1 @@ -246,8 +255,13 @@ def fetch_receipt_details_incremental(): except Exception as e: log("ERR", f"发生全局异常: {e}") finally: - conn.close() - page.listen.stop() + if 'conn' in locals() and conn: + conn.close() + if 'page' in locals() and page: + try: + page.listen.stop() + except Exception: + pass if __name__ == "__main__": fetch_receipt_details_incremental() \ No newline at end of file diff --git a/web_ui/__pycache__/app.cpython-313.pyc b/web_ui/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000..343d048 Binary files /dev/null and b/web_ui/__pycache__/app.cpython-313.pyc differ diff --git a/web_ui/app.py b/web_ui/app.py index 515c80f..de625c5 100644 --- a/web_ui/app.py +++ b/web_ui/app.py @@ -149,8 +149,6 @@ def get_receipts(): def sync_receipts(): """触发后台运行收货明细增量抓取脚本(修复跨机器路径问题)""" import sys - import io - import contextlib if str(BROWSER_LOGIN_DIR) not in sys.path: sys.path.insert(0, str(BROWSER_LOGIN_DIR)) @@ -158,16 +156,13 @@ def sync_receipts(): try: from fetch_receipt_details_incremental import fetch_receipt_details_incremental - # 捕获函数内部的 print/log 输出 - f = io.StringIO() - with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f): - fetch_receipt_details_incremental() + # 将长时间运行的抓取任务放入后台线程,防止前端请求超时 + threading.Thread(target=fetch_receipt_details_incremental, daemon=True).start() - logs = f.getvalue() return jsonify({ "success": True, - "message": "增量同步执行完成", - "logs": logs + "message": "增量同步任务已在后台启动!请观察黑框控制台的运行日志。", + "logs": "任务已在后台运行..." }) except ImportError: return jsonify({"success": False, "message": "找不到增量抓取脚本或导入失败"}), 404 @@ -178,8 +173,6 @@ def sync_receipts(): def sync_bom(): """触发后台运行 BOM 成本全量抓取脚本(修复跨机器路径问题)""" import sys - import io - import contextlib if str(BROWSER_LOGIN_DIR) not in sys.path: sys.path.insert(0, str(BROWSER_LOGIN_DIR)) @@ -187,16 +180,13 @@ def sync_bom(): try: from fetch_bom_cost_full_tree import fetch_bom_cost_tree - # 捕获函数内部的 print/log 输出 - f = io.StringIO() - with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f): - fetch_bom_cost_tree() + # 将长时间运行的抓取任务放入后台线程,防止前端请求超时 + threading.Thread(target=fetch_bom_cost_tree, daemon=True).start() - logs = f.getvalue() return jsonify({ "success": True, - "message": "BOM 同步执行完成", - "logs": logs + "message": "BOM 树抓取任务已在后台启动!预计耗时 10-20 分钟,请观察黑框控制台的运行日志。", + "logs": "任务已在后台运行..." }) except ImportError: return jsonify({"success": False, "message": "找不到 BOM 抓取脚本或导入失败"}), 404 @@ -447,19 +437,8 @@ def compare_page(): """渲染 BOM 成本期间对比分析页面""" return render_template('bom_compare.html') -@app.route('/api/bom_tree_compare/') -def get_bom_tree_compare(parent_code): - """ - 为成本对比页面提供带时间段价格分析的 BOM 树数据。 - 前端需要传入两个时间段参数: - start_a, end_a (期间 A) - start_b, end_b (期间 B) - """ - start_a = request.args.get('start_a') - end_a = request.args.get('end_a') - start_b = request.args.get('start_b') - end_b = request.args.get('end_b') - +def build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b): + """构建用于成本对比分析的 BOM 树数据""" conn = get_db_connection() # 1. 查出基本结构 @@ -470,7 +449,7 @@ def get_bom_tree_compare(parent_code): if not parent_info: conn.close() - return jsonify({"error": "找不到该父件"}), 404 + return None, "找不到该父件" children_records = conn.execute( 'SELECT id, node_material_code, node_material_name, parent_node_id, bom_level, usage_qty FROM bom_child WHERE parent_material_code = ?', @@ -578,19 +557,12 @@ def get_bom_tree_compare(parent_code): prices_dict[code][status_key] = 'fallback' # 标记为使用了历史最近价 fallback_found.add(code) - # 3. 如果连期间之前的历史记录都没有,但它有基准价(说明是在期间之后才采购的),也算是 fallback 还是 missing? - # 我们之前定义的是“无任何历史记录”才标红。所以,如果它有最新基准价(latest_price > 0), - # 说明这东西买过,只是在 start_date 之前没买过。我们不应该把它标红(missing),而应该标为普通的没价格或者某种特殊颜色。 - # 但为了严谨:既然我们在 start_date 之前没买过,意味着期初无库存/无价格,期间也没买,那这期间的价格只能是 0。 - # 并且如果 latest_price > 0,说明它并非“历史从未采购”,我们把它标为 fallback 但价格是 0。 for code in missing_codes: if code not in fallback_found: prices_dict[code][period_key] = 0.0 if prices_dict[code].get('latest_price', 0) > 0: - # 有基准价,但在该期间和该期间之前都没买过,这不算“从未采购过” prices_dict[code][status_key] = 'fallback' else: - # 彻底查不到任何记录,才是真正的 missing prices_dict[code][status_key] = 'missing' # --- B. 处理期间 A --- @@ -655,19 +627,11 @@ def get_bom_tree_compare(parent_code): has_children = bool(node.get('children')) - # 判断一个节点在业务上是不是真的“组件”还是“采购件” - # 即使它没有子节点(抓取时下面没数据),但如果它自身没有采购价(latestUnitPrice == 0) - # 且在实际业务中它是挂在 BOM 上的(说明是个虚拟组件或大总成),我们也不该给它标颜色 - is_real_purchased_leaf = (not has_children) and (node['latestUnitPrice'] > 0 or node['periodAStatus'] != 'missing' or node['periodBStatus'] != 'missing') - if not has_children: - # 叶子节点,核心逻辑修改:区分是否是 1PZJ 铸件 if code and code.startswith('1PZJ') and node.get('castingWeight'): - # 铸件:总成本 = BOM 用量(件) * 单件重量(KG/件) * 采购单价(元/KG) multiplier = usage_qty * float(node['castingWeight']) node['calcType'] = 'casting' else: - # 普通件:总成本 = BOM 用量 * 采购单价 multiplier = usage_qty node['calcType'] = 'normal' @@ -675,9 +639,6 @@ def get_bom_tree_compare(parent_code): node['totalPeriodA'] = node['periodAUnitPrice'] * multiplier node['totalPeriodB'] = node['periodBUnitPrice'] * multiplier - # 只要它是叶子节点,我们就给它标颜色。 - # 如果它真的是虚拟件(基准价是 0,且 A B 都是 missing),那就大大方方给它标红点 - # 用户刚才反馈说有的子件有基准价但是连点都没标,就是因为之前那个 is_real_purchased_leaf 过滤得太严格或者有 Bug。 node['showAStatus'] = node['periodAStatus'] node['showBStatus'] = node['periodBStatus'] else: @@ -691,33 +652,186 @@ def get_bom_tree_compare(parent_code): total_a += child['totalPeriodA'] total_b += child['totalPeriodB'] - # 父件总成本 = (子件成本之和) * 父件自身的耗用量 - # 如果累加为0但自身有价格,使用 (自身单价 * 自身耗用量) 作为兜底 node['totalLatest'] = (total_latest if total_latest > 0 else node['latestUnitPrice']) * usage_qty node['totalPeriodA'] = (total_a if total_a > 0 else node['periodAUnitPrice']) * usage_qty node['totalPeriodB'] = (total_b if total_b > 0 else node['periodBUnitPrice']) * usage_qty - # 父件的累加成本是由众多子件构成的,所以不显示单一的黄/红状态灯 node['showAStatus'] = 'normal' node['showBStatus'] = 'normal' calc_compare_costs(root_tree) + return root_tree, None + +@app.route('/api/bom_tree_compare/') +def get_bom_tree_compare(parent_code): + """ + 为成本对比页面提供带时间段价格分析的 BOM 树数据。 + """ + start_a = request.args.get('start_a') + end_a = request.args.get('end_a') + start_b = request.args.get('start_b') + end_b = request.args.get('end_b') + + root_tree, error = build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b) + if error: + return jsonify({"error": error}), 404 # 为了配合 ElementUI 的树形表格,根节点必须包装在数组里 return jsonify([root_tree]) -def open_browser(): +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter +from flask import send_file + +@app.route('/api/export_compare/') +def export_compare(parent_code): + start_a = request.args.get('start_a') + end_a = request.args.get('end_a') + start_b = request.args.get('start_b') + end_b = request.args.get('end_b') + + root_tree, error = build_bom_compare_tree(parent_code, start_a, end_a, start_b, end_b) + if error: + return jsonify({"error": error}), 404 + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "BOM成本对比" + + # 定义样式 + header_fill = PatternFill(start_color="409EFF", end_color="409EFF", fill_type="solid") + header_font = Font(color="FFFFFF", bold=True) + center_align = Alignment(horizontal="center", vertical="center") + border = Border(left=Side(style='thin'), right=Side(style='thin'), + top=Side(style='thin'), bottom=Side(style='thin')) + + headers = [ + "物料代码", "物料名称", "BOM层级", "用量", "单位", + "最新单价", "最新总成本", + f"期间A单价 ({start_a or '无'}至{end_a or '无'})", f"期间A总成本", + f"期间B单价 ({start_b or '无'}至{end_b or '无'})", f"期间B总成本", + "单价差异 (B-A)", "总成本差异 (B-A)" + ] + + ws.append(headers) + for col_idx, cell in enumerate(ws[1], 1): + cell.fill = header_fill + cell.font = header_font + cell.alignment = center_align + cell.border = border + ws.column_dimensions[get_column_letter(col_idx)].width = 15 + + ws.column_dimensions['A'].width = 18 + ws.column_dimensions['B'].width = 30 + ws.column_dimensions['H'].width = 25 + ws.column_dimensions['J'].width = 25 + + def write_node_to_excel(node, level=0): + # 差异计算逻辑(同前端) + period_a_unit = node.get('periodAUnitPrice', 0) or 0 + period_b_unit = node.get('periodBUnitPrice', 0) or 0 + diff_unit = period_b_unit - period_a_unit + + period_a_total = node.get('totalPeriodA', 0) or 0 + period_b_total = node.get('totalPeriodB', 0) or 0 + diff_total = period_b_total - period_a_total + + prefix = " " * level + + row_data = [ + node.get('materialCode', ''), + prefix + node.get('materialName', ''), + node.get('bomLevel', ''), + node.get('usageQty', 1), + node.get('unitName', ''), + node.get('latestUnitPrice', 0), + node.get('totalLatest', 0), + period_a_unit, + period_a_total, + period_b_unit, + period_b_total, + diff_unit, + diff_total + ] + + ws.append(row_data) + current_row = ws.max_row + + # 简单格式化 + for col_idx, cell in enumerate(ws[current_row], 1): + cell.border = border + if col_idx in [6, 7, 8, 9, 10, 11, 12, 13]: + cell.number_format = '0.00' + + # 递归子节点 + if node.get('children'): + for child in node['children']: + write_node_to_excel(child, level + 1) + + write_node_to_excel(root_tree) + + output = io.BytesIO() + wb.save(output) + output.seek(0) + + filename = f"BOM成本对比_{parent_code}.xlsx" + return send_file( + output, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + +def open_browser(port): """延迟 1.5 秒后自动打开系统默认浏览器""" time.sleep(1.5) - url = "http://127.0.0.1:5050" + url = f"http://127.0.0.1:{port}" print(f"🚀 正在自动打开浏览器: {url}") webbrowser.open(url) +def find_free_port(start_port=5050, max_port=5100): + """自动寻找未被占用的端口,避免 Windows 10013 端口被拒错误""" + import socket + for port in range(start_port, max_port): + # 尝试通过创建 socket 并监听来确认端口是否真的可用 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + # 开启端口复用,避免 TIME_WAIT 状态导致误判 + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + # 绑定到 127.0.0.1 而不是 0.0.0.0 + s.bind(('127.0.0.1', port)) + return port + except OSError: + continue + + # 如果 5050-5100 都被占用或被拦截,尝试让系统随机分配一个端口 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(('127.0.0.1', 0)) + return s.getsockname()[1] + if __name__ == '__main__': + # 动态获取一个可用端口 + try: + port = find_free_port() + except Exception as e: + print(f"⚠️ 无法自动获取端口,回退到默认端口: {e}") + port = 5050 + # 启动前开启一个线程去拉起浏览器 - threading.Thread(target=open_browser, daemon=True).start() + threading.Thread(target=open_browser, args=(port,), daemon=True).start() # 启动后端服务 - print("🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:5050") - # 更改默认端口为 5050,避开 macOS 控制中心的 5000 端口占用 - app.run(debug=False, host='0.0.0.0', port=5050) \ No newline at end of file + print(f"🚀 前端展示后端服务已启动!请在浏览器访问: http://127.0.0.1:{port}") + + # 智能判断:如果是通过 PyInstaller 打包运行的,则关闭热加载以避免多进程冲突和双开浏览器 + is_frozen = getattr(sys, 'frozen', False) + + # 更改为动态端口,避开被占用的端口。修改 host 为 127.0.0.1 避免 Windows 权限拦截 + app.run( + debug=not is_frozen, + host='127.0.0.1', + port=port, + threaded=True, + use_reloader=not is_frozen + ) \ No newline at end of file diff --git a/web_ui/templates/bom_compare.html b/web_ui/templates/bom_compare.html index fefc49b..4fff013 100644 --- a/web_ui/templates/bom_compare.html +++ b/web_ui/templates/bom_compare.html @@ -211,7 +211,7 @@
- + 期间 A (基准期) - + 期间 B (对比期) - + 执行对比 + 导出 Excel @@ -418,6 +419,7 @@ parents: [], loadingParents: false, loadingData: false, + exporting: false, currentParentCode: null, tableData: [], originalTableData: [], @@ -562,6 +564,59 @@ }; this.tableData = filterInvisibleNodes(newData); + }, + + exportExcel() { + if (!this.currentParentCode) { + this.$message.warning('请先选择一个成品父件并执行对比'); + return; + } + + if (!this.periodA_start || !this.periodA_end || !this.periodB_start || !this.periodB_end) { + this.$message.warning('请完整选择期间A和期间B的时间范围'); + return; + } + + this.exporting = true; + + const params = new URLSearchParams({ + start_a: this.periodA_start, + end_a: this.periodA_end, + start_b: this.periodB_start, + end_b: this.periodB_end + }); + + axios({ + url: `/api/export_compare/${this.currentParentCode}?${params.toString()}`, + method: 'GET', + responseType: 'blob' // 重要:设置响应类型为 blob + }) + .then(response => { + // 创建下载链接 + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + // 从响应头中获取文件名,如果没有则使用默认文件名 + const contentDisposition = response.headers['content-disposition']; + let fileName = `BOM成本对比_${this.currentParentCode}.xlsx`; + if (contentDisposition) { + const fileNameMatch = contentDisposition.match(/filename="?(.+)"?/); + if (fileNameMatch && fileNameMatch.length === 2) { + fileName = decodeURIComponent(fileNameMatch[1]); + } + } + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + this.exporting = false; + this.$message.success('导出成功'); + }) + .catch(error => { + this.exporting = false; + this.$message.error('导出失败,请重试'); + }); } } })