(() => { "use strict"; const API_BASE = "/api/v1"; const REQUEST_TIMEOUT_MS = 4500; const DEFAULT_MINE_CODE = "shangwan"; const DEMO_MINE = { id: "demo-shangwan", code: DEFAULT_MINE_CODE, name: "上湾煤矿", short_name: "上湾煤矿" }; const DEMO_DASHBOARD = { mine_name: "上湾煤矿", kpis: [ { key: "sensors", label: "传感器总数", value: 2486, unit: "台" }, { key: "online_rate", label: "设备在线率", value: 97.2, unit: "%" }, { key: "alarms", label: "未闭环告警", value: 18, unit: "条" }, { key: "high_defects", label: "高风险缺陷", value: 3, unit: "项" }, { key: "score", label: "综合评价得分", value: 92.8, unit: "分" } ], diagnosis_summary: { high: 3, medium: 7, low: 8, total: 18 }, evaluation_summary: { score: 92.8, grade: "A", run_no: "EV-20261008-001" }, open_work_order_count: 7 }; const DEMO_DOCUMENTS = [ { id: "n1202", type: "PDF", title: "N1202 工作面通风安全技术措施", category: "通风专业", code: "TF-CS-2026-086", owner: "王强", pages: "42 页", size: "2.8 MB", date: "2026-10-07", status: "抽取完成", confidence: 92.4, summary: "已完成工作面通风方式、需风量、瓦斯管理及局部通风参数等关键字段结构化。", fields: [ ["工作面名称", "N1202 综采工作面", "98.6%"], ["通风方式", "U 型通风", "96.8%"], ["设计需风量", "1,404 m³/min", "94.2%"], ["瓦斯浓度上限", "0.80%", "97.1%"] ] }, { id: "resistance", type: "DOC", title: "2026 年 9 月通风阻力测定报告", category: "通风专业", code: "TF-BB-2026-091", owner: "赵磊", pages: "28 页", size: "1.6 MB", date: "2026-09-30", status: "抽取完成", confidence: 90.8, summary: "已抽取通风阻力测点、巷道参数、风机工况和阻力变化趋势。", fields: [ ["测定路线", "北翼回风系统", "95.7%"], ["最大阻力值", "2,860 Pa", "93.1%"], ["主通风机风量", "7,860 m³/min", "96.4%"], ["主要异常区段", "北翼回风巷", "89.6%"] ] }, { id: "airlog", type: "XLS", title: "矿井测风记录日报表", category: "监测监控", code: "TF-BB-2026-092", owner: "刘伟", pages: "1,240 行", size: "856 KB", date: "2026-10-08", status: "抽取中 76%", confidence: 88.5, summary: "正在按测风站、用风地点和日期执行表格结构识别。", fields: [ ["日报日期", "2026-10-08", "99.1%"], ["测风站数量", "18 个", "96.3%"], ["总进风量", "7,920 m³/min", "94.8%"], ["异常测点", "S1301 测风站", "87.6%"] ] }, { id: "s1302", type: "PDF", title: "S1302 工作面综合防尘设计", category: "粉尘防治", code: "TF-SJ-2026-034", owner: "陈晨", pages: "36 页", size: "3.1 MB", date: "2026-10-05", status: "等待解析", confidence: 0, summary: "资料已入库,等待执行版面解析和防尘设施参数抽取。", fields: [ ["工作面名称", "S1302 综采工作面", "待抽取"], ["防尘方式", "煤层注水 + 喷雾降尘", "待抽取"], ["设计负责人", "陈晨", "已识别"] ] }, { id: "regulation", type: "PDF", title: "矿井通风管理制度汇编", category: "制度管理", code: "TF-GC-2026-012", owner: "周洋", pages: "118 页", size: "5.4 MB", date: "2026-09-18", status: "抽取完成", confidence: 89.6, summary: "已完成章节结构识别、制度条款切分和责任岗位抽取。", fields: [ ["制度版本", "2026 版", "98.2%"], ["制度章节数", "12 章", "96.7%"], ["测风管理制度", "第 4 章", "94.3%"], ["责任岗位数", "28 个", "90.4%"] ] }, { id: "acceptance", type: "DOC", title: "南翼通风系统调整验收资料", category: "通风专业", code: "TF-YS-2026-021", owner: "孙敏", pages: "22 页", size: "1.2 MB", date: "2026-09-26", status: "抽取完成", confidence: 93.1, summary: "已抽取调整范围、验收指标、现场测风结果和验收结论。", fields: [ ["验收对象", "南翼通风系统调整工程", "98.4%"], ["风量验收结果", "合格", "96.8%"], ["设施验收数量", "36 项", "94.2%"], ["验收负责人", "孙敏", "97.1%"] ] } ]; const DEMO_MONITORING = { overview: { data_source_count: 12, online_source_count: 11, sensor_count: 2486, online_sensor_count: 2417, facility_count: 486, abnormal_facility_count: 3, open_alarm_count: 18, high_alarm_count: 3 }, sources: [ { id: "demo-source-gas", name: "安全监控系统", source_type: "security", status: "online", point_count: 1286, latency_ms: 2400 }, { id: "demo-source-video", name: "工业视频监控", source_type: "video", status: "online", point_count: 218, latency_ms: 1800 }, { id: "demo-source-facility", name: "通防设施工况", source_type: "facility", status: "online", point_count: 486, latency_ms: 3200 } ], sensors: [ { id: "demo-gas", name: "N1202 瓦斯监测点", sensor_type: "gas", unit: "%CH₄", last_value: 0.86, status: "alarm", warning_threshold: 0.72, alarm_threshold: 0.8 }, { id: "demo-airflow", name: "S1301 测风站", sensor_type: "airflow", unit: "m³/min", last_value: 1286, status: "warning", warning_threshold: 1300, alarm_threshold: 1200 }, { id: "demo-dust", name: "S1302 粉尘监测点", sensor_type: "dust", unit: "mg/m³", last_value: 4.2, status: "warning", warning_threshold: 3.6, alarm_threshold: 4.0 } ], facilities: [], alarms: [ { id: "demo-alarm-1", title: "N1202 回风巷瓦斯浓度超限", level: "high", status: "open", value: 0.86, threshold: 0.8 } ] }; const SAFETY_METRIC_DEFS = [ { key: "gas", name: "甲烷浓度", shortName: "甲烷", sensorType: "gas", icon: "flame", unit: "%CH₄", tone: "red", baseline: 0.54, amplitude: 0.08, noise: 0.025, warningThreshold: 0.6, alarmThreshold: 0.8, thresholdMode: "above", pointCount: 286, onlineCount: 284, recommendations: ["复核 N1202 回风巷局部通风机运行状态", "连续跟踪瓦斯浓度变化并准备撤人断电预案"] }, { key: "airflow", name: "工作面风量", shortName: "风量", sensorType: "airflow", icon: "wind", unit: "m³/min", tone: "blue", baseline: 1398, amplitude: 34, noise: 16, warningThreshold: 1300, alarmThreshold: 1200, thresholdMode: "below", pointCount: 218, onlineCount: 216, recommendations: ["核对 S1301 测风站与 N1202 工作面风量", "检查调节风窗开度和漏风状况"] }, { key: "co", name: "一氧化碳", shortName: "CO", sensorType: "co", icon: "activity", unit: "ppm", tone: "amber", baseline: 11.5, amplitude: 2.2, noise: 1.1, warningThreshold: 18, alarmThreshold: 24, thresholdMode: "above", pointCount: 164, onlineCount: 163, recommendations: ["检查采空区遗煤氧化与密闭状态", "结合烟雾和温度测点进行联合研判"] }, { key: "temperature", name: "环境温度", shortName: "温度", sensorType: "temperature", icon: "thermometer", unit: "℃", tone: "purple", baseline: 24.6, amplitude: 1.4, noise: 0.5, warningThreshold: 30, alarmThreshold: 35, thresholdMode: "above", pointCount: 192, onlineCount: 191, recommendations: ["持续监测回风巷温度变化", "关注高温区域设备散热和风流组织"] }, { key: "dust", name: "粉尘浓度", shortName: "粉尘", sensorType: "dust", icon: "cloud-dust", unit: "mg/m³", tone: "teal", baseline: 3.15, amplitude: 0.36, noise: 0.16, warningThreshold: 3.5, alarmThreshold: 4, thresholdMode: "above", pointCount: 176, onlineCount: 174, recommendations: ["检查 S1302 喷雾降尘装置运行状态", "核对采煤机割煤期间的粉尘峰值"] }, { key: "air_speed", name: "巷道风速", shortName: "风速", sensorType: "air_speed", icon: "gauge", unit: "m/s", tone: "green", baseline: 4.15, amplitude: 0.28, noise: 0.12, warningThreshold: 0.25, alarmThreshold: 0.15, thresholdMode: "below", pointCount: 250, onlineCount: 247, recommendations: ["复核低风速巷道的风量分配", "检查局部巷道变形和堆积物影响"] } ]; const DEMO_KNOWLEDGE = [ { id: "k-reg-001", title: "《煤矿安全规程》瓦斯超限处置条款", knowledge_type: "规程", category: "规范标准", summary: "瓦斯浓度超限后应立即停止作业、切断电源、撤出人员,并核查通风系统。", content: "瓦斯浓度超限后,应当立即停止作业、切断电源、撤出人员,并核查局部通风机、风筒和回风系统状态。处置完成后复测瓦斯浓度,形成闭环记录。" }, { id: "k-case-026", title: "通风设施漏风处置案例", knowledge_type: "事故案例", category: "事故案例", summary: "通过风门密封检查、闭锁装置复核和风量复测完成漏风处置。", content: "风门漏风排查应重点检查门框密封、闭锁装置、门板变形、墙体裂隙和传感器状态,确认漏风后及时封堵并复测风量。" }, { id: "k-expert-021", title: "局部通风机循环风判定经验", knowledge_type: "专家经验", category: "专家经验", summary: "结合吸风口瓦斯浓度、风筒距离、巷道风量和风流方向综合判定。", content: "判定局部通风机循环风,应比较吸风口与巷道风流瓦斯浓度,并结合风筒出口距离、巷道风量和风流方向综合判断。" } ]; const DEMO_DIAGNOSIS = [ { id: "d1", defect_name: "N1202 回风巷瓦斯浓度超限", defect_code: "R-GAS-001", scene: "灾害防治", severity: "high", confidence: 92.6, status: "processing", conclusion: "瓦斯浓度超过报警阈值。", recommendation: "停止作业并核查局部通风机和回风系统。", occurred_at: "10:38:24" }, { id: "d2", defect_name: "T-17 局部通风机停风", defect_code: "R-LOCAL-001", scene: "局部通风", severity: "high", confidence: 91.2, status: "pending", conclusion: "局部通风机连续失电,存在停风风险。", recommendation: "核查供电回路、风筒连接和人员撤离情况。", occurred_at: "10:32:08" }, { id: "d3", defect_name: "2# 调节风门状态不一致", defect_code: "R-FAC-001", scene: "通风设施", severity: "medium", confidence: 88.7, status: "pending", conclusion: "视频与传感器反馈状态不一致。", recommendation: "安排现场复核风门开闭状态。", occurred_at: "10:28:42" }, { id: "d4", defect_name: "S1301 采煤工作面风量偏小", defect_code: "R-AF-001", scene: "风量分配", severity: "medium", confidence: 89.3, status: "processing", conclusion: "实际风量低于计划风量。", recommendation: "调整风窗开度并复测风量。", occurred_at: "09:56:10" }, { id: "d5", defect_name: "北翼通风阻力较上月上升 6.2%", defect_code: "R-SYS-001", scene: "通风系统管理", severity: "low", confidence: 86.5, status: "closed", conclusion: "巷道阻力呈上升趋势。", recommendation: "排查巷道变形和堆积物。", occurred_at: "08:30:00" } ]; const DEMO_EVALUATION = { run: { run_no: "EV-20261008-001", total_score: 92.8, grade: "A", summary: "安全性、可靠性表现稳定,风量分配均衡度和局部通风管理为当前主要提升方向。", recommendations: [ "北翼回风阻力专项排查", "主通风机能效优化", "S1301 风量复测与分配调整" ], created_at: "2026-10-08T10:00:00+08:00" }, dimensions: [ { name: "安全性", score: 96.2, change: 1.8, note: "瓦斯、火灾、粉尘、通风可靠性" }, { name: "可靠性", score: 91.4, change: 0.6, note: "设备可用率、数据连续性、故障恢复" }, { name: "经济性", score: 84.7, change: -0.9, note: "主通风机能耗、漏风率、运行效率" }, { name: "管理合规", score: 94.1, change: 2.1, note: "资料完整、制度执行、整改及时性" }, { name: "数据质量", score: 89.6, change: 3.4, note: "完整性、及时性、一致性与有效性" } ] }; const DEMO_WORK_ORDERS = [ { id: "w1", order_no: "WO-20261008-001", title: "N1202 回风巷瓦斯超限处置", content: "核查局部通风机与回风系统,确认撤人断电措施执行情况。", priority: "high", status: "pending", assignee_name: "王强", due_at: "2026-10-08T12:30:00+08:00", created_at: "2026-10-08T10:38:00+08:00" }, { id: "w2", order_no: "WO-20261008-002", title: "2# 调节风门状态复核", content: "视频判断风门未闭合,传感器反馈正常,需现场确认。", priority: "medium", status: "pending", assignee_name: "赵磊", due_at: "2026-10-08T14:20:00+08:00", created_at: "2026-10-08T10:28:00+08:00" }, { id: "w3", order_no: "WO-20261008-003", title: "T-17 局部通风机异常处置", content: "现场检查供电回路与风筒连接状态。", priority: "high", status: "processing", assignee_name: "刘伟", due_at: "2026-10-08T12:00:00+08:00", created_at: "2026-10-08T10:32:00+08:00" }, { id: "w4", order_no: "WO-20261008-004", title: "S1301 风量复测", content: "调整风窗开度后进行第二次测风。", priority: "medium", status: "processing", assignee_name: "周洋", due_at: "2026-10-08T14:00:00+08:00", created_at: "2026-10-08T09:56:00+08:00" }, { id: "w5", order_no: "WO-20261007-012", title: "主通风机运行曲线复核", content: "完成能效分析,等待通防科负责人确认。", priority: "medium", status: "review", assignee_name: "孙敏", due_at: "2026-10-08T17:00:00+08:00", created_at: "2026-10-07T16:20:00+08:00" }, { id: "w6", order_no: "WO-20261008-006", title: "风门传感器更换", content: "更换后数据恢复,复核记录已归档。", priority: "low", status: "closed", assignee_name: "赵磊", due_at: "2026-10-08T11:00:00+08:00", created_at: "2026-10-08T08:30:00+08:00" } ]; const EMPTY_DOCUMENTS = { page: { items: [], total: 0 }, categories: [] }; const DEMO_DOCUMENT_STATISTICS = { total_documents: 7280, documents_added_this_month: 126, extracted_fields: 12406, field_completeness: 96.2, extraction_accuracy: 85.7, accuracy_change: 2.1, pending_tasks: 18, estimated_minutes: 36, verified_fields: 0, edited_fields: 0, incorrect_fields: 0, expected_fields: 0, valid_fields: 0, invalid_fields: 0, missing_fields: 0, pending_judgements: 0, accuracy_fields: 0, storage_used_bytes: 0, storage_capacity_bytes: 1024 * 1024 * 1024 * 1024, storage_usage_percent: 0, updated_at: new Date().toISOString() }; const FACILITY_CATEGORIES = [ { key: "sensors", name: "传感器", icon: "radio-tower", keywords: ["传感器"], total: 223, alarms: 0, offline: 2 }, { key: "main-fan", name: "主通风机", icon: "wind", keywords: ["主扇", "主通风机"], total: 2, alarms: 1, offline: 0 }, { key: "local-fan", name: "局部通风机", icon: "wind", keywords: ["局扇", "局部通风机"], total: 2, alarms: 0, offline: 0 }, { key: "auto-door", name: "自动风门", icon: "door-open", keywords: ["风门"], total: 5, alarms: 0, offline: 1 }, { key: "auto-window", name: "自动风窗", icon: "settings-2", keywords: ["风窗"], total: 6, alarms: 0, offline: 1 }, { key: "airflow-device", name: "测风装置", icon: "gauge", keywords: ["测风", "风速"], total: 80, alarms: 0, offline: 2 }, { key: "compressor", name: "压风机", icon: "settings", keywords: ["压风"], total: 4, alarms: 0, offline: 0 }, { key: "spray", name: "喷雾", icon: "cloud-dust", keywords: ["喷雾"], total: 22, alarms: 0, offline: 0 }, { key: "dust", name: "喷粉", icon: "cloud-dust", keywords: ["喷粉", "粉尘"], total: 2, alarms: 0, offline: 0 }, { key: "nitrogen", name: "制氮", icon: "cpu", keywords: ["制氮"], total: 4, alarms: 0, offline: 0 }, { key: "grout", name: "制浆", icon: "database", keywords: ["制浆", "注浆"], total: 2, alarms: 0, offline: 0 }, { key: "spray-equipment", name: "喷雾设备", icon: "cloud-dust", keywords: ["雾化", "净化水幕"], total: 12, alarms: 0, offline: 0 }, { key: "gas-drainage", name: "瓦斯抽采泵", icon: "plug-zap", keywords: ["瓦斯抽采", "抽采泵"], total: 2, alarms: 0, offline: 0 } ]; const VIDEO_CHANNELS = [ { id: "v01", name: "五盘区402辅运大巷风门", group: "风门区域", location: "五盘区402辅运大巷", status: "online" }, { id: "v02", name: "六盘区501胶带运输巷风门", group: "风门区域", location: "六盘区501胶带运输巷", status: "online" }, { id: "v03", name: "七盘区602回风联络巷风门", group: "风门区域", location: "七盘区602回风联络巷", status: "online" }, { id: "v04", name: "中央变电所进风通道风门", group: "风门区域", location: "中央变电所", status: "online" }, { id: "v05", name: "采区水泵房专用通道风门", group: "风门区域", location: "采区水泵房", status: "online" }, { id: "v06", name: "南风井绕道处风门", group: "风门区域", location: "南风井绕道", status: "online" }, { id: "v07", name: "12煤四盘区回风入口风门", group: "风门区域", location: "12煤四盘区", status: "online" }, { id: "v08", name: "22107胶运绕道行车风门", group: "风门区域", location: "22107胶运绕道", status: "offline" }, { id: "v09", name: "1号掘进工作面局部风机-变频器区", group: "局扇区域", location: "1号掘进工作面", status: "online" }, { id: "v10", name: "1号掘进工作面局部风机-配电柜区", group: "局扇区域", location: "1号掘进工作面", status: "online" }, { id: "v11", name: "2号掘进工作面局部风机", group: "局扇区域", location: "2号掘进工作面", status: "online" }, { id: "v12", name: "3号掘进工作面局部风机", group: "局扇区域", location: "3号掘进工作面", status: "online" }, { id: "v13", name: "4号局部风机配电点", group: "局扇区域", location: "4号掘进工作面", status: "online" }, { id: "v14", name: "一号主风机-风机主体", group: "主扇区域", location: "一号主通风机", status: "online" }, { id: "v15", name: "一号主风机-配电室", group: "主扇区域", location: "一号主风机配电室", status: "online" }, { id: "v16", name: "二号主风机-风机主体", group: "主扇区域", location: "二号主通风机", status: "online" }, { id: "v17", name: "主扇配电室", group: "主扇区域", location: "主通风机配电室", status: "online" }, { id: "v18", name: "主扇风硐监测点", group: "主扇区域", location: "主通风机风硐", status: "online" } ]; const VIDEO_MODULES = { door: { defaultChannel: "v01", title: "风门视频图像综合解析系统", statusTitle: "风门状态总览", selector: "全部风门", typeFilterLabel: "告警类型", typeFilterOptions: ["全部类型", "AI防夹人预警", "异物卡堵预警", "密封失效故障", "违规停留预警", "风门未关严", "车辆撞击故障"], scopeFilterLabel: "风门选择", scopeFilterOptions: ["全部风门", "五盘区402辅运大巷风门", "六盘区501胶带运输巷风门", "七盘区602回风联络巷风门", "中央变电所进风通道风门"], snapshotTypes: ["车辆通行", "人员通行", "风门未关严"], summary: [ ["正常风门", 3, "正常"], ["离线设备", 0, "断开"], ["设备故障", 1, "异常"], ["实时预警", 1, "告警"], ["今日车辆", 28, "辆"], ["今日人员", 15, "人"] ], logs: [ ["11:42:31", "七盘区602回风联络巷风门未关严,门板闭合缝隙15mm,超标10mm", "danger"], ["11:42:26", "中央变电所进风通道风门违规停留,禁停区有人员逗留", "warning"], ["11:42:20", "中央变电所进风通道风门密封失效,压力值异常", "warning"], ["16:30:22", "六盘区501胶带运输巷防夹人预警,危险区域有人闯入", "danger"], ["16:28:15", "中央变电所进风通道风门未关严,门板闭合缝隙15mm", "warning"] ], tabs: ["车辆通行记录", "人员通行记录", "风门未关严记录"], columns: ["序号", "风门名称", "车牌号", "通行时间", "结果"], rows: [ [1, "五盘区402辅运大巷风门", "3571", "2026-03-27 16:20:18", "通行成功"], [2, "六盘区501胶带运输巷风门", "6829", "2026-03-27 16:10:25", "通行成功"], [3, "七盘区602回风联络巷风门", "1122", "2026-03-27 15:50:33", "通行成功"], [4, "中央变电所进风通道风门", "3344", "2026-03-27 15:30:41", "通行成功"], [5, "采区水泵房专用通道风门", "5566", "2026-03-27 15:10:50", "通行成功"] ] }, "local-fan": { defaultChannel: "v09", title: "局部风机无人值守演示系统", statusTitle: "风机状态总览", selector: "全部风机", typeFilterLabel: "操作类型", typeFilterOptions: ["全部操作", "风机调频", "风机切换", "参数调整", "复位操作"], scopeFilterLabel: "风机选择", scopeFilterOptions: ["全部风机", "1号掘进工作面局部风机", "2号掘进工作面局部风机", "3号掘进工作面局部风机", "4号局部风机配电点"], snapshotTypes: ["风机调频", "风机切换", "参数调整"], summary: [ ["风机正常", 4, "正常"], ["风机离线", 0, "断开"], ["风机故障", 1, "异常"], ["实时预警", 3, "预警"], ["实时人数", 2, "人"], ["今日进入", 15, "人"] ], logs: [ ["10:12:20", "1号风机 风机调频 张三|人员准入匹配", "success"], ["11:05:10", "2号风机 参数调整 李四|人员准入匹配", "success"], ["14:30:00", "3号风机 切换主备 未知人员|未检测到人员", "danger"] ], tabs: ["人员轨迹", "区域统计", "预警记录"], columns: ["序号", "风机", "区域", "姓名", "区队", "进入", "离开", "时长"], rows: [ [1, "1号掘进工作面局部风机", "变频器区", "张三", "机运队", "2026-03-28 08:45", "2026-03-28 08:53", "8分20秒"], [2, "1号掘进工作面局部风机", "配电柜区", "李四", "维修队", "2026-03-28 09:10", "2026-03-28 09:15", "5分10秒"] ] }, "main-fan": { defaultChannel: "v14", title: "主通风机无人巡检演示系统", statusTitle: "主风机运行状态", selector: "全部设备", typeFilterLabel: "操作类型", typeFilterOptions: ["全部操作", "启动风机", "停止风机", "参数调整", "巡检操作"], scopeFilterLabel: "设备选择", scopeFilterOptions: ["全部设备", "一号主风机", "二号主风机", "主扇配电室", "主扇风硐监测点"], snapshotTypes: ["人员检测", "设备操作", "设备预警"], summary: [ ["运行风机", 1, "运行"], ["停止风机", 1, "停止"], ["设备故障", 0, "正常"], ["预警总数", 2, "预警"], ["人员检测", 1, "人"], ["今日记录", 26, "条"] ], logs: [ ["08:50:10", "一号主风机,启动风机,PLC操作匹配", "success"], ["10:20:30", "配电室 参数调整 PLC操作匹配", "success"], ["14:10:00", "二号主风机 停止风机 未检测到人员", "danger"] ], tabs: ["人员检测记录", "设备操作日志", "设备预警记录"], columns: ["序号", "设备", "位置", "检测时间", "检测结果", "状态"], rows: [ [1, "一号主风机", "风机主体", "2026-04-02 08:45:20", "检测到人员", "正常"], [2, "配电室", "设备操作区", "2026-04-02 10:18:10", "检测到人员", "正常"], [3, "二号主风机", "风机主体", "2026-04-02 14:05:00", "检测到人员", "异常"] ] } }; const state = { initialized: false, apiAvailable: false, mine: DEMO_MINE, documents: new Map(), templates: [], documentPage: 1, documentPageSize: 5, documentCategory: "all", documentKeyword: "", documentSearchSnippets: new Map(), documentStatistics: DEMO_DOCUMENT_STATISTICS, monitoring: null, facilityMonitoring: null, safetyMetrics: [], safetySelectedMetric: "gas", safetyRange: 24, safetyReadings: new Map(), safetyLoaded: false, videoModule: "door", videoLayout: 2, videoRecordTab: 0, videoSelectedChannel: "v01", videoSearch: "", knowledgeCategory: null, knowledgeCategoryPage: 1, knowledgeCategoryPageSize: 20, knowledgeCategoryKeyword: "", knowledgeCategoryTotal: 0, judgementPolls: new Set(), errors: [] }; function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function withMine(path) { if (!state.apiAvailable || !state.mine?.id) return path; const separator = path.includes("?") ? "&" : "?"; return `${path}${separator}mine_id=${encodeURIComponent(state.mine.id)}`; } async function request(path, options = {}) { const { timeoutMs = REQUEST_TIMEOUT_MS, ...fetchOptions } = options; const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); const isFormData = typeof FormData !== "undefined" && fetchOptions.body instanceof FormData; try { const response = await fetch(`${API_BASE}${path}`, { ...fetchOptions, headers: { Accept: "application/json", ...(fetchOptions.body && !isFormData ? { "Content-Type": "application/json" } : {}), ...(fetchOptions.headers || {}) }, signal: controller.signal }); if (!response.ok) { let message = `HTTP ${response.status}`; let payload = null; try { payload = await response.json(); message = payload?.message || payload?.detail || message; } catch { // Keep the HTTP status when the response is not JSON. } const error = new Error(message); error.status = response.status; error.code = payload?.code || ""; throw error; } if (response.status === 204) return null; return await response.json(); } finally { window.clearTimeout(timeoutId); } } async function withFallback(label, loader, fallback) { try { const data = await loader(); if ( data == null || (Array.isArray(data) && data.length === 0) || (Array.isArray(data?.items) && data.items.length === 0) ) { throw new Error("empty response"); } return { data, source: "api" }; } catch (error) { state.errors.push(`${label}: ${error?.message || error}`); return { data: fallback, source: "demo" }; } } function apiLoader(label, path, fallback, options = {}) { return withFallback(label, () => request(withMine(path), options), fallback); } function formatNumber(value) { const number = Number(value); if (!Number.isFinite(number)) return String(value ?? "--"); return number.toLocaleString("zh-CN", { maximumFractionDigits: 1 }); } function formatBytes(value) { const bytes = Number(value); if (!Number.isFinite(bytes) || bytes < 0) return "--"; if (bytes === 0) return "0 B"; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`; return `${(bytes / 1024 ** 4).toFixed(2)} TB`; } function formatDate(value) { if (!value) return "--"; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value).slice(0, 10); return date.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }); } function formatTime(value) { if (!value) return "--"; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value).slice(11, 16) || "--"; return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); } function formatDuration(seconds) { const value = Number(seconds); if (!Number.isFinite(value) || value < 0) return "--"; if (value < 60) { return `${value < 10 ? value.toFixed(1) : Math.round(value)} 秒`; } const minutes = Math.floor(value / 60); const remaining = Math.round(value % 60); return `${minutes} 分 ${remaining} 秒`; } function formatTaskStartTime(value) { if (!value) return "等待中"; const raw = String(value); const utcValue = /(?:z|[+-]\d{2}:\d{2})$/i.test(raw) ? raw : `${raw}Z`; const date = new Date(utcValue); if (Number.isNaN(date.getTime())) return raw.slice(0, 16); const part = (number) => String(number).padStart(2, "0"); return `${part(date.getMonth() + 1)}-${part(date.getDate())} ${part(date.getHours())}:${part(date.getMinutes())}`; } function setText(selector, value) { const element = document.querySelector(selector); if (element) element.textContent = value; } function setMetric(key, value, decimals = 0) { const element = document.querySelector(`[data-metric="${key}"]`); if (!element) return; if (typeof window.animateNumber === "function" && Number.isFinite(Number(value))) { window.animateNumber(element, Number(value), decimals); return; } element.textContent = decimals ? Number(value).toFixed(decimals) : formatNumber(value); } function kpiMap(kpis) { return new Map((kpis || []).map((item) => [item.key, item])); } function updateConnectionBadge(source) { const badge = document.querySelector(".topbar .status-pill"); if (!badge) return; const labels = { api: "实时运行", demo: "演示数据", mixed: "部分演示", loading: "数据连接中" }; badge.innerHTML = `${labels[source] || labels.demo}`; badge.dataset.source = source; } function renderDashboard(dashboard, monitoring, evaluation) { const kpis = kpiMap(dashboard.kpis); const sensors = kpis.get("sensors")?.value ?? monitoring.overview?.sensor_count ?? 0; const onlineRate = kpis.get("online_rate")?.value ?? ((monitoring.overview?.online_sensor_count || 0) / Math.max(monitoring.overview?.sensor_count || 1, 1) * 100); const alarms = kpis.get("alarms")?.value ?? monitoring.overview?.open_alarm_count ?? 0; const highDefects = kpis.get("high_defects")?.value ?? dashboard.diagnosis_summary?.high ?? 0; const score = dashboard.evaluation_summary?.score ?? evaluation.run?.total_score ?? 0; const grade = String(dashboard.evaluation_summary?.grade || evaluation.run?.grade || "A").replace("级", ""); setText("#currentMine", dashboard.mine_name || state.mine?.name || "上湾煤矿"); setMetric("points", sensors); setMetric("alerts", alarms); setMetric("grade", grade); setText(".kpi-card .kpi-foot .text-success", formatNumber(score)); const pointsCard = document.querySelector('[data-metric="points"]')?.closest(".kpi-card"); if (pointsCard) { pointsCard.querySelector(".kpi-foot span")?.replaceChildren(`在线率 ${Number(onlineRate).toFixed(1)}%`); } const alertCard = document.querySelector('[data-metric="alerts"]')?.closest(".kpi-card"); if (alertCard) { const high = alertCard.querySelector(".kpi-foot .text-danger"); if (high) high.textContent = String(highDefects); const rate = alertCard.querySelectorAll(".kpi-foot span")[1]; if (rate) rate.textContent = `未闭环 ${dashboard.open_work_order_count ?? 0} 项`; } const context = document.querySelector(".context-message strong"); const contextText = document.querySelector(".context-message p"); if (context) context.textContent = Number(highDefects) > 0 ? "系统运行总体平稳" : "系统运行安全稳定"; if (contextText) { contextText.textContent = `当前识别 ${formatNumber(highDefects)} 项高优先级风险,未闭环任务 ${formatNumber(dashboard.open_work_order_count || 0)} 项。`; } const ring = document.querySelector(".dashboard-grid .score-ring"); if (ring) { ring.style.setProperty("--score", score); const strong = ring.querySelector("strong"); if (strong) strong.textContent = formatNumber(score); } const gradeBadge = document.querySelector(".dashboard-grid .grade-badge"); if (gradeBadge) gradeBadge.textContent = `${grade} 级`; renderScoreRows(evaluation.dimensions || DEMO_EVALUATION.dimensions); renderSensorMetrics(monitoring.sensors); const updateTime = document.querySelector(".map-toolbar > span"); if (updateTime) { updateTime.innerHTML = `数据更新于 ${new Date().toLocaleTimeString("zh-CN", { hour12: false })}`; } } function renderScoreRows(dimensions) { const list = document.querySelector(".dashboard-grid .score-list"); if (!list || !dimensions?.length) return; list.innerHTML = dimensions .slice(0, 5) .map((item) => { const score = Number(item.score || 0); return `
${escapeHtml(item.name)}
${score.toFixed(1)}
`; }) .join(""); } function renderSensorMetrics(sensors) { const items = sensors?.length ? sensors : DEMO_MONITORING.sensors; const preferred = ["gas", "airflow", "dust", "temperature", "carbon_monoxide"]; const ordered = preferred .map((type) => items.find((sensor) => sensor.sensor_type === type)) .filter(Boolean); if (ordered.length < 3) ordered.push(...items.filter((sensor) => !ordered.includes(sensor))); document.querySelectorAll(".dashboard-grid .sensor-metric").forEach((metric, index) => { const sensor = ordered[index]; if (!sensor) return; const strong = metric.querySelector(".sensor-value strong"); const unit = metric.querySelector(".sensor-value small"); const trend = metric.querySelector(".sensor-value .trend"); if (strong) strong.textContent = formatNumber(sensor.last_value ?? sensor.value ?? "--"); if (unit) unit.textContent = sensor.unit || ""; if (trend) { trend.textContent = sensor.status === "alarm" ? "超限" : sensor.status === "warning" ? "关注" : "稳定"; trend.classList.toggle("down", sensor.status === "alarm"); trend.classList.toggle("up", sensor.status !== "alarm"); } }); } function documentStatus(documentData) { const parseStatus = documentData.parse_status || documentData.status || ""; if (parseStatus === "completed" || parseStatus.includes("完成")) return { label: "抽取完成", className: "success" }; if (parseStatus === "failed") { return { label: "处理失败", className: "failed" }; } if (parseStatus === "parsing") { return { label: `解析中 ${documentData.parse_progress ?? 0}%`, className: "running" }; } if ( parseStatus === "processing" || parseStatus === "extracting" || parseStatus.includes("抽取中") ) { return { label: `抽取中 ${documentData.parse_progress ?? 76}%`, className: "running" }; } return { label: "等待解析", className: "pending" }; } function fallbackDocumentFor(item) { return DEMO_DOCUMENTS.find( (demo) => demo.code && demo.code === item.code || demo.title === item.title ); } function normalizeDocumentType(extension) { const value = String(extension || "").replace(".", "").trim().toUpperCase(); if (value.includes("PDF")) return "PDF"; if (value.includes("DOC")) return "DOC"; if (value.includes("XLS") || value.includes("CSV")) return "XLS"; return value || "FILE"; } function mapDocument(item) { const fallback = fallbackDocumentFor(item) || {}; const status = documentStatus(item); const type = normalizeDocumentType(item.file_ext || fallback.type); const pageText = item.page_count ? `${formatNumber(item.page_count)} 页` : item.row_count ? `${formatNumber(item.row_count)} 行` : fallback.pages || (documentStatus(item).label === "抽取完成" ? "未提供页数" : "--"); return { id: String(item.id), type, title: item.title || fallback.title || "未命名资料", category: item.category || fallback.category || "未分类", code: item.code || fallback.code || "--", owner: item.owner_name || fallback.owner || "--", pages: pageText, size: formatBytes(item.file_size) !== "--" ? formatBytes(item.file_size) : fallback.size || "--", date: formatDate(item.effective_date || item.created_at) || fallback.date || "--", status: status.label, statusClass: status.className, parseStatus: item.parse_status || "", parseProgress: Number(item.parse_progress || 0), parseError: item.parse_error || "", confidence: item.metadata?.confidence ?? fallback.confidence, summary: item.summary || fallback.summary || "", fields: fallback.fields || [], extractions: item.extractions || [] }; } function mapDocumentDetail(item) { const documentData = mapDocument(item); if (item.extractions?.length) { const template = state.templates.find( (candidate) => candidate.code === item.document_type || candidate.document_type === item.document_type ); const templateGroups = template?.fields?.groups || []; const groupOrder = new Map( templateGroups.map((group, index) => [group.key, group.order ?? index]) ); const fieldOrder = new Map( templateGroups.flatMap((group) => (group.fields || []).map((field, index) => [field.key, field.order ?? index]) ) ); const grouped = new Map(); item.extractions.forEach((field) => { const metadata = field.metadata || field.metadata_json || {}; const groupName = metadata.group_name || "其他字段"; const groupKey = metadata.group_key || groupName; if (groupKey === "compliance_analysis") return; if (!grouped.has(groupName)) { grouped.set(groupName, { key: groupKey, name: groupName, order: Number(metadata.group_order ?? groupOrder.get(groupKey) ?? 999), fields: [] }); } grouped.get(groupName).fields.push({ id: String(field.id || field.field_key), label: field.field_name || field.field_key, value: field.normalized_value || field.field_value || "--", confidence: field.confidence == null ? "已抽取" : `${Number(field.confidence).toFixed(1)}%`, edited: Boolean(field.edited), extractionCorrect: field.extraction_correct !== false, editReason: field.edit_reason || "", editJudgementStatus: field.edit_judgement_status || "not_required", order: Number(metadata.field_order ?? fieldOrder.get(field.field_key) ?? 999) }); }); documentData.fieldGroups = [...grouped.values()] .sort((left, right) => left.order - right.order) .map((group) => ({ ...group, fields: group.fields .sort((left, right) => left.order - right.order) })); documentData.fields = item.extractions.map((field) => [ field.field_name || field.field_key, field.normalized_value || field.field_value || "--", field.confidence == null ? "已抽取" : `${Number(field.confidence).toFixed(1)}%` ]); const confidences = item.extractions .map((field) => Number(field.confidence)) .filter(Number.isFinite); if (confidences.length) { documentData.confidence = confidences.reduce((sum, value) => sum + value, 0) / confidences.length; } } return documentData; } function currentDocumentFallback() { const activeId = document.querySelector(".document-row.active")?.dataset.documentId; return state.documents.get(activeId) || DEMO_DOCUMENTS[0]; } function renderDocumentRows(items, categories, total) { const list = document.querySelector("#businessDocumentList"); if (!list) return; const rows = [...list.querySelectorAll(".document-row")]; const documents = items.map(mapDocument); state.documents.clear(); documents.forEach((documentData, index) => { let row = rows[index]; if (!row) { row = document.createElement("button"); row.className = "document-row"; row.type = "button"; list.appendChild(row); rows.push(row); } row.hidden = false; documentData.id = String(documentData.id || `document-${index}`); documentData.searchSnippets = state.documentSearchSnippets.get(String(documentData.id)) || []; state.documents.set(documentData.id, documentData); row.dataset.documentId = documentData.id; row.dataset.category = documentData.category; row.innerHTML = ` ${escapeHtml(documentData.type)} ${escapeHtml(documentData.title)} 编号 ${escapeHtml(documentData.code)} · ${escapeHtml(documentData.pages)} · ${escapeHtml(documentData.owner)} ${documentData.searchSnippets.length ? `${escapeHtml(documentData.searchSnippets[0].label)}${escapeHtml(documentData.searchSnippets[0].text)}` : ""} ${escapeHtml(documentData.status)} ${documentData.fields.length}字段 `; if (typeof window.injectIcons === "function") window.injectIcons(row); const editAction = row.querySelector("[data-edit-document]"); editAction?.addEventListener("click", (event) => { event.stopPropagation(); openBusinessEditModal(documentData.id); }); editAction?.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); openBusinessEditModal(documentData.id); } }); const deleteAction = row.querySelector("[data-delete-document]"); deleteAction?.addEventListener("click", (event) => { event.stopPropagation(); deleteBusinessDocument(documentData.id, documentData.title); }); deleteAction?.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); deleteBusinessDocument(documentData.id, documentData.title); } }); }); rows.slice(documents.length).forEach((row) => row.remove()); if (!documents.length) { const panel = document.querySelector("#businessDetailPanel"); if (panel) { panel.innerHTML = `

当前业务资料库暂无资料。

`; if (typeof window.injectIcons === "function") window.injectIcons(panel); } } const firstRow = rows[0]; if (documents.length && firstRow) { rows.forEach((row) => row.classList.toggle("active", row === firstRow)); window.renderBusinessDocument?.(firstRow.dataset.documentId); } const filteredTotal = Number(total) || documents.length; const globalTotal = (categories || []).reduce( (sum, item) => sum + Number(item.count || 0), 0 ) || filteredTotal; renderDocumentCategories(documents, categories, filteredTotal); renderDocumentTotals(filteredTotal, globalTotal); renderDocumentPagination(filteredTotal); } function renderDocumentCategories(documents, categories, total) { const buttons = [...document.querySelectorAll("#businessCategoryList button")]; const counts = new Map( (categories || []).map((item) => [item.category, Number(item.count || 0)]) ); if (!counts.size) { documents.forEach((documentData) => { counts.set(documentData.category, (counts.get(documentData.category) || 0) + 1); }); } buttons.forEach((button) => { const key = button.dataset.docCategory; const count = key === "all" ? [...counts.values()].reduce((sum, value) => sum + value, 0) || Number(total) || documents.length : counts.get(key) || 0; const badge = button.querySelector("b"); if (badge) badge.textContent = formatNumber(count); }); } function renderDocumentTotals(filteredTotal, globalTotal) { const toolbar = document.querySelector("#view-business-data .toolbar-copy p"); if (toolbar) toolbar.textContent = `已接入 ${formatNumber(globalTotal)} 份资料,关键字段抽取结果与业务对象已建立关联`; const firstKpi = document.querySelector("#view-business-data .business-kpi-card strong"); if (firstKpi) firstKpi.textContent = formatNumber(globalTotal); const footer = document.querySelector("#view-business-data .document-list-footer > span"); if (footer) footer.textContent = `共 ${formatNumber(filteredTotal)} 份资料`; } function renderDocumentStatistics(statistics) { const data = statistics || DEMO_DOCUMENT_STATISTICS; state.documentStatistics = data; const setKpi = (key, value) => { const element = document.querySelector(`[data-business-kpi="${key}"]`); if (element) element.textContent = value; }; const setHint = (key, value) => { const element = document.querySelector(`[data-business-kpi-hint="${key}"]`); if (element) element.textContent = value; }; setKpi("total", formatNumber(data.total_documents)); setHint("total", `本月新增 ${formatNumber(data.documents_added_this_month)} 份`); setKpi("fields", formatNumber(data.extracted_fields)); setHint("fields", `字段完整率 ${Number(data.field_completeness || 0).toFixed(1)}%`); setKpi("accuracy", `${Number(data.extraction_accuracy || 0).toFixed(1)}%`); const accuracyChange = Number(data.accuracy_change || 0); const pendingJudgements = Number(data.pending_judgements || 0); setHint( "accuracy", pendingJudgements ? `待判定 ${formatNumber(pendingJudgements)} 项` : Number(data.edited_fields) ? `已编辑 ${formatNumber(data.edited_fields)} 项,抽取错误 ${formatNumber(data.incorrect_fields)} 项` : accuracyChange ? `较上月${accuracyChange > 0 ? "提升" : "下降"} ${Math.abs(accuracyChange).toFixed(1)}%` : "暂无上月对比数据" ); setKpi("pending", formatNumber(data.pending_tasks)); setHint( "pending", Number(data.pending_tasks) ? `预计 ${formatNumber(data.estimated_minutes)} 分钟完成` : "当前无待处理任务" ); const toolbar = document.querySelector("#view-business-data .toolbar-copy p"); if (toolbar) { toolbar.textContent = `已接入 ${formatNumber(data.total_documents)} 份资料,字段抽取平均准确率 ${Number(data.extraction_accuracy || 0).toFixed(1)}%`; } const storagePercent = Number(data.storage_usage_percent || 0); const storagePercentElement = document.querySelector("[data-storage-percent]"); const storageBar = document.querySelector("[data-storage-bar]"); const storageUsage = document.querySelector("[data-storage-usage]"); if (storagePercentElement) { storagePercentElement.textContent = `${storagePercent.toFixed(1)}%`; } if (storageBar) { storageBar.style.width = `${Math.max(0, Math.min(storagePercent, 100))}%`; } if (storageUsage) { storageUsage.textContent = `已使用 ${formatBytes(data.storage_used_bytes)} / ${formatBytes(data.storage_capacity_bytes)}`; } const businessCard = document.querySelector( "#view-data [data-open-business]" ); if (businessCard) { const status = businessCard.querySelector(".source-status"); const footValues = businessCard.querySelectorAll(".source-foot span"); const pendingTasks = Number(data.pending_tasks || 0); if (status) { status.className = `source-status ${pendingTasks ? "sync" : "online"}`; status.textContent = pendingTasks ? "同步中" : "已同步"; } if (footValues[0]) { footValues[0].textContent = `${formatNumber(data.total_documents)} 份`; } if (footValues[1]) { footValues[1].textContent = pendingTasks ? `待处理 ${formatNumber(pendingTasks)} 项` : `已抽取 ${formatNumber(data.extracted_fields)} 字段`; } } } function renderDocumentPagination(total) { const footer = document.querySelector("#view-business-data .document-list-footer"); if (!footer) return; const pageCount = Math.max(1, Math.ceil((Number(total) || 0) / state.documentPageSize)); const currentPage = Math.min(Math.max(state.documentPage, 1), pageCount); state.documentPage = currentPage; const pageButtons = Array.from({ length: pageCount }, (_, index) => { const page = index + 1; return ``; }).join(""); footer.innerHTML = ` 共 ${formatNumber(total)} 份资料
${pageButtons}
`; footer.querySelectorAll("[data-document-page]").forEach((button) => { button.addEventListener("click", () => { const page = Number(button.dataset.documentPage); if (!Number.isFinite(page) || page < 1 || page > pageCount || page === currentPage) return; refreshDocumentsFromApi(page).catch(() => { window.showToast?.("资料分页加载失败", "warning"); }); }); }); } async function hydrateDocumentDetails(items) { const results = await Promise.all( items.slice(0, 6).map(async (item) => { try { return await request(`/documents/${encodeURIComponent(item.id)}`); } catch { return null; } }) ); results.filter(Boolean).forEach((item) => { state.documents.set(String(item.id), mapDocumentDetail(item)); }); state.documents.forEach((documentData, documentId) => { const row = findDocumentRow(documentId); const fieldCount = row?.querySelector(".document-fields b"); if (fieldCount) fieldCount.textContent = String(documentData.fields.length); }); const activeId = document.querySelector(".document-row.active")?.dataset.documentId; if (activeId && state.documents.has(String(activeId))) { window.renderBusinessDocument?.(activeId); } } async function deleteBusinessDocument(documentId, title) { const confirmed = window.confirm(`确定删除“${title || "该资料"}”吗?删除后无法恢复。`); if (!confirmed) return; try { await request(`/documents/${encodeURIComponent(documentId)}`, { method: "DELETE" }); state.documents.delete(String(documentId)); const nextPage = document.querySelectorAll("#businessDocumentList .document-row").length <= 1 && state.documentPage > 1 ? state.documentPage - 1 : state.documentPage; await refreshDocumentsFromApi(nextPage); if (!document.querySelector("#businessDocumentList .document-row")) { const panel = document.querySelector("#businessDetailPanel"); if (panel) { panel.innerHTML = `

当前业务资料库暂无资料。

`; window.injectIcons?.(panel); } } window.showToast?.("资料已删除"); } catch (error) { window.showToast?.(`删除失败:${error?.message || error}`, "warning"); } } function openBusinessEditModal(documentId) { const documentData = state.documents.get(String(documentId)); const modal = document.querySelector("#businessEditModal"); const form = document.querySelector("#businessEditForm"); if (!documentData || !modal || !form) return; form.elements.document_id.value = documentData.id; form.elements.title.value = documentData.title; form.elements.code.value = documentData.code === "--" ? "" : documentData.code; form.elements.category.value = documentData.category; modal.hidden = false; window.setTimeout(() => form.elements.title.focus(), 0); } function closeBusinessEditModal() { const modal = document.querySelector("#businessEditModal"); if (modal) modal.hidden = true; } function installBusinessEdit() { const modal = document.querySelector("#businessEditModal"); const form = document.querySelector("#businessEditForm"); if (!modal || !form || form.dataset.apiBound) return; form.dataset.apiBound = "true"; modal.querySelectorAll("[data-edit-close]").forEach((button) => { button.addEventListener("click", closeBusinessEditModal); }); form.addEventListener("submit", async (event) => { event.preventDefault(); const submit = form.querySelector('[type="submit"]'); const documentId = form.elements.document_id.value; if (!documentId) return; if (submit) submit.disabled = true; try { const previousCategory = state.documents.get(String(documentId))?.category; await request(`/documents/${encodeURIComponent(documentId)}`, { method: "PATCH", body: JSON.stringify({ title: form.elements.title.value.trim(), code: form.elements.code.value.trim() || null, category: form.elements.category.value }) }); closeBusinessEditModal(); const newCategory = form.elements.category.value; if (newCategory !== previousCategory) { await setBusinessCategory(newCategory); } else { await refreshDocumentsFromApi(); } window.showToast?.("资料信息已更新"); } catch (error) { window.showToast?.(`保存失败:${error?.message || error}`, "warning"); } finally { if (submit) submit.disabled = false; } }); } async function updateBusinessDocumentField(documentId, extractionId, value) { const updated = await request( `/documents/${encodeURIComponent(documentId)}/extractions/${encodeURIComponent(extractionId)}`, { method: "PATCH", body: JSON.stringify({ field_value: value, normalized_value: value, verified: true, verified_by: "前端用户" }) } ); const documentData = state.documents.get(String(documentId)); if (documentData?.fieldGroups) { documentData.fieldGroups.forEach((group) => { group.fields.forEach((field) => { if (String(field.id) === String(extractionId)) { field.value = updated.normalized_value || updated.field_value || value; field.edited = Boolean(updated.edited); field.extractionCorrect = updated.extraction_correct !== false; field.editReason = updated.edit_reason || ""; field.editJudgementStatus = updated.edit_judgement_status || "not_required"; } }); }); } if (updated.edit_judgement_status === "pending") { pollExtractionJudgement(documentId, extractionId).catch(() => {}); } return updated; } async function pollExtractionJudgement(documentId, extractionId) { const key = `${documentId}:${extractionId}`; if (state.judgementPolls.has(key)) return; state.judgementPolls.add(key); try { for (let attempt = 0; attempt < 20; attempt += 1) { await new Promise((resolve) => window.setTimeout(resolve, attempt < 3 ? 700 : 1500) ); const detail = await request(`/documents/${encodeURIComponent(documentId)}`); state.documents.set(String(documentId), mapDocumentDetail(detail)); window.renderBusinessDocument?.(String(documentId)); const field = (detail.extractions || []).find( (item) => String(item.id) === String(extractionId) ); if (!field || field.edit_judgement_status !== "pending") { const statisticsResult = await loadDocumentStatistics(); renderDocumentStatistics(statisticsResult.data); if (field?.edit_judgement_status === "completed") { window.showToast?.( field.extraction_correct === false ? "字段判定完成:属于抽取错误" : "字段判定完成:不属于抽取错误" ); } return; } } } finally { state.judgementPolls.delete(key); } } async function refreshBusinessDocument(documentId) { const [detail, statistics] = await Promise.all([ request(`/documents/${encodeURIComponent(documentId)}`), loadDocumentStatistics() ]); state.documents.set(String(documentId), mapDocumentDetail(detail)); renderDocumentStatistics(statistics.data); window.renderBusinessDocument?.(String(documentId)); } function setUploadProgress(stage, percent) { const progress = document.querySelector("#businessUploadProgress"); const stageLabel = document.querySelector("#businessUploadStage"); const percentLabel = document.querySelector("#businessUploadPercent"); const bar = document.querySelector("#businessUploadProgressBar"); if (progress) progress.hidden = false; if (stageLabel) stageLabel.textContent = stage; if (percentLabel) percentLabel.textContent = `${Math.max(0, Math.min(percent, 100))}%`; if (bar) bar.style.width = `${Math.max(0, Math.min(percent, 100))}%`; } function resetUploadProgress() { const progress = document.querySelector("#businessUploadProgress"); const stageLabel = document.querySelector("#businessUploadStage"); const percentLabel = document.querySelector("#businessUploadPercent"); const bar = document.querySelector("#businessUploadProgressBar"); if (progress) progress.hidden = true; if (stageLabel) stageLabel.textContent = "正在上传"; if (percentLabel) percentLabel.textContent = "0%"; if (bar) bar.style.width = "0%"; } function clearTemplateSelectionError() { const select = document.querySelector("#businessTemplateSelect"); const error = document.querySelector("#businessTemplateError"); if (select) select.classList.remove("invalid"); if (error) error.hidden = true; } function showTemplateSelectionError() { const select = document.querySelector("#businessTemplateSelect"); const error = document.querySelector("#businessTemplateError"); if (select) { select.classList.add("invalid"); select.focus(); } if (error) error.hidden = false; } function openUploadModal() { const modal = document.querySelector("#businessUploadModal"); if (!modal) return; modal.hidden = false; resetUploadProgress(); clearTemplateSelectionError(); window.setTimeout(() => modal.querySelector('select[name="category"]')?.focus(), 0); } function closeUploadModal() { const modal = document.querySelector("#businessUploadModal"); if (modal) modal.hidden = true; } function resetUploadFileState(form) { const fileField = form?.querySelector(".upload-file-field"); const fileInput = form?.querySelector('input[type="file"]'); const fileTitle = document.querySelector("#businessFileTitle"); const fileDetail = document.querySelector("#businessFileDetail"); if (fileField) fileField.classList.remove("has-file"); if (fileInput) fileInput.value = ""; if (fileTitle) fileTitle.textContent = "选择 Word、PDF 或 Excel 文件"; if (fileDetail) { fileDetail.textContent = "支持 .doc .docx .pdf .xls .xlsx .csv,单个文件不超过 50 MB"; } } function updateUploadFileState(fileInput) { const file = fileInput?.files?.[0]; const fileField = fileInput?.closest(".upload-file-field"); const fileTitle = document.querySelector("#businessFileTitle"); const fileDetail = document.querySelector("#businessFileDetail"); if (!file) { resetUploadFileState(fileInput?.form); return; } fileField?.classList.add("has-file"); if (fileTitle) fileTitle.textContent = file.name; const extension = `.${file.name.split(".").pop()?.toLowerCase() || ""}`; const lightweight = fileInput.form?.querySelector('input[name="parse_mode"][value="lightweight"]'); const precision = fileInput.form?.querySelector('input[name="parse_mode"][value="precision"]'); if (extension === ".doc" || extension === ".xls") { if (lightweight) lightweight.checked = false; if (precision) precision.checked = true; } if (fileDetail) { fileDetail.textContent = extension === ".doc" || extension === ".xls" ? `已选择 · ${formatBytes(file.size)} · 旧版格式将使用精准解析` : `已选择 · ${formatBytes(file.size)} · 点击可重新选择`; } } function findDocumentRow(documentId) { return [...document.querySelectorAll("#businessDocumentList .document-row")].find( (row) => row.dataset.documentId === String(documentId) ); } function updateDocumentProcessingRow(documentId, run) { const row = findDocumentRow(documentId); if (!row) return; const status = documentStatus({ parse_status: run.stage || run.status, parse_progress: run.progress }); const extraction = row.querySelector(".document-extraction"); if (extraction) { extraction.className = `document-extraction ${status.className}`; extraction.innerHTML = `${escapeHtml(status.label)}`; } const documentData = state.documents.get(String(documentId)); if (documentData) { documentData.status = status.label; documentData.statusClass = status.className; documentData.parseStatus = run.stage || run.status; documentData.parseProgress = Number(run.progress || 0); documentData.parseError = run.error || ""; } if (row.classList.contains("active")) { const detailStatus = document.querySelector("#businessDetailPanel .business-detail-status"); if (detailStatus) { detailStatus.className = `business-detail-status ${status.className}`; detailStatus.textContent = status.label; } } } async function refreshDocumentsFromApi(page = state.documentPage) { state.documentPage = page; const [result, statisticsResult] = await Promise.all([ loadDocuments(), loadDocumentStatistics() ]); const documents = result.data.page?.items || []; renderDocumentRows( documents, result.data.categories, result.data.page?.total ); renderDocumentStatistics(statisticsResult.data); if (result.source === "api" && documents.length) { hydrateDocumentDetails(documents).catch(() => {}); } return documents; } async function setBusinessCategory(category) { state.documentCategory = category || "all"; state.documentPage = 1; document.querySelectorAll("#businessCategoryList button").forEach((button) => { button.classList.toggle("active", button.dataset.docCategory === state.documentCategory); }); window.dispatchEvent( new CustomEvent("vent:business-category-changed", { detail: { category: state.documentCategory } }) ); try { await refreshDocumentsFromApi(1); } catch { window.showToast?.("资料分类加载失败", "warning"); } } async function setBusinessSearch(keyword) { state.documentKeyword = String(keyword || "").trim(); state.documentPage = 1; try { await refreshDocumentsFromApi(1); } catch { window.showToast?.("全文检索失败", "warning"); } } async function getDocumentProcessing(documentId) { try { return await request( `/documents/${encodeURIComponent(documentId)}/processing` ); } catch (error) { if (String(error?.message || "").includes("404")) return null; throw error; } } async function reprocessBusinessDocument(documentId, options = {}) { const run = await request(`/documents/${encodeURIComponent(documentId)}/process`, { method: "POST", body: JSON.stringify({ force: true, parse_mode: options.parseMode || "lightweight", template_id: options.templateId || null }) }); updateDocumentProcessingRow(documentId, run); refreshDocumentProcessingTasks().catch(() => {}); pollDocumentProcessing(documentId).catch(() => {}); return run; } async function pollDocumentProcessing(documentId) { for (let attempt = 0; attempt < 1200; attempt += 1) { await new Promise((resolve) => window.setTimeout(resolve, attempt < 3 ? 800 : 1500)); try { const run = await request( `/documents/${encodeURIComponent(documentId)}/processing` ); updateDocumentProcessingRow(documentId, run); setUploadProgress( run.stage === "parsing" ? "正在解析文档" : run.stage === "extracting" ? "正在抽取关键信息" : run.stage === "completed" ? "处理完成" : "正在处理", run.progress || 0 ); if (run.status === "completed") { await refreshDocumentsFromApi(); await refreshDocumentProcessingTasks(); const row = findDocumentRow(documentId); row?.click(); window.showToast?.( `资料解析完成,共提取 ${run.model_output?.field_count || 0} 个字段` ); return run; } if (run.status === "failed") { await refreshDocumentsFromApi(); await refreshDocumentProcessingTasks(); window.showToast?.(run.error || "资料解析失败", "warning"); return run; } } catch { // The upload may still be committing; keep polling briefly. } } window.showToast?.("资料处理时间较长,请稍后刷新查看结果", "warning"); return null; } function installBusinessUpload() { const modal = document.querySelector("#businessUploadModal"); const form = document.querySelector("#businessUploadForm"); if (!modal || !form || form.dataset.apiBound) return; form.dataset.apiBound = "true"; const templateSelect = document.querySelector("#businessTemplateSelect"); const fileInput = form.querySelector('input[type="file"]'); if (templateSelect) { templateSelect.innerHTML = state.templates.length ? '' + state.templates .map( (template) => `` ) .join("") : ''; } document.querySelectorAll("[data-open-upload]").forEach((button) => { button.addEventListener("click", openUploadModal); }); modal.querySelectorAll("[data-upload-close]").forEach((button) => { button.addEventListener("click", () => { resetUploadFileState(form); clearTemplateSelectionError(); resetUploadProgress(); closeUploadModal(); }); }); fileInput?.addEventListener("change", () => { clearTemplateSelectionError(); updateUploadFileState(fileInput); }); templateSelect?.addEventListener("change", clearTemplateSelectionError); form.addEventListener("submit", async (event) => { event.preventDefault(); if (!state.apiAvailable) { window.showToast?.("后端不可用,暂时无法上传资料", "warning"); return; } const submit = form.querySelector('[type="submit"]'); const formData = new FormData(form); formData.set("mine_id", state.mine.id); if (submit) submit.disabled = true; setUploadProgress("正在上传文件", 3); try { const documentData = await request("/documents/upload", { method: "POST", body: formData, timeoutMs: 120000 }); setUploadProgress("文件已上传,等待解析", 20); window.showToast?.("资料上传成功,已进入解析队列"); clearTemplateSelectionError(); resetUploadProgress(); closeUploadModal(); form.reset(); resetUploadFileState(form); await refreshDocumentsFromApi(1); refreshDocumentProcessingTasks().catch(() => {}); pollDocumentProcessing(documentData.id).catch(() => {}); } catch (error) { resetUploadProgress(); if (error?.code === "extraction_template_required") { showTemplateSelectionError(); window.showToast?.( "文件名未匹配到抽取模板,请手动选择业务资料类型", "warning" ); } else { window.showToast?.(`上传失败:${error?.message || error}`, "warning"); } } finally { if (submit) submit.disabled = false; } }); } function sourceVisual(source) { const type = String(source.source_type || "").toLowerCase(); if (type.includes("video")) return { icon: "video", className: "blue", description: "视频监控与智能识别" }; if (type.includes("facility")) return { icon: "settings-2", className: "amber", description: "风门、风窗、密闭和局部通风机" }; if (type.includes("document")) return { icon: "file-text", className: "purple", description: "规程、措施、报表与台账" }; return { icon: "database", className: "teal", description: "瓦斯、风速、温度、CO 等监测点" }; } function processingTaskStatus(task) { const status = String(task.status || "pending"); const stage = String(task.stage || status); if (status === "completed" || stage === "completed") { return { label: "已完成", className: "success" }; } if (status === "failed" || stage === "failed") { return { label: "处理失败", className: "failed" }; } if (["running", "parsing", "extracting"].includes(status) || ["parsing", "extracting"].includes(stage)) { const stageLabel = stage === "parsing" ? "解析中" : stage === "extracting" ? "抽取中" : "处理中"; return { label: `${stageLabel} ${Number(task.progress || 0)}%`, className: "running" }; } return { label: "等待中", className: "pending" }; } function processingTaskDescription(task) { const finished = task.status === "completed" || task.stage === "completed" || task.status === "failed" || task.stage === "failed"; const elapsed = Number.isFinite(Number(task.processing_seconds)) ? ` · ${finished ? "处理耗时" : "已用时"} ${formatDuration(task.processing_seconds)}` : ""; const startText = `开始时间 ${formatTaskStartTime(task.started_at)}`; if (task.status === "failed" || task.stage === "failed") { return `${task.error || "资料处理失败,等待重试"} · ${startText}${elapsed}`; } if (task.status === "completed" || task.stage === "completed") { const source = task.page_count ? `识别 ${formatNumber(task.page_count)} 页` : task.row_count ? `识别 ${formatNumber(task.row_count)} 行` : "识别完成"; return `${source} · 抽取 ${formatNumber(task.field_count)} 个关键字段 · ${startText}${elapsed}`; } if (task.stage === "parsing") return `正在解析文档版面与表格结构 · ${startText}${elapsed}`; if (task.stage === "extracting") return `正在调用模型抽取关键字段 · ${startText}${elapsed}`; return `等待进入解析队列 · ${startText}${elapsed}`; } function renderDocumentProcessingTasks( tasks, targetSelector = "#documentProcessingTaskList", limit = 4 ) { const list = document.querySelector(targetSelector); if (!list) return; if (!tasks?.length) { list.innerHTML = `

${state.apiAvailable ? "当前没有资料处理任务。" : "处理任务加载失败,请检查后端服务。"}

`; window.injectIcons?.(list); return; } list.innerHTML = tasks .slice(0, limit) .map((task) => { const status = processingTaskStatus(task); const type = normalizeDocumentType(task.file_ext); return `
${escapeHtml(type)}
《${escapeHtml(task.title)}》

${escapeHtml(processingTaskDescription(task))}

${escapeHtml(status.label)}
`; }) .join(""); } async function refreshDocumentProcessingTasks() { try { const tasks = await request(withMine("/documents/processing-tasks?limit=4")); renderDocumentProcessingTasks(tasks || [], "#documentProcessingTaskList", 4); return tasks || []; } catch { const list = document.querySelector("#documentProcessingTaskList"); if (list) { list.innerHTML = `

处理任务加载失败,请稍后重试。

`; window.injectIcons?.(list); } return []; } } async function openProcessingRecords() { const modal = document.querySelector("#documentProcessingRecordsModal"); if (!modal) return; modal.hidden = false; document.body.style.overflow = "hidden"; const list = document.querySelector("#documentProcessingRecordsList"); const count = document.querySelector("#documentProcessingRecordsCount"); if (list) { list.innerHTML = `

正在加载处理记录。

`; window.injectIcons?.(list); } try { const tasks = await request(withMine("/documents/processing-tasks?limit=50")); if (count) count.textContent = `${formatNumber((tasks || []).length)} 条记录`; renderDocumentProcessingTasks( tasks || [], "#documentProcessingRecordsList", 50 ); } catch { if (list) { list.innerHTML = `

处理记录加载失败,请稍后重试。

`; window.injectIcons?.(list); } } } function closeProcessingRecords() { const modal = document.querySelector("#documentProcessingRecordsModal"); if (modal) modal.hidden = true; document.body.style.overflow = ""; } function renderMonitoring(monitoring, documents, documentStatistics = null) { state.monitoring = monitoring; const documentTotal = Number( documentStatistics?.total_documents ?? documents.length ); const extractedFieldTotal = Number( documentStatistics?.extracted_fields ?? 0 ); const overview = monitoring.overview || DEMO_MONITORING.overview; const sources = monitoring.sources?.length ? monitoring.sources.slice() : DEMO_MONITORING.sources.slice(); const hasFacilitySource = sources.some((source) => String(source.source_type || "").toLowerCase().includes("facility") ); if (!hasFacilitySource) { sources.push({ id: "fallback-facilities", name: "通防设施工况", source_type: "facility", status: "online", point_count: overview.facility_count || DEMO_MONITORING.overview.facility_count, latency_ms: 2400 }); } const sourceGrid = document.querySelector("#view-data .source-grid"); if (sourceGrid) { const cards = sources.slice(0, 3).map((source) => { const visual = sourceVisual(source); const statusText = source.status === "online" ? "在线" : "异常"; const isVideoPlatform = String(source.source_type || "").toLowerCase().includes("video") || String(source.name || "").includes("视频"); const isFacilityStatus = String(source.source_type || "").toLowerCase().includes("facility") || String(source.name || "").includes("通防设施"); const isSafetyMonitoring = String(source.source_type || "").toLowerCase().includes("monitoring") || String(source.source_type || "").toLowerCase().includes("security") || String(source.name || "").includes("安全监控"); const pointUnit = String(source.source_type || "").toLowerCase().includes("facility") ? "台" : "点"; const tag = isVideoPlatform || isFacilityStatus || isSafetyMonitoring ? "button" : "article"; const attrs = isVideoPlatform ? 'type="button" data-video-platform aria-label="打开工业视频平台"' : isFacilityStatus ? 'type="button" data-facility-status aria-label="打开通防设施工况"' : isSafetyMonitoring ? 'type="button" data-safety-monitoring aria-label="打开 KJ 安全监控系统"' : ""; const className = isVideoPlatform || isFacilityStatus || isSafetyMonitoring ? "source-card source-card-button" : "source-card"; return ` <${tag} class="${className}" ${attrs}>
${statusText}
${escapeHtml(source.name)}

${escapeHtml(visual.description)}

${formatNumber(source.point_count)} ${pointUnit}延迟 ${(Number(source.latency_ms || 0) / 1000).toFixed(1)}s
`; }); cards.push(` `); sourceGrid.innerHTML = cards.join(""); if (typeof window.injectIcons === "function") window.injectIcons(sourceGrid); sourceGrid.querySelectorAll("[data-video-platform]").forEach((button) => { button.addEventListener("click", () => openVideoPlatform()); }); sourceGrid.querySelector("[data-safety-monitoring]")?.addEventListener("click", () => openSafetyMonitoring()); sourceGrid.querySelector("[data-facility-status]")?.addEventListener("click", () => window.navigateTo?.("facility-status")); sourceGrid.querySelector("[data-open-business]")?.addEventListener("click", () => window.navigateTo?.("business-data")); } const toolbar = document.querySelector("#view-data .toolbar-copy p"); if (toolbar) { toolbar.textContent = `当前接入 ${formatNumber(overview.data_source_count)} 类数据源,在线 ${formatNumber(overview.online_source_count)} 类,告警 ${formatNumber(overview.open_alarm_count)} 条`; } const objectValues = { sensors: overview.sensor_count, facilities: overview.facility_count }; document.querySelectorAll("#view-data .object-card").forEach((card) => { const label = card.querySelector("strong")?.textContent?.trim(); if (label === "传感器") card.querySelector("b").textContent = formatNumber(objectValues.sensors); if (label === "通风设施") card.querySelector("b").textContent = formatNumber(objectValues.facilities); }); } function buildFacilityCategories(monitoring) { const sensors = monitoring.sensors || []; const facilities = monitoring.facilities || []; const overview = monitoring.overview || {}; return FACILITY_CATEGORIES.map((category) => { let actualTotal = 0; let actualAlarms = 0; let actualOffline = 0; let records = []; if (category.key === "sensors") { actualTotal = Number(overview.sensor_count || sensors.length || 0); actualAlarms = sensors.filter((item) => item.status === "alarm").length; actualOffline = sensors.filter((item) => item.status === "offline").length; records = sensors.map((item) => ({ name: item.name, location: item.location || "未配置地点", status: item.status || "normal" })); } else { records = facilities .filter((facility) => category.keywords.some((keyword) => String(facility.facility_type || "").includes(keyword) ) ) .map((facility) => ({ name: facility.name, location: facility.location || "未配置地点", status: facility.status || "normal" })); actualTotal = records.length; actualAlarms = records.filter((item) => item.status === "warning" || item.status === "alarm").length; actualOffline = records.filter((item) => item.status === "offline").length; } return { ...category, total: Math.max(actualTotal, category.total), alarms: Math.max(actualAlarms, category.alarms), offline: Math.max(actualOffline, category.offline), records }; }); } function renderFacilityStatus(monitoring) { state.facilityMonitoring = monitoring; const categories = buildFacilityCategories(monitoring); const summaryGrid = document.querySelector("#facilitySummaryGrid"); const categoryGrid = document.querySelector("#facilityCategoryGrid"); if (!summaryGrid || !categoryGrid) return; const total = categories.reduce((sum, item) => sum + item.total, 0); const alarms = categories.reduce((sum, item) => sum + item.alarms, 0); const offline = categories.reduce((sum, item) => sum + item.offline, 0); const onlineRate = total ? ((total - offline) / total * 100).toFixed(1) : "0.0"; const overviewText = document.querySelector("#facilityOverviewText"); if (overviewText) { overviewText.textContent = `当前共纳管 ${formatNumber(total)} 台设备,在线率 ${onlineRate}%,${alarms ? `存在 ${alarms} 项报警` : "暂无报警"},${offline ? `${offline} 台设备处于断开状态` : "设备连接状态正常"}。`; } const categoryCount = document.querySelector("#facilityCategoryCount"); if (categoryCount) categoryCount.textContent = `${categories.length} 类`; summaryGrid.innerHTML = `
设备总数${formatNumber(total)}全部通防设备
设备在线率${onlineRate}%${formatNumber(total - offline)} 台在线
报警设备${formatNumber(alarms)}${alarms ? "需要及时确认" : "暂无报警"}
断开设备${formatNumber(offline)}${offline ? "请检查连接状态" : "连接状态正常"}
`; if (typeof window.injectIcons === "function") window.injectIcons(summaryGrid); categoryGrid.innerHTML = categories .map( (category) => ` ` ) .join(""); if (typeof window.injectIcons === "function") window.injectIcons(categoryGrid); categoryGrid.querySelectorAll("[data-facility-category]").forEach((button) => { button.addEventListener("click", () => { categoryGrid.querySelectorAll("[data-facility-category]").forEach((item) => { item.classList.toggle("active", item === button); }); const category = categories.find((item) => item.key === button.dataset.facilityCategory); renderFacilityDetail(category); }); }); renderFacilityDetail(categories[0]); } function renderFacilityDetail(category) { const panel = document.querySelector("#facilityDetailPanel"); if (!panel || !category) return; const records = category.records.slice(0, 12); while (records.length < Math.min(category.total, 12)) { const index = records.length; const status = index < category.offline ? "offline" : index < category.offline + category.alarms ? "warning" : "normal"; records.push({ name: `${category.name} ${String(index + 1).padStart(2, "0")}`, location: index % 2 ? "北翼回风巷" : "22煤一盘区", status }); } const statusLabel = { normal: "正常", warning: "报警", alarm: "报警", offline: "断开" }; const onlineCount = Math.max(category.total - category.offline, 0); const onlineRate = category.total ? (onlineCount / category.total * 100).toFixed(1) : "0.0"; panel.innerHTML = `
设备明细

${escapeHtml(category.name)}

在线率 ${onlineRate}% · 报警 ${category.alarms} · 断开 ${category.offline}

${formatNumber(category.total)} 台
${records .map( (item) => `
${escapeHtml(item.name)}

${escapeHtml(item.location)}

${statusLabel[item.status] || "正常"}
` ) .join("")}
`; if (typeof window.injectIcons === "function") window.injectIcons(panel); } function installFacilityActions() { document.querySelector("[data-refresh-facility]")?.addEventListener("click", async () => { const result = await loadMonitoring(); renderFacilityStatus(result.data); window.showToast?.("通防设施工况已刷新"); }); } function safetyStatusForValue(metric, value) { const number = Number(value); if (!Number.isFinite(number)) return "offline"; if (metric.thresholdMode === "below") { if (number <= Number(metric.alarmThreshold)) return "alarm"; if (number <= Number(metric.warningThreshold)) return "warning"; return "normal"; } if (number >= Number(metric.alarmThreshold)) return "alarm"; if (number >= Number(metric.warningThreshold)) return "warning"; return "normal"; } function formatSafetyValue(metric, value) { const number = Number(value); if (!Number.isFinite(number)) return "--"; const decimals = metric.key === "airflow" ? 0 : metric.key === "temperature" || metric.key === "co" ? 1 : 2; return number.toLocaleString("zh-CN", { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); } function buildDemoMetricSeries(metric) { const values = Array.from({ length: 48 }, (_, index) => { const wave = Math.sin(index / 4.2 + metric.key.length) * metric.amplitude; const secondary = Math.cos(index / 7.4 + metric.baseline) * metric.amplitude * 0.35; const noise = Math.sin(index * 1.73 + metric.key.length * 1.8) * metric.noise; return metric.baseline + wave + secondary + noise; }); if (metric.key === "gas") { values[47] = 0.86; values[46] = 0.78; values[45] = 0.7; } else if (metric.key === "airflow") { values[47] = 1286; values[46] = 1302; values[45] = 1328; } else if (metric.key === "dust") { values[47] = 4.2; values[46] = 3.82; values[45] = 3.65; } else if (metric.key === "air_speed") { values[47] = 0.22; values[46] = 0.27; values[45] = 0.31; } else { values[47] = metric.baseline + metric.amplitude * 0.28; } return values.map((value) => Number(value.toFixed(3))); } function calculateLinearForecast(values, count = 8) { const sample = values.slice(-Math.min(values.length, 16)).map(Number).filter(Number.isFinite); if (sample.length < 2) return Array.from({ length: count }, () => sample[0] || 0); const meanX = (sample.length - 1) / 2; const meanY = sample.reduce((sum, value) => sum + value, 0) / sample.length; let numerator = 0; let denominator = 0; sample.forEach((value, index) => { numerator += (index - meanX) * (value - meanY); denominator += (index - meanX) ** 2; }); const slope = denominator ? numerator / denominator : 0; return Array.from({ length: count }, (_, index) => Number((sample[sample.length - 1] + slope * (index + 1)).toFixed(3)) ); } function buildSafetyMetrics(monitoring) { const sensors = monitoring.sensors || []; return SAFETY_METRIC_DEFS.map((definition) => { const sensor = sensors.find( (item) => String(item.sensor_type || "").toLowerCase() === definition.sensorType || String(item.code || "").toLowerCase().includes(definition.key) ); const readings = sensor ? state.safetyReadings.get(String(sensor.id)) : null; let values = Array.isArray(readings) && readings.length > 2 ? readings.map((item) => Number(item.value)).filter(Number.isFinite) : buildDemoMetricSeries(definition); if (values.length < 3) values = buildDemoMetricSeries(definition); const sensorValue = Number(sensor?.last_value); if (Number.isFinite(sensorValue)) { values[values.length - 1] = sensorValue; values[values.length - 2] = values[values.length - 2] * 0.35 + sensorValue * 0.65; } const currentValue = values[values.length - 1]; const forecast = calculateLinearForecast(values); const predictedValue = forecast[forecast.length - 1]; const status = sensor?.status === "offline" ? "offline" : safetyStatusForValue(definition, currentValue); const distance = definition.thresholdMode === "below" ? (currentValue - definition.alarmThreshold) / Math.max(definition.warningThreshold - definition.alarmThreshold, 0.001) : (definition.alarmThreshold - currentValue) / Math.max(definition.alarmThreshold - definition.warningThreshold, 0.001); const baseRisk = status === "alarm" ? 88 : status === "warning" ? 66 : status === "offline" ? 78 : 24; const riskScore = Math.max(8, Math.min(98, Math.round(baseRisk + (1 - Math.max(0, Math.min(1, distance))) * 8))); return { ...definition, sensor, unit: sensor?.unit || definition.unit, location: sensor?.location || "全矿监测网络", values, forecast, currentValue, predictedValue, predictedStatus: safetyStatusForValue(definition, predictedValue), status, riskScore }; }); } function getSafetySource(monitoring) { return ( (monitoring.sources || []).find((source) => { const type = String(source.source_type || "").toLowerCase(); return type.includes("monitoring") || type.includes("security") || String(source.name || "").includes("安全监控"); }) || DEMO_MONITORING.sources[0] ); } function buildSparkline(values, tone = "teal") { const series = values.slice(-18).map(Number).filter(Number.isFinite); if (series.length < 2) return ""; const min = Math.min(...series); const max = Math.max(...series); const range = max - min || 1; const points = series .map((value, index) => { const x = (index / (series.length - 1)) * 84 + 2; const y = 28 - ((value - min) / range) * 24; return `${x.toFixed(1)},${y.toFixed(1)}`; }) .join(" "); return ``; } function buildSmoothPath(points, smoothing = 0.16) { if (!points.length) return ""; if (points.length === 1) return `M ${points[0].x} ${points[0].y}`; return points.reduce((path, point, index) => { if (index === 0) return `M ${point.x} ${point.y}`; const previous = points[index - 1]; const beforePrevious = points[index - 2] || previous; const next = points[index + 1] || point; const control1X = previous.x + (point.x - beforePrevious.x) * smoothing; const control1Y = previous.y + (point.y - beforePrevious.y) * smoothing; const control2X = point.x - (next.x - previous.x) * smoothing; const control2Y = point.y - (next.y - previous.y) * smoothing; return `${path} C ${control1X.toFixed(1)} ${control1Y.toFixed(1)}, ${control2X.toFixed(1)} ${control2Y.toFixed(1)}, ${point.x} ${point.y}`; }, ""); } function renderSafetyChart(metric) { const chart = document.querySelector("#safetyMonitoringChart"); if (!chart || !metric) return; const range = Math.min(state.safetyRange, metric.values.length); const values = metric.values.slice(-range); const forecast = metric.forecast || []; const svgWidth = 820; const svgHeight = 290; const margin = { top: 22, right: 28, bottom: 38, left: 62 }; const plotWidth = svgWidth - margin.left - margin.right; const plotHeight = svgHeight - margin.top - margin.bottom; const allValues = [...values, ...forecast, metric.warningThreshold, metric.alarmThreshold].map(Number).filter(Number.isFinite); let minimum = Math.min(...allValues); let maximum = Math.max(...allValues); if (maximum === minimum) { maximum += 1; minimum -= 1; } const padding = (maximum - minimum) * 0.16; minimum -= padding; maximum += padding; const totalSteps = Math.max(values.length + forecast.length - 1, 1); const xFor = (index) => margin.left + (index / totalSteps) * plotWidth; const yFor = (value) => margin.top + (1 - (Number(value) - minimum) / (maximum - minimum)) * plotHeight; const actualPoints = values.map((value, index) => ({ x: Number(xFor(index).toFixed(1)), y: Number(yFor(value).toFixed(1)) })); const forecastPoints = [ actualPoints[actualPoints.length - 1], ...forecast.map((value, index) => ({ x: Number(xFor(values.length + index).toFixed(1)), y: Number(yFor(value).toFixed(1)) })) ]; const actualPath = buildSmoothPath(actualPoints); const forecastPath = buildSmoothPath(forecastPoints); const areaPath = `${actualPath} L ${actualPoints[actualPoints.length - 1].x} ${margin.top + plotHeight} L ${actualPoints[0].x} ${margin.top + plotHeight} Z`; const gridLines = Array.from({ length: 5 }, (_, index) => { const y = margin.top + (index / 4) * plotHeight; const value = maximum - (index / 4) * (maximum - minimum); return `${formatSafetyValue(metric, value)}`; }).join(""); const thresholdLines = [ { value: metric.warningThreshold, className: "warning", label: "预警" }, { value: metric.alarmThreshold, className: "alarm", label: "报警" } ] .filter((item) => Number.isFinite(Number(item.value))) .map((item) => { const y = yFor(item.value); return `${item.label} ${formatSafetyValue(metric, item.value)}`; }) .join(""); const currentPoint = actualPoints[actualPoints.length - 1]; const gradientId = `safety-gradient-${metric.key}`; chart.innerHTML = ` ${gridLines} ${thresholdLines} ${state.safetyRange} 小时前 当前 预测 +2h `; } function renderSafetyInsights(metric) { if (!metric) return; const riskRing = document.querySelector("#safetyRiskRing"); const riskScore = document.querySelector("#safetyRiskScore"); const riskLabel = document.querySelector("#safetyRiskLabel"); const riskSummary = document.querySelector("#safetyRiskSummary"); const predictionText = document.querySelector("#safetyPredictionText"); const confidence = document.querySelector("#safetyPredictionConfidence"); const recommendations = document.querySelector("#safetyRecommendations"); if (riskRing) { riskRing.dataset.level = metric.status; riskRing.style.setProperty("--risk-progress", `${metric.riskScore}%`); } if (riskScore) riskScore.textContent = String(metric.riskScore); const labels = { normal: "运行平稳", warning: "趋势关注", alarm: "超限预警", offline: "测点离线" }; if (riskLabel) { riskLabel.textContent = labels[metric.status] || "运行平稳"; riskLabel.className = `safety-risk-label ${metric.status}`; } if (riskSummary) { if (metric.status === "alarm") { riskSummary.textContent = `${metric.name}当前值 ${formatSafetyValue(metric, metric.currentValue)} ${metric.unit},已越过报警阈值,需要立即处置。`; } else if (metric.status === "warning") { riskSummary.textContent = `${metric.name}当前值靠近控制边界,未来 2 小时仍存在继续偏离的风险。`; } else { riskSummary.textContent = `${metric.name}处于稳定区间,当前阈值余量充足。`; } } const change = metric.currentValue ? ((metric.predictedValue - metric.currentValue) / Math.abs(metric.currentValue)) * 100 : 0; const direction = Math.abs(change) < 0.4 ? "基本持平" : change > 0 ? "缓慢上升" : "缓慢下降"; if (predictionText) { predictionText.textContent = `基于近 ${metric.values.length} 个监测点执行趋势外推,预计未来 2 小时${direction},预测值约 ${formatSafetyValue(metric, metric.predictedValue)} ${metric.unit}。`; } if (confidence) { const value = Math.min(98, Math.round(88 + Math.min(metric.values.length, 48) / 6)); confidence.textContent = `置信度 ${value}%`; } if (recommendations) { recommendations.innerHTML = metric.recommendations .map( (item, index) => `
${String(index + 1).padStart(2, "0")}

${escapeHtml(item)}

` ) .join(""); } } function renderSafetyMetricTabs(metrics) { const container = document.querySelector("#safetyMetricTabs"); if (!container) return; container.innerHTML = metrics .map( (metric) => ` ` ) .join(""); container.querySelectorAll("[data-safety-metric]").forEach((button) => { button.addEventListener("click", () => { state.safetySelectedMetric = button.dataset.safetyMetric; renderSafetyMonitoring(state.monitoring); }); }); if (typeof window.injectIcons === "function") window.injectIcons(container); } function renderSafetySensorList(metrics) { const container = document.querySelector("#safetySensorList"); const count = document.querySelector("#safetySensorCount"); if (!container) return; if (count) count.textContent = `${metrics.length} 类参数`; const statusLabel = { normal: "正常", warning: "关注", alarm: "报警", offline: "离线" }; container.innerHTML = metrics .map( (metric) => ` ` ) .join(""); container.querySelectorAll("[data-safety-sensor-metric]").forEach((button) => { button.addEventListener("click", () => { state.safetySelectedMetric = button.dataset.safetySensorMetric; renderSafetyMonitoring(state.monitoring); }); }); if (typeof window.injectIcons === "function") window.injectIcons(container); } function renderSafetyAlarmList(monitoring) { const container = document.querySelector("#safetyAlarmList"); const count = document.querySelector("#safetyAlarmCount"); if (!container) return; const alarms = monitoring?.alarms?.length ? monitoring.alarms : DEMO_MONITORING.alarms; if (count) count.textContent = `${alarms.length} 条`; const levelLabel = { high: "高风险", medium: "中风险", low: "低风险" }; container.innerHTML = alarms.length ? alarms .slice(0, 6) .map( (alarm) => `
${levelLabel[alarm.level] || "告警"}
${escapeHtml(alarm.title || "监测数据异常")}

${escapeHtml(alarm.content || `当前值 ${formatSafetyValue({ key: "gas", name: "监测值" }, alarm.value)} · 阈值 ${formatSafetyValue({ key: "gas", name: "阈值" }, alarm.threshold)}`)}

` ) .join("") : '

当前没有未处置告警

'; if (typeof window.injectIcons === "function") window.injectIcons(container); } function renderSafetyMonitoring(monitoring) { if (!monitoring) return; state.monitoring = monitoring; const summaryGrid = document.querySelector("#safetyMonitoringSummary"); if (!summaryGrid) return; const metrics = buildSafetyMetrics(monitoring); state.safetyMetrics = metrics; const source = getSafetySource(monitoring); const overview = monitoring.overview || DEMO_MONITORING.overview; const totalPoints = Number(source.point_count || DEMO_MONITORING.sources[0].point_count || 0); const onlineSensors = Number(overview.online_sensor_count || metrics.reduce((sum, item) => sum + item.onlineCount, 0)); const sensorTotal = Number(overview.sensor_count || metrics.reduce((sum, item) => sum + item.pointCount, 0)); const onlineRate = sensorTotal ? ((onlineSensors / sensorTotal) * 100).toFixed(1) : "0.0"; const abnormalCount = metrics.filter((metric) => metric.status !== "normal").length; const latency = Number(source.latency_ms || 2400) / 1000; const selected = metrics.find((metric) => metric.key === state.safetySelectedMetric) || metrics[0]; state.safetySelectedMetric = selected?.key || "gas"; summaryGrid.innerHTML = `
接入测点${formatNumber(totalPoints)}

安全监控系统接入总量

在线率${onlineRate}%

${formatNumber(onlineSensors)} 个测点在线

异常参数${formatNumber(abnormalCount)}

存在报警或关注趋势

数据延迟${latency.toFixed(1)}s

${escapeHtml(source.protocol || "OPC UA")} 实时链路

`; const toolbarText = document.querySelector("#safetyMonitoringToolbarText"); if (toolbarText) { toolbarText.textContent = `${source.protocol || "OPC UA"} · ${source.endpoint || "实时数据链路"} · 覆盖瓦斯、风量、CO、温度、粉尘、风速`; } const overviewText = document.querySelector("#safetyOverviewText"); if (overviewText) { overviewText.textContent = `已接入 ${formatNumber(totalPoints)} 个安全监测点,在线率 ${onlineRate}%,当前识别到 ${formatNumber(abnormalCount)} 类参数需要关注。`; } const sourceName = document.querySelector("#safetyOverviewSource"); if (sourceName) sourceName.textContent = source.code || source.name || "KJ-SAFETY"; const sourceProtocol = document.querySelector("#safetyOverviewProtocol"); if (sourceProtocol) sourceProtocol.textContent = `${source.protocol || "OPC UA"} · 实时接入`; renderSafetyMetricTabs(metrics); if (selected) { const title = document.querySelector("#safetyChartTitle"); const subtitle = document.querySelector("#safetyChartSubtitle"); const currentValue = document.querySelector("#safetyCurrentValue"); const currentUnit = document.querySelector("#safetyCurrentUnit"); if (title) title.textContent = `${selected.name}趋势`; if (subtitle) subtitle.textContent = `${selected.location} · 阈值 ${formatSafetyValue(selected, selected.warningThreshold)} / ${formatSafetyValue(selected, selected.alarmThreshold)} ${selected.unit}`; if (currentValue) currentValue.textContent = formatSafetyValue(selected, selected.currentValue); if (currentUnit) currentUnit.textContent = selected.unit; renderSafetyChart(selected); renderSafetyInsights(selected); } renderSafetySensorList(metrics); renderSafetyAlarmList(monitoring); if (typeof window.injectIcons === "function") window.injectIcons(summaryGrid); } function installSafetyMonitoringActions() { document.querySelector("[data-refresh-safety-monitoring]")?.addEventListener("click", async () => { const result = await loadMonitoring(); renderSafetyMonitoring(result.data); await hydrateSafetyReadings(result.data); window.showToast?.("安全监控数据已刷新"); }); document.querySelectorAll("[data-safety-range]").forEach((button) => { button.addEventListener("click", () => { state.safetyRange = Number(button.dataset.safetyRange || 24); document.querySelectorAll("[data-safety-range]").forEach((item) => item.classList.toggle("active", item === button)); const metric = state.safetyMetrics.find((item) => item.key === state.safetySelectedMetric); renderSafetyChart(metric); }); }); } function videoStatusLabel(status) { return status === "online" ? "在线" : status === "offline" ? "离线" : "异常"; } function videoSourceCount(monitoring) { const source = (monitoring?.sources || []).find((item) => String(item.source_type || "").toLowerCase().includes("video") ); return Number(source?.point_count || 218); } function renderVideoSummary(monitoring) { const grid = document.querySelector("#videoSummaryGrid"); if (!grid) return; const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door; const total = videoSourceCount(monitoring); const configured = VIDEO_CHANNELS.length; const online = VIDEO_CHANNELS.filter((item) => item.status === "online").length; grid.innerHTML = `
视频通道${formatNumber(total)}

视频平台接入总量

已配置通道${formatNumber(configured)}

当前页面可管理通道

在线通道${formatNumber(online)}

视频连接状态正常

识别事件${formatNumber((state.monitoring?.alarms || []).length)}

当前智能分析事件

`; window.injectIcons?.(grid); const count = document.querySelector("#videoChannelCount"); if (count) count.textContent = `${formatNumber(configured)} / ${formatNumber(total)} 路`; const toolbar = document.querySelector("#videoPlatformToolbarText"); if (toolbar) { toolbar.textContent = `${moduleData.title}|共接入 ${formatNumber(total)} 路视频,当前已配置 ${formatNumber(configured)} 路,在线 ${formatNumber(online)} 路`; } } function renderVideoStatusOverview() { const container = document.querySelector("#videoStatusOverview"); const title = document.querySelector("#videoStatusTitle"); if (!container) return; const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door; if (title) title.textContent = moduleData.statusTitle; container.innerHTML = moduleData.summary .map( ([label, value, suffix], index) => `
${formatNumber(value)} ${escapeHtml(label)} ${escapeHtml(suffix)}
` ) .join(""); } function filteredVideoChannels() { const moduleGroup = { door: "风门区域", "local-fan": "局扇区域", "main-fan": "主扇区域" }[state.videoModule] || "风门区域"; const keyword = state.videoSearch.trim().toLowerCase(); return VIDEO_CHANNELS.filter((channel) => { if (channel.group !== moduleGroup) return false; if (!keyword) return true; return `${channel.name} ${channel.location}`.toLowerCase().includes(keyword); }); } function renderVideoDeviceTree() { const tree = document.querySelector("#videoDeviceTree"); if (!tree) return; const channels = filteredVideoChannels(); const count = document.querySelector("#videoChannelCount"); if (count) { count.textContent = `${formatNumber(channels.length)} / ${formatNumber(videoSourceCount(state.monitoring))} 路`; } if (!channels.length) { tree.innerHTML = `

没有匹配的视频通道

`; window.injectIcons?.(tree); return; } tree.innerHTML = `
${channels .map( (channel) => ` ` ) .join("")}
`; window.injectIcons?.(tree); tree.querySelectorAll("[data-video-channel]").forEach((button) => { button.addEventListener("click", () => { state.videoSelectedChannel = button.dataset.videoChannel; renderVideoPlatform(state.monitoring); }); }); } function videoWallChannels() { const all = VIDEO_CHANNELS.slice(); const selectedIndex = all.findIndex((item) => item.id === state.videoSelectedChannel); if (selectedIndex > 0) { const [selected] = all.splice(selectedIndex, 1); all.unshift(selected); } return all.slice(0, state.videoLayout); } function renderVideoWall() { const wall = document.querySelector("#videoWall"); const title = document.querySelector("#videoWallTitle"); if (!wall) return; wall.className = `video-wall layout-${state.videoLayout}`; const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door; const layoutNames = { 1: "单画面监控", 2: "双画面监控", 4: "四分屏监控", 6: "六分屏监控", 9: "九分屏监控" }; if (title) title.textContent = `${moduleData.title} · ${layoutNames[state.videoLayout] || "实时监控"}`; const channels = videoWallChannels(); wall.innerHTML = channels .map( (channel) => `
${escapeHtml(channel.name)} 等待视频流接入
${videoStatusLabel(channel.status)}
${escapeHtml(channel.name)}${escapeHtml(channel.location)}
` ) .join(""); window.injectIcons?.(wall); wall.querySelectorAll("[data-video-action]").forEach((button) => { button.addEventListener("click", (event) => { event.stopPropagation(); handleVideoAction(button.dataset.videoAction, button.dataset.channel); }); }); } function handleVideoAction(action, channelId) { const channel = VIDEO_CHANNELS.find((item) => item.id === channelId); if (!channel) return; if (action === "fullscreen") { document.querySelector(`[data-video-tile="${channelId}"]`)?.requestFullscreen?.(); return; } const labels = { play: "播放/暂停", mute: "静音", snapshot: "抓图" }; window.showToast?.( `${channel.name} 已执行${labels[action] || "操作"};当前视频流接口尚未配置`, "warning" ); } function renderVideoEvents(monitoring) { const list = document.querySelector("#videoEventList"); const count = document.querySelector("#videoEventCount"); if (!list) return; const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door; const logs = moduleData.logs; if (count) count.textContent = `${formatNumber(logs.length)} 条`; list.innerHTML = logs .map( ([time, message, tone]) => `

${escapeHtml(message)}

` ) .join(""); } function renderVideoThumbnails() { const grid = document.querySelector("#videoThumbnailGrid"); const count = document.querySelector("#videoThumbnailCount"); if (!grid) return; const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door; const snapshotTypes = moduleData.snapshotTypes || ["识别抓拍"]; const channels = VIDEO_CHANNELS.slice(0, 8); if (count) count.textContent = `${formatNumber(channels.length)} 张`; grid.innerHTML = channels .map( (channel, index) => `
${escapeHtml(channel.name)}${escapeHtml(snapshotTypes[index % snapshotTypes.length])}
` ) .join(""); window.injectIcons?.(grid); } function renderVideoFilterOptions() { const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door; const typeLabel = document.querySelector("#videoRecordFilterLabel"); const typeSelect = document.querySelector("#videoRecordType"); const scopeLabel = document.querySelector("#videoScopeFilterLabel"); const scopeSelect = document.querySelector("#videoDeviceScope"); if (typeLabel) typeLabel.textContent = moduleData.typeFilterLabel; if (typeSelect) { typeSelect.innerHTML = moduleData.typeFilterOptions .map((option) => ``) .join(""); } if (scopeLabel) scopeLabel.textContent = moduleData.scopeFilterLabel; if (scopeSelect) { scopeSelect.innerHTML = moduleData.scopeFilterOptions .map((option) => ``) .join(""); } } function renderVideoRecords() { const tabs = document.querySelector("#videoRecordTabs"); const table = document.querySelector("#videoRecordsTable"); const title = document.querySelector("#videoRecordTitle"); if (!tabs || !table) return; const moduleData = VIDEO_MODULES[state.videoModule] || VIDEO_MODULES.door; const activeIndex = Math.min(state.videoRecordTab, moduleData.tabs.length - 1); state.videoRecordTab = activeIndex; if (title) title.textContent = moduleData.tabs[activeIndex]; tabs.innerHTML = moduleData.tabs .map( (label, index) => `` ) .join(""); tabs.querySelectorAll("[data-video-record-tab]").forEach((button) => { button.addEventListener("click", () => { state.videoRecordTab = Number(button.dataset.videoRecordTab); renderVideoRecords(); }); }); table.innerHTML = ` ${moduleData.columns.map((column) => ``).join("")} ${moduleData.rows .map( (row) => `${row.map((value) => ``).join("")}` ) .join("")}
${escapeHtml(column)}
${escapeHtml(value)}
`; } function syncVideoPanelHeights() { const devicePanel = document.querySelector(".video-device-panel"); const wallPanel = document.querySelector(".video-wall-panel"); if (!devicePanel || !wallPanel) return; if (window.innerWidth <= 1180) { devicePanel.style.height = ""; wallPanel.style.height = ""; return; } devicePanel.style.height = "auto"; wallPanel.style.height = "auto"; const targetHeight = Math.ceil(wallPanel.getBoundingClientRect().height); if (!targetHeight) return; devicePanel.style.height = `${targetHeight}px`; wallPanel.style.height = `${targetHeight}px`; } function renderVideoPlatform(monitoring = state.monitoring) { state.monitoring = monitoring || state.monitoring; renderVideoSummary(state.monitoring); renderVideoStatusOverview(); renderVideoDeviceTree(); renderVideoWall(); renderVideoEvents(state.monitoring); renderVideoThumbnails(); renderVideoRecords(); renderVideoFilterOptions(); document.querySelectorAll("[data-video-module]").forEach((button) => { button.classList.toggle("active", button.dataset.videoModule === state.videoModule); }); const note = document.querySelector("#videoStreamNote"); if (note) { note.textContent = "当前为原生视频管理工作台;真实视频流地址接入后可直接在当前分屏中播放。"; } window.requestAnimationFrame(syncVideoPanelHeights); } function installVideoPlatformActions() { document.querySelector("[data-refresh-video-platform]")?.addEventListener("click", () => { renderVideoPlatform(state.monitoring); window.showToast?.("视频通道状态已刷新"); }); document.querySelectorAll("[data-video-module]").forEach((button) => { button.addEventListener("click", () => { state.videoModule = button.dataset.videoModule || "door"; state.videoRecordTab = 0; state.videoSelectedChannel = VIDEO_MODULES[state.videoModule]?.defaultChannel || state.videoSelectedChannel; renderVideoPlatform(state.monitoring); }); }); document.querySelectorAll("[data-video-layout]").forEach((button) => { button.addEventListener("click", () => { state.videoLayout = Number(button.dataset.videoLayout || 4); document.querySelectorAll("[data-video-layout]").forEach((item) => { item.classList.toggle("active", item === button); }); renderVideoWall(); window.requestAnimationFrame(syncVideoPanelHeights); }); }); const search = document.querySelector("#videoChannelSearch"); search?.addEventListener("input", () => { state.videoSearch = search.value.trim(); renderVideoDeviceTree(); }); document.querySelector("[data-video-search]")?.addEventListener("click", () => { renderVideoThumbnails(); renderVideoRecords(); window.showToast?.("视频记录检索完成"); }); document.querySelector("[data-video-reset]")?.addEventListener("click", () => { state.videoSearch = ""; if (search) search.value = ""; state.videoRecordTab = 0; renderVideoPlatform(state.monitoring); window.showToast?.("筛选条件已重置"); }); document.querySelectorAll("[data-video-quick-range]").forEach((button) => { button.addEventListener("click", () => { document.querySelectorAll("[data-video-quick-range]").forEach((item) => item.classList.toggle("active", item === button)); window.showToast?.(button.dataset.videoQuickRange === "today" ? "已切换为今日范围" : "已切换为近7天范围"); }); }); window.addEventListener("resize", () => { window.requestAnimationFrame(syncVideoPanelHeights); }); } function openVideoPlatform() { window.navigateTo?.("video-platform"); renderVideoPlatform(state.monitoring); } function installDocumentExport() { const button = document.querySelector("[data-export-documents]"); if (!button || button.dataset.exportBound) return; button.dataset.exportBound = "true"; button.addEventListener("click", async () => { if (!state.apiAvailable) { window.showToast?.("后端不可用,暂时无法导出资料清单", "warning"); return; } const original = button.innerHTML; button.disabled = true; button.innerHTML = '正在导出'; window.injectIcons?.(button); try { const params = new URLSearchParams(); params.set("mine_id", state.mine.id); if (state.documentCategory && state.documentCategory !== "all") { params.set("category", state.documentCategory); } const keyword = document .querySelector("#businessDocumentSearch") ?.value.trim(); if (keyword) params.set("keyword", keyword); const response = await fetch( `${API_BASE}/documents/export?${params.toString()}` ); if (!response.ok) { let message = `HTTP ${response.status}`; try { const payload = await response.json(); message = payload?.message || payload?.detail || message; } catch { // Keep the HTTP status when the response is not JSON. } throw new Error(message); } const blob = await response.blob(); const disposition = response.headers.get("Content-Disposition") || ""; const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]; const filename = encodedName ? decodeURIComponent(encodedName) : `业务资料清单_${new Date().toISOString().slice(0, 10)}.xlsx`; const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 1000); window.showToast?.("资料清单已导出"); } catch (error) { window.showToast?.(`导出失败:${error?.message || error}`, "warning"); } finally { button.disabled = false; button.innerHTML = original; window.injectIcons?.(button); } }); } async function hydrateSafetyReadings(monitoring) { const sensors = monitoring?.sensors || []; if (!state.apiAvailable || !sensors.length) { state.safetyLoaded = true; return; } const entries = await Promise.all( sensors.map(async (sensor) => { try { const readings = await request(`/monitoring/sensors/${encodeURIComponent(sensor.id)}/readings?limit=48`, { timeoutMs: 3200 }); return [ String(sensor.id), (readings || []).slice().sort((left, right) => new Date(left.measured_at) - new Date(right.measured_at)) ]; } catch { return [String(sensor.id), []]; } }) ); state.safetyReadings = new Map(entries); state.safetyLoaded = true; if (state.monitoring) renderSafetyMonitoring(state.monitoring); } function openSafetyMonitoring() { window.navigateTo?.("safety-monitoring"); if (!state.safetyLoaded) hydrateSafetyReadings(state.monitoring).catch(() => {}); } function renderKnowledgeGraph(graph) { const canvas = document.querySelector(".graph-canvas"); const svg = canvas?.querySelector("svg"); if (!svg || !graph?.nodes?.length) return; const nodeMap = new Map(graph.nodes.map((node) => [node.id, node])); const links = (graph.edges || []) .filter((edge) => nodeMap.has(edge.source) && nodeMap.has(edge.target)) .map((edge) => { const source = nodeMap.get(edge.source); const target = nodeMap.get(edge.target); return ``; }) .join(""); const nodes = graph.nodes .map((node) => { const center = node.node_type === "risk"; const radius = center ? 48 : 34; const className = center ? "graph-center" : node.node_type === "evidence" ? "graph-node amber-node" : "graph-node"; const value = Number(node.value || 0); return ` ${escapeHtml(`${node.label} ${node.sub_label || ""} · ${value} 条关联知识`)} ${escapeHtml(node.label || "")} ${escapeHtml(node.sub_label || "")} `; }) .join(""); svg.innerHTML = ` ${links} ${nodes} `; } function renderKnowledge(payload) { const items = Array.isArray(payload) ? payload : payload?.catalog?.items || payload?.items || []; const overview = Array.isArray(payload) ? null : payload?.overview || null; const graph = Array.isArray(payload) ? null : payload?.graph || null; const count = Number(overview?.total_notes || items.length || DEMO_KNOWLEDGE.length); const heroStats = document.querySelector(".knowledge-hero-stats"); if (heroStats) { const stats = heroStats.querySelectorAll("div"); if (stats[0]) { stats[0].querySelector("strong").textContent = formatNumber(count); stats[0].querySelector("span").textContent = "知识条目"; } if (stats[1]) { const semantic = Boolean(overview?.service?.semantic_enabled); stats[1].querySelector("strong").textContent = semantic ? "混合" : "关键词"; stats[1].querySelector("span").textContent = "检索模式"; } if (stats[2]) { const responseMs = Number(overview?.average_response_ms || 0); stats[2].querySelector("strong").textContent = responseMs ? `${(responseMs / 1000).toFixed(1)}s` : "--"; stats[2].querySelector("span").textContent = "平均响应"; } } const navBadge = document.querySelector('[data-view="knowledge"] .nav-badge'); if (navBadge) navBadge.textContent = formatNumber(count); const dashboardCount = document.querySelector(".knowledge-count"); if (dashboardCount) dashboardCount.textContent = `${formatNumber(count)} 条知识`; const heroCopy = document.querySelector(".knowledge-hero-copy p"); if (heroCopy) { const chunkCount = Number(overview?.total_chunks || 0); heroCopy.textContent = chunkCount ? `已接入 ${formatNumber(count)} 篇知识、${formatNumber(chunkCount)} 个检索切片,结合关键词检索与来源追溯回答通防业务问题。` : `已接入 ${formatNumber(count)} 篇知识,结合混合检索与来源追溯回答通防业务问题。`; } const categoryMap = new Map( (overview?.category_counts || []).map((item) => [item.name, Number(item.count || 0)]) ); if (!categoryMap.size) { items.forEach((item) => { const key = item.category_name || item.category || item.knowledge_type || "其他"; categoryMap.set(key, (categoryMap.get(key) || 0) + 1); }); } document.querySelectorAll(".knowledge-category-grid .category-card").forEach((card) => { const name = card.querySelector("strong")?.textContent?.trim(); const badge = card.querySelector("b"); if (badge) badge.textContent = formatNumber(categoryMap.get(name) ?? 0); }); renderKnowledgeGraph(graph); } const KNOWLEDGE_CATEGORY_META = { standards: { name: "规范标准", description: "规程、标准、办法及技术要求" }, cases: { name: "事故案例", description: "典型事故与隐患处置案例" }, expert: { name: "专家经验", description: "专家规则、判识经验与处置建议" }, management: { name: "管理流程", description: "业务流程、责任岗位与闭环规则" } }; function closeKnowledgeCategory() { const modal = document.querySelector("#knowledgeCatalogModal"); if (modal) modal.hidden = true; document.body.classList.remove("knowledge-catalog-open"); } async function loadKnowledgeCatalog(page = state.knowledgeCategoryPage) { const category = state.knowledgeCategory; const modal = document.querySelector("#knowledgeCatalogModal"); const content = document.querySelector("#knowledgeCatalogContent"); const pager = document.querySelector("#knowledgeCatalogPager"); if (!category || !modal || !content || !pager) return; state.knowledgeCategoryPage = page; content.innerHTML = '
正在加载知识列表...
'; pager.innerHTML = ""; const params = new URLSearchParams({ category, page: String(page), page_size: String(state.knowledgeCategoryPageSize) }); if (state.knowledgeCategoryKeyword) { params.set("keyword", state.knowledgeCategoryKeyword); } try { const data = await request(`/knowledge/catalog?${params.toString()}`, { timeoutMs: 20000 }); state.knowledgeCategoryTotal = Number(data?.total || 0); const title = document.querySelector("#knowledgeCatalogTitle"); const subtitle = document.querySelector("#knowledgeCatalogSubtitle"); if (title) title.textContent = KNOWLEDGE_CATEGORY_META[category]?.name || "知识列表"; if (subtitle) { subtitle.textContent = `${KNOWLEDGE_CATEGORY_META[category]?.description || ""} · 共 ${formatNumber(state.knowledgeCategoryTotal)} 条`; } renderKnowledgeCatalog(data?.items || []); renderKnowledgeCatalogPager(); } catch (error) { content.innerHTML = `
知识列表加载失败:${escapeHtml(error?.message || error)}
`; window.showToast?.("知识列表加载失败", "warning"); } } function renderKnowledgeCatalog(items) { const content = document.querySelector("#knowledgeCatalogContent"); if (!content) return; if (!items.length) { content.innerHTML = '
当前分类下没有匹配的知识。
'; return; } content.innerHTML = `
${items.map((item) => ` `).join("")}
`; if (typeof window.injectIcons === "function") window.injectIcons(content); } function renderKnowledgeCatalogPager() { const pager = document.querySelector("#knowledgeCatalogPager"); if (!pager) return; const pages = Math.max( 1, Math.ceil(state.knowledgeCategoryTotal / state.knowledgeCategoryPageSize) ); const current = state.knowledgeCategoryPage; const pageButtons = []; const start = Math.max(1, current - 2); const end = Math.min(pages, current + 2); for (let page = start; page <= end; page += 1) { pageButtons.push( `` ); } pager.innerHTML = ` 第 ${current} / ${pages} 页 ${pageButtons.join("")} `; } async function openKnowledgeDetail(noteId) { const content = document.querySelector("#knowledgeCatalogContent"); const pager = document.querySelector("#knowledgeCatalogPager"); if (!content) return; content.innerHTML = '
正在加载知识正文...
'; if (pager) pager.innerHTML = ""; try { const detail = await request( `/knowledge/catalog/${encodeURIComponent(noteId)}`, { timeoutMs: 20000 } ); content.innerHTML = `

${escapeHtml(detail.title)}

${[detail.category_name, ...(detail.tags || [])].filter(Boolean).map((tag) => `${escapeHtml(tag)}`).join("")}
${renderMarkdown(detail.full_content || detail.summary || "")}
`; content.scrollTop = 0; if (typeof window.injectIcons === "function") window.injectIcons(content); } catch (error) { content.innerHTML = `
知识正文加载失败:${escapeHtml(error?.message || error)}
`; } } async function openKnowledgeCategory(category) { const meta = KNOWLEDGE_CATEGORY_META[category]; const modal = document.querySelector("#knowledgeCatalogModal"); if (!meta || !modal) return; state.knowledgeCategory = category; state.knowledgeCategoryPage = 1; state.knowledgeCategoryKeyword = ""; const form = document.querySelector("#knowledgeCatalogSearch"); if (form) form.reset(); modal.hidden = false; document.body.classList.add("knowledge-catalog-open"); await loadKnowledgeCatalog(1); } function installKnowledgeCategoryBrowser() { if (window.__ventKnowledgeCategoryBrowserInstalled) return; window.__ventKnowledgeCategoryBrowserInstalled = true; document.querySelectorAll("[data-knowledge-category]").forEach((card) => { const open = () => openKnowledgeCategory(card.dataset.knowledgeCategory); card.addEventListener("click", open); card.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); open(); } }); }); document.querySelectorAll("[data-knowledge-catalog-close]").forEach((element) => { element.addEventListener("click", closeKnowledgeCategory); }); document.querySelector("#knowledgeCatalogSearch")?.addEventListener("submit", (event) => { event.preventDefault(); const keyword = new FormData(event.currentTarget).get("keyword")?.toString().trim() || ""; state.knowledgeCategoryKeyword = keyword; loadKnowledgeCatalog(1); }); document.querySelector("#knowledgeCatalogContent")?.addEventListener("click", (event) => { const back = event.target.closest("[data-knowledge-back]"); if (back) { loadKnowledgeCatalog(); return; } const item = event.target.closest("[data-knowledge-note]"); if (item) openKnowledgeDetail(item.dataset.knowledgeNote); }); document.querySelector("#knowledgeCatalogPager")?.addEventListener("click", (event) => { const button = event.target.closest("[data-knowledge-page]"); if (!button || button.disabled) return; loadKnowledgeCatalog(Number(button.dataset.knowledgePage)); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !document.querySelector("#knowledgeCatalogModal")?.hidden) { closeKnowledgeCategory(); } }); } function renderKnowledgeError(container, error, compact = false) { const notLoaded = Number(error?.status) === 404 || String(error?.message || "").includes("404"); if (notLoaded) { container.innerHTML = compact ? `
通防知识助手接口未加载

知识检索接口未在当前后端进程中加载,请重启后端服务后重试。

` : `
智能问答

回答与依据

接口未加载

知识检索接口未在当前后端进程中加载,请重启后端服务后重试。

`; if (typeof window.injectIcons === "function") window.injectIcons(container); window.showToast?.("知识接口未加载,请重启后端服务", "warning"); return; } container.innerHTML = compact ? `
通防知识助手检索失败

知识库检索失败,请稍后重试。

` : `
智能问答

回答与依据

检索失败

知识库检索失败,请稍后重试。

`; if (typeof window.injectIcons === "function") window.injectIcons(container); window.showToast?.("知识库检索失败,请稍后重试", "warning"); } function sourceView(source) { const score = Number(source.score || 0); return { citationIndex: Number(source.citation_index || 0), title: source.title, scoreText: score > 0 ? `${Math.round(score * 100)}%` : "相关" }; } function renderMarkdown(value) { const codeBlocks = []; const source = String(value || "") .replace(/\r\n/g, "\n") .replace(/```(\w*)\n?([\s\S]*?)```/g, (_, language, code) => { codeBlocks.push({ language, code }); return `\n@@CODEBLOCK${codeBlocks.length - 1}@@\n`; }); const lines = escapeHtml(source).split("\n"); const output = []; let listType = null; const closeList = () => { if (listType) output.push(``); listType = null; }; const inline = (text) => text .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/\*(.+?)\*/g, "$1") .replace(/`([^`]+)`/g, "$1") .replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1'); for (const rawLine of lines) { const line = rawLine.trimEnd(); const codeMatch = line.trim().match(/^@@CODEBLOCK(\d+)@@$/); if (codeMatch) { closeList(); const block = codeBlocks[Number(codeMatch[1])]; if (block) { output.push( `
${escapeHtml(block.code.trim())}
` ); } continue; } const heading = line.match(/^\s*(#{1,6})\s+(.+)$/); if (heading) { closeList(); const level = heading[1].length; output.push(`${inline(heading[2].trim())}`); continue; } const bullet = line.match(/^\s*[-*]\s+(.+)$/); const ordered = line.match(/^\s*\d+[.、)]\s+(.+)$/); if (bullet || ordered) { const nextType = bullet ? "ul" : "ol"; if (listType !== nextType) { closeList(); listType = nextType; output.push(`<${listType}>`); } output.push(`
  • ${inline((bullet || ordered)[1].trim())}
  • `); continue; } closeList(); if (line.trim()) output.push(`

    ${inline(line.trim())}

    `); } closeList(); return output.join(""); } function createKnowledgeStreamView(container, question) { let answer = ""; let thinking = ""; let answerEl = null; let statusEl = null; let thinkingPanel = null; let thinkingEl = null; let thinkingState = null; let renderFrame = 0; let thinkingFrame = 0; const renderMeta = (data = {}) => { const sources = (data.sources || []).map(sourceView); container.innerHTML = `
    智能问答

    回答与依据

    RAG 生成中

    问题:${escapeHtml(question)}

    `; answerEl = container.querySelector("[data-rag-answer]"); statusEl = container.querySelector("[data-rag-status]"); thinkingPanel = container.querySelector("[data-rag-thinking]"); thinkingEl = container.querySelector("[data-rag-thinking-content]"); thinkingState = container.querySelector("[data-rag-thinking-state]"); if (typeof window.injectIcons === "function") window.injectIcons(container); }; const scrollToBottom = () => { const scroller = container.querySelector(".ai-answer"); if (scroller) scroller.scrollTop = scroller.scrollHeight; }; const renderAnswer = () => { renderFrame = 0; if (!answerEl) return; answerEl.innerHTML = renderMarkdown(answer); scrollToBottom(); }; const scheduleAnswer = () => { if (renderFrame) return; renderFrame = window.requestAnimationFrame(renderAnswer); }; const renderThinking = (finished = false) => { thinkingFrame = 0; if (!thinkingPanel || !thinkingEl) return; thinkingPanel.hidden = !thinking; thinkingEl.textContent = thinking; if (thinkingState) thinkingState.textContent = finished ? "已完成" : "思考中"; scrollToBottom(); }; const scheduleThinking = () => { if (thinkingFrame) return; thinkingFrame = window.requestAnimationFrame(() => renderThinking(false)); }; return { get answer() { return answer; }, meta: renderMeta, thinking(content) { if (!answerEl) renderMeta(); thinking += String(content || ""); scheduleThinking(); }, delta(content) { if (!answerEl) renderMeta(); answer += String(content || ""); scheduleAnswer(); }, done(data = {}) { answer = String(data.answer || answer); if (data.thinking) thinking = String(data.thinking); if (renderFrame) window.cancelAnimationFrame(renderFrame); if (thinkingFrame) window.cancelAnimationFrame(thinkingFrame); renderAnswer(); renderThinking(true); container.querySelector("[data-rag-sources]")?.removeAttribute("hidden"); if (statusEl) { const status = data.answer_status || "generated"; const generated = status === "generated"; const conversational = status === "conversational"; statusEl.className = `subtle-badge ${generated || conversational ? "success" : ""}`; statusEl.textContent = generated ? "RAG 大模型生成" : conversational ? "智能助手" : "检索摘要"; } } }; } async function streamKnowledgeAnswerFetch(container, question) { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 120000); try { const response = await fetch(`${API_BASE}/knowledge/rag/stream`, { method: "POST", headers: { Accept: "text/event-stream", "Content-Type": "application/json" }, body: JSON.stringify({ question, top_k: 5, generate_answer: true }), signal: controller.signal }); if (!response.ok) { let message = `HTTP ${response.status}`; let payload = null; try { payload = await response.json(); message = payload?.message || payload?.detail || message; } catch { // Keep the HTTP status. } const error = new Error(message); error.status = response.status; throw error; } if (!response.body) throw new Error("当前浏览器不支持流式响应"); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; const view = createKnowledgeStreamView(container, question); const handleEvent = (eventName, data) => { if (eventName === "meta") { view.meta(data); } else if (eventName === "thinking") { view.thinking(data.content || ""); } else if (eventName === "delta") { view.delta(data.content || ""); } else if (eventName === "warning") { window.showToast?.(data.message || "大模型流式生成异常", "warning"); } else if (eventName === "done") { view.done(data); } }; const processBlock = (block) => { if (!block.trim()) return; let eventName = "message"; const dataLines = []; for (const line of block.split(/\r?\n/)) { if (line.startsWith("event:")) eventName = line.slice(6).trim(); if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()); } if (!dataLines.length) return; handleEvent(eventName, JSON.parse(dataLines.join("\n"))); }; while (true) { const { done, value } = await reader.read(); buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); const blocks = buffer.split(/\r?\n\r?\n/); buffer = blocks.pop() || ""; blocks.forEach(processBlock); if (done) break; } if (buffer.trim()) processBlock(buffer); } finally { window.clearTimeout(timeoutId); } } function streamKnowledgeAnswerEventSource(container, question) { return new Promise((resolve, reject) => { const url = `${API_BASE}/knowledge/rag/stream?question=${encodeURIComponent(question)}` + "&top_k=5"; const source = new EventSource(url); const view = createKnowledgeStreamView(container, question); let received = false; let settled = false; const timeoutId = window.setTimeout(() => { if (settled) return; source.close(); settled = true; reject(new Error("流式响应超时")); }, 120000); const finish = () => { if (settled) return; settled = true; window.clearTimeout(timeoutId); source.close(); resolve(); }; source.addEventListener("meta", (event) => { received = true; view.meta(JSON.parse(event.data)); }); source.addEventListener("thinking", (event) => { received = true; const data = JSON.parse(event.data); view.thinking(data.content || ""); }); source.addEventListener("delta", (event) => { received = true; const data = JSON.parse(event.data); view.delta(data.content || ""); }); source.addEventListener("warning", (event) => { const data = JSON.parse(event.data); window.showToast?.(data.message || "大模型流式生成异常", "warning"); }); source.addEventListener("done", (event) => { const data = JSON.parse(event.data); view.done(data); finish(); }); source.onerror = () => { if (settled) return; window.clearTimeout(timeoutId); source.close(); if (received && view.answer) { settled = true; resolve(); return; } settled = true; reject(new Error("SSE 连接失败")); }; }); } function streamKnowledgeAnswerWebSocket(container, question) { return new Promise((resolve, reject) => { const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const socket = new WebSocket( `${protocol}://${window.location.host}${API_BASE}/knowledge/rag/ws` ); const view = createKnowledgeStreamView(container, question); let received = false; let settled = false; const finish = () => { if (settled) return; settled = true; socket.close(); resolve(); }; const fail = (error) => { if (settled) return; settled = true; error.streamStarted = received; socket.close(); reject(error); }; socket.onopen = () => { socket.send( JSON.stringify({ question, top_k: 5, generate_answer: true }) ); }; socket.onmessage = (event) => { const item = JSON.parse(event.data); const data = item.data || {}; if (item.event === "meta") { received = true; view.meta(data); } else if (item.event === "thinking") { received = true; view.thinking(data.content || ""); } else if (item.event === "delta") { received = true; view.delta(data.content || ""); } else if (item.event === "warning") { window.showToast?.(data.message || "大模型流式生成异常", "warning"); } else if (item.event === "done") { view.done(data); finish(); } else if (item.event === "error") { fail(new Error(data.message || "流式问答失败")); } }; socket.onerror = () => fail(new Error("WebSocket 连接失败")); socket.onclose = () => { if (!settled && !received) fail(new Error("WebSocket 连接已关闭")); else if (!settled) finish(); }; }); } function streamKnowledgeAnswer(container, question) { if (typeof WebSocket !== "undefined") { return streamKnowledgeAnswerWebSocket(container, question).catch((error) => { if (error?.streamStarted) throw error; return streamKnowledgeAnswerEventSource(container, question); }); } if (typeof EventSource !== "undefined") { return streamKnowledgeAnswerEventSource(container, question); } return streamKnowledgeAnswerFetch(container, question); } function installKnowledgeRenderer() { if (window.__ventApiKnowledgeRendererInstalled) return; window.__ventApiKnowledgeRendererInstalled = true; const originalRenderAnswer = window.renderAnswer; if (typeof originalRenderAnswer !== "function") return; window.renderAnswer = function renderAnswerWithApi(container, question, compact = false) { if (!state.apiAvailable) { originalRenderAnswer(container, question, compact); return; } if (compact) { container.innerHTML = `
    通防知识助手正在检索知识库

    正在检索相关知识,请稍候...

    `; } else { container.innerHTML = `
    智能问答

    回答与依据

    检索中

    问题:${escapeHtml(question)}

    正在检索知识库并整理依据,请稍候...

    `; } if (typeof window.injectIcons === "function") window.injectIcons(container); if (!compact) { streamKnowledgeAnswer(container, question).catch((error) => { renderKnowledgeError(container, error, false); }); return; } request("/knowledge/ask", { method: "POST", body: JSON.stringify({ question, top_k: 3, generate_answer: false }), timeoutMs: 30000 }) .then((result) => { if (!result?.answer) return; const answerText = result.answer; const answerHtml = escapeHtml(answerText).replace(/\r?\n/g, "
    "); const sources = (result.sources || []).map((source) => ({ citationIndex: Number(source.citation_index || 0), title: source.title, score: Number(source.score || 0), scoreText: Number(source.score || 0) > 0 ? `${Math.round(Number(source.score || 0) * 100)}%` : "相关" })); const modeLabel = result.search_mode === "hybrid" ? "混合检索" : "关键词检索"; container.innerHTML = `
    通防知识助手${modeLabel} · 命中 ${result.total_candidates || sources.length} 条

    ${answerHtml}

    ${sources.map((source) => `${escapeHtml(source.title)}`).join("")}
    `; if (typeof window.injectIcons === "function") window.injectIcons(container); }) .catch((error) => renderKnowledgeError(container, error, true)); }; } function severityClass(severity) { return severity === "high" ? "high" : severity === "low" ? "low" : "medium"; } function sceneMeta(scene) { const keyMap = { "局部通风": ["local", "orange"], "通风设施": ["facility", "yellow"], "通风系统管理": ["system", "teal"], "风量分配": ["volume", "blue"], "灾害防治": ["disaster", "red"] }; return keyMap[scene] || ["system", "teal"]; } function statusMeta(status) { if (["completed", "closed", "verified", "已记录"].includes(status)) return ["closed", "已记录"]; if (["processing", "处理中"].includes(status)) return ["processing", "处理中"]; return ["pending", "待处理"]; } function renderDiagnosis(defects) { const list = defects?.length ? defects : DEMO_DIAGNOSIS; const normalized = list.map((item, index) => { const [sceneKey] = sceneMeta(item.scene); return { ...item, id: item.id || `risk-api-${index}`, scene_key: sceneKey, defect_code: item.defect_code || item.code || `RISK-API-${index + 1}`, evidence: item.evidence || ( sceneKey === "disaster" ? ["radio-tower", "video", "file-text"] : sceneKey === "volume" ? ["gauge", "file-text"] : ["radio-tower", "activity", "file-text"] ) }; }); window.setHazardRisks?.(normalized); } function renderEvaluation(evaluation) { const run = evaluation.run || DEMO_EVALUATION.run; const dimensions = evaluation.dimensions?.length ? evaluation.dimensions : DEMO_EVALUATION.dimensions; const score = Number(run.total_score || 0); const grade = String(run.grade || "A").replace("级", ""); const summary = run.summary || "综合评价已完成,运行状态稳定。"; setText(".evaluation-grade strong", grade); setText(".evaluation-grade em", score >= 90 ? "运行状态优良" : score >= 80 ? "运行状态良好" : "需要持续改进"); setText(".evaluation-summary-copy .eyebrow", `${formatDate(run.created_at || new Date())} · 自动评价`); setText(".evaluation-summary-copy h2", `${state.mine?.name || "上湾煤矿"}通防业务综合评价报告`); setText(".evaluation-summary-copy p", summary); setText(".radar-panel .subtle-badge", `综合得分 ${formatNumber(score)}`); const gradeBox = document.querySelector(".evaluation-grade"); if (gradeBox) gradeBox.dataset.score = String(score); const dimensionsList = document.querySelector(".dimension-list"); const iconMap = ["shield-check", "activity", "circle-dollar-sign", "clipboard-check", "database"]; const colorMap = ["green", "blue", "amber", "purple", "teal"]; if (dimensionsList) { dimensionsList.innerHTML = dimensions .slice(0, 5) .map((item, index) => { const change = Number(item.change || 0); return `
    ${escapeHtml(item.name)}

    ${escapeHtml(item.note || "业务综合指标")}

    ${Number(item.score || 0).toFixed(1)}${Math.abs(change).toFixed(1)}
    `; }) .join(""); if (typeof window.injectIcons === "function") window.injectIcons(dimensionsList); } document.querySelectorAll(".radar-labels text").forEach((label, index) => { const item = dimensions[index]; if (item) label.textContent = `${item.name} ${Number(item.score || 0).toFixed(1)}`; }); const agentText = document.querySelector(".evaluation-agent-panel .agent-copy p"); if (agentText) { agentText.textContent = `“${summary}”`; } const suggestions = document.querySelector(".evaluation-agent-panel .agent-suggestions"); if (suggestions && run.recommendations?.length) { suggestions.innerHTML = run.recommendations .slice(0, 3) .map((item, index) => `${index + 1}${escapeHtml(item)}`) .join(""); } } function renderWorkOrders(orders) { const items = orders?.length ? orders : DEMO_WORK_ORDERS; const groups = [ { statuses: ["pending"], title: "待确认", dot: "pending" }, { statuses: ["processing"], title: "处理中", dot: "processing" }, { statuses: ["review"], title: "待复核", dot: "review" }, { statuses: ["completed", "verified", "closed"], title: "已完成", dot: "closed" } ]; const columns = document.querySelectorAll("#view-closed-loop .kanban-column"); groups.forEach((group, index) => { const column = columns[index]; if (!column) return; const groupItems = items.filter((item) => group.statuses.includes(item.status)); const head = column.querySelector(".kanban-head span"); if (head) head.innerHTML = `${group.title} ${groupItems.length}`; const cards = groupItems.map((item) => { const priority = item.priority || "medium"; const priorityLabel = priority === "high" ? "高优先级" : priority === "low" ? "常规" : "中优先级"; return ` `; }); column.innerHTML = head ? `${head.outerHTML}${cards.join("")}` : cards.join(""); column.querySelectorAll(".work-card").forEach((card) => { card.addEventListener("click", () => window.showToast?.(`已打开任务:${card.querySelector("strong")?.textContent || "整改任务"}`)); }); }); const toolbar = document.querySelector("#view-closed-loop .toolbar-copy p"); const closed = items.filter((item) => ["completed", "verified", "closed"].includes(item.status)).length; if (toolbar) { const rate = items.length ? (closed / items.length * 100).toFixed(1) : "0.0"; toolbar.textContent = `当前展示 ${items.length} 项整改任务,已完成 ${closed} 项,闭环率 ${rate}%`; } } async function loadMine() { try { const mines = await request("/mines"); const mine = mines.find((item) => item.code === DEFAULT_MINE_CODE) || mines.find((item) => item.is_default) || mines[0]; if (!mine) throw new Error("no mine returned"); state.apiAvailable = true; state.mine = mine; return { data: mine, source: "api" }; } catch (error) { state.errors.push(`mines: ${error?.message || error}`); state.apiAvailable = false; state.mine = DEMO_MINE; return { data: DEMO_MINE, source: "demo" }; } } async function loadDocuments() { const categoryQuery = state.documentCategory && state.documentCategory !== "all" ? `&category=${encodeURIComponent(state.documentCategory)}` : ""; try { const keyword = state.documentKeyword.trim(); const documentRequest = keyword ? request( withMine( `/documents/search?q=${encodeURIComponent(keyword)}&page=${state.documentPage}&page_size=${state.documentPageSize}${categoryQuery}` ) ) : request( withMine( `/documents?page=${state.documentPage}&page_size=${state.documentPageSize}${categoryQuery}` ) ); const [pageResult, categories] = await Promise.all([ documentRequest, request(withMine("/documents/categories")) ]); state.documentSearchSnippets = new Map(); const items = keyword ? (pageResult.items || []).map((hit) => { state.documentSearchSnippets.set( String(hit.document?.id), hit.snippets || [] ); return hit.document; }) : pageResult?.items || []; return { data: { page: { items, total: Number(pageResult?.total || 0), page: Number(pageResult?.page || state.documentPage), page_size: Number(pageResult?.page_size || state.documentPageSize) }, categories: categories || [] }, source: "api" }; } catch (error) { state.errors.push(`documents: ${error?.message || error}`); return { data: EMPTY_DOCUMENTS, source: "demo" }; } } async function loadDocumentStatistics() { return withFallback( "document-statistics", () => request(withMine("/documents/statistics")), DEMO_DOCUMENT_STATISTICS ); } async function loadBusinessTemplates() { return withFallback( "document-templates", async () => { const templates = await request("/documents/templates"); if (!templates?.length) throw new Error("empty response"); return templates; }, [] ); } async function loadMonitoring() { return withFallback( "monitoring", async () => { const [overview, sources, sensors, facilities, alarms] = await Promise.all([ request(withMine("/monitoring/overview")), request(withMine("/monitoring/sources")), request(withMine("/monitoring/sensors")), request(withMine("/monitoring/facilities")), request(withMine("/monitoring/alarms?limit=100")) ]); return { overview, sources, sensors, facilities, alarms }; }, DEMO_MONITORING ); } async function loadKnowledge() { return withFallback( "knowledge", async () => { const [overview, catalog, graph] = await Promise.all([ request("/knowledge/overview", { timeoutMs: 15000 }), request("/knowledge/catalog?page=1&page_size=200", { timeoutMs: 15000 }), request("/knowledge/graph", { timeoutMs: 15000 }) ]); if (!overview?.total_notes && !catalog?.items?.length) { throw new Error("empty response"); } return { overview, catalog, graph }; }, { overview: null, catalog: { items: DEMO_KNOWLEDGE }, graph: null } ); } async function loadDiagnosis() { return withFallback( "diagnosis", async () => { const tasks = await request(withMine("/diagnosis/tasks?limit=20")); if (!tasks?.length) throw new Error("empty response"); const details = await Promise.all( tasks.slice(0, 5).map((task) => request(`/diagnosis/tasks/${encodeURIComponent(task.id)}`)) ); const defects = details.flatMap((task) => (task.results || []).map((result) => ({ id: result.id, defect_name: result.defect_name, defect_code: result.defect_code, scene: result.scene, severity: result.severity, confidence: Number(result.confidence || 0) * (Number(result.confidence || 0) <= 1 ? 100 : 1), status: result.status, conclusion: result.conclusion, recommendation: result.recommendation, occurred_at: formatTime(result.created_at || task.created_at) })) ); if (!defects.length) throw new Error("empty response"); return defects; }, DEMO_DIAGNOSIS ); } async function loadEvaluation() { return withFallback( "evaluations", async () => { const [indicators, runs] = await Promise.all([ request(withMine("/evaluations/indicators")), request(withMine("/evaluations/runs?limit=1")) ]); if (!runs?.length) throw new Error("empty response"); const run = await request(`/evaluations/runs/${encodeURIComponent(runs[0].id)}`); const indicatorMap = new Map((indicators || []).map((item) => [String(item.id), item])); const dimensions = (run.scores || []) .map((score) => { const indicator = indicatorMap.get(String(score.indicator_id)); return { name: indicator?.dimension || indicator?.name, score: Number(score.score || 0), change: 0, note: indicator?.name || "评价指标得分" }; }) .filter((item) => item.name); return { run, dimensions: dimensions.length ? dimensions : DEMO_EVALUATION.dimensions }; }, DEMO_EVALUATION ); } async function loadWorkOrders() { return withFallback( "work-orders", async () => { const page = await request(withMine("/work-orders?page=1&page_size=200")); if (!page?.items?.length) throw new Error("empty response"); return page.items; }, DEMO_WORK_ORDERS ); } async function runHazardAgent(agent) { const sceneName = { local: "局部通风", facility: "通风设施", system: "通风系统管理", volume: "风量分配", disaster: "灾害防治" }[agent.sceneKey] || "all"; let source = "demo"; if (state.apiAvailable && state.mine?.id) { try { await request("/diagnosis/run", { method: "POST", body: JSON.stringify({ mine_id: state.mine.id, scene: sceneName, requested_by: "隐患识别中心" }) }); source = "api"; } catch (error) { state.errors.push(`hazard-agent:${agent.id}: ${error?.message || error}`); } } const localFindings = window.getHazardAgentFindings?.(agent) || []; await new Promise((resolve) => window.setTimeout(resolve, 650)); return { findings: localFindings, source }; } function installActionInterceptors() { const diagnosisButton = document.querySelector("[data-run-diagnosis]"); if (diagnosisButton && !diagnosisButton.dataset.apiBound) { diagnosisButton.dataset.apiBound = "true"; diagnosisButton.addEventListener( "click", async (event) => { event.stopImmediatePropagation(); if (!state.apiAvailable) { window.showToast?.("后端不可用,已使用本地演示诊断结果", "warning"); return; } const original = diagnosisButton.innerHTML; diagnosisButton.disabled = true; diagnosisButton.innerHTML = '诊断进行中'; window.injectIcons?.(diagnosisButton); try { await request("/diagnosis/run", { method: "POST", body: JSON.stringify({ mine_id: state.mine.id, scene: "all", requested_by: "前端用户" }) }); const result = await loadDiagnosis(); renderDiagnosis(result.data); window.showToast?.(`全量诊断完成,识别到 ${result.data.length} 项缺陷与风险`); } catch { renderDiagnosis(DEMO_DIAGNOSIS); window.showToast?.("诊断接口不可用,已回退到本地演示数据", "warning"); } finally { diagnosisButton.disabled = false; diagnosisButton.innerHTML = original; window.injectIcons?.(diagnosisButton); } }, true ); } const evaluationButton = document.querySelector("[data-start-evaluation]"); if (evaluationButton && !evaluationButton.dataset.apiBound) { evaluationButton.dataset.apiBound = "true"; evaluationButton.addEventListener( "click", async (event) => { event.stopImmediatePropagation(); if (!state.apiAvailable) { renderEvaluation(DEMO_EVALUATION); window.showToast?.("后端不可用,已使用本地演示评价结果", "warning"); return; } const original = evaluationButton.innerHTML; evaluationButton.disabled = true; evaluationButton.innerHTML = '评价计算中'; window.injectIcons?.(evaluationButton); try { await request("/evaluations/run", { method: "POST", body: JSON.stringify({ mine_id: state.mine.id, requested_by: "前端用户" }) }); const result = await loadEvaluation(); renderEvaluation(result.data); window.showToast?.(`综合评价已更新,综合得分 ${formatNumber(result.data.run.total_score)},等级 ${result.data.run.grade}`); } catch { renderEvaluation(DEMO_EVALUATION); window.showToast?.("评价接口不可用,已回退到本地演示数据", "warning"); } finally { evaluationButton.disabled = false; evaluationButton.innerHTML = original; window.injectIcons?.(evaluationButton); } }, true ); } } async function init() { if (state.initialized) return; state.initialized = true; updateConnectionBadge("loading"); try { await loadMine(); const [dashboardResult, monitoringResult, documentsResult, documentStatisticsResult, templatesResult, knowledgeResult, diagnosisResult, evaluationResult, workOrdersResult] = await Promise.all([ state.apiAvailable ? apiLoader("dashboard", "/dashboard/overview", DEMO_DASHBOARD) : Promise.resolve({ data: DEMO_DASHBOARD, source: "demo" }), state.apiAvailable ? loadMonitoring() : Promise.resolve({ data: DEMO_MONITORING, source: "demo" }), state.apiAvailable ? loadDocuments() : Promise.resolve({ data: EMPTY_DOCUMENTS, source: "demo" }), state.apiAvailable ? loadDocumentStatistics() : Promise.resolve({ data: DEMO_DOCUMENT_STATISTICS, source: "demo" }), state.apiAvailable ? loadBusinessTemplates() : Promise.resolve({ data: [], source: "demo" }), state.apiAvailable ? loadKnowledge() : Promise.resolve({ data: DEMO_KNOWLEDGE, source: "demo" }), state.apiAvailable ? loadDiagnosis() : Promise.resolve({ data: DEMO_DIAGNOSIS, source: "demo" }), state.apiAvailable ? loadEvaluation() : Promise.resolve({ data: DEMO_EVALUATION, source: "demo" }), state.apiAvailable ? loadWorkOrders() : Promise.resolve({ data: DEMO_WORK_ORDERS, source: "demo" }) ]); const documents = documentsResult.data.page?.items || []; state.templates = templatesResult.data || []; renderDashboard(dashboardResult.data, monitoringResult.data, evaluationResult.data); renderMonitoring( monitoringResult.data, documents, documentStatisticsResult.data ); refreshDocumentProcessingTasks().catch(() => {}); renderFacilityStatus(monitoringResult.data); renderSafetyMonitoring(monitoringResult.data); renderVideoPlatform(monitoringResult.data); renderDocumentRows( documents, documentsResult.data.categories, documentsResult.data.page?.total ); renderDocumentStatistics(documentStatisticsResult.data); renderKnowledge(knowledgeResult.data); renderDiagnosis(diagnosisResult.data); renderEvaluation(evaluationResult.data); renderWorkOrders(workOrdersResult.data); installKnowledgeRenderer(); installKnowledgeCategoryBrowser(); installActionInterceptors(); installBusinessUpload(); installBusinessEdit(); installFacilityActions(); installSafetyMonitoringActions(); installVideoPlatformActions(); installDocumentExport(); hydrateSafetyReadings(monitoringResult.data).catch(() => {}); if (state.apiAvailable && documentsResult.source === "api") { hydrateDocumentDetails(documents).catch(() => {}); } if (!state.apiAvailable) { updateConnectionBadge("demo"); } else if (state.errors.length) { updateConnectionBadge("mixed"); } else { updateConnectionBadge("api"); } } finally { document.body.classList.remove("api-loading"); } } window.VentAPI = { init, getBusinessDocument(documentId) { return state.documents.get(String(documentId)); }, updateBusinessDocumentField, refreshBusinessDocument, getDocumentPreview(documentId) { return request(`/documents/${encodeURIComponent(documentId)}/preview`); }, getDocumentFileUrl(documentId, download = false) { return `${API_BASE}/documents/${encodeURIComponent(documentId)}/file${download ? "?download=1" : ""}`; }, getDocumentProcessing, reprocessBusinessDocument, openProcessingRecords, closeProcessingRecords, openVideoPlatform, openKnowledgeCategory, setBusinessSearch, setBusinessCategory, runHazardAgent, state }; })();