From 031ec4d2890129e78933eff8b238d8faa8d63bcc Mon Sep 17 00:00:00 2001 From: Jimmy Date: Thu, 7 May 2026 15:18:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- browser_login/fetch_receipt_details_full.py | 152 ++++++++++-- .../fetch_receipt_details_incremental.py | 24 +- web_ui/__pycache__/app.cpython-313.pyc | Bin 0 -> 34993 bytes web_ui/app.py | 232 +++++++++++++----- web_ui/templates/bom_compare.html | 61 ++++- 5 files changed, 377 insertions(+), 92 deletions(-) create mode 100644 web_ui/__pycache__/app.cpython-313.pyc 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 0000000000000000000000000000000000000000..343d048e258dcf9424b3bc0a337125eff4d27875 GIT binary patch literal 34993 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 W&&Elytvwq*(JsW^cx@h|mH!|6JzjAD literal 0 HcmV?d00001 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('导出失败,请重试'); + }); } } })