(() => {
"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,
videoPlatform: createVideoPlatformState(),
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)}`
: ""}
${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 `
`;
})
.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)}
${tag}>
`;
});
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 = `
`;
}
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);
});
});
}
// -------------------------------------------------------------------------
// 工业视频平台:对接 /api/v1/video-platform,后端不可用时回退内置演示数据
// -------------------------------------------------------------------------
const VIDEO_MODULE_KEYS = {
door: "door",
"local-fan": "local_fan",
"main-fan": "main_fan"
};
const VIDEO_MODULE_VIEW_KEYS = {
door: "door",
local_fan: "local-fan",
main_fan: "main-fan"
};
const VIDEO_MODULE_GROUPS = {
door: "风门区域",
"local-fan": "局扇区域",
"main-fan": "主扇区域"
};
const VIDEO_DEMO_RECORD_TYPES = {
door: ["vehicle_passage", "person_passage", "door_unclosed"],
"local-fan": ["person_track", "area_stay", "person_warn"],
"main-fan": ["person_detect", "device_operation", "device_warn"]
};
const VIDEO_EVENT_TONES = { danger: "danger", warning: "warning", info: "gray" };
const VIDEO_EVENT_STATUS_LABELS = {
pending: "待处理",
processing: "处理中",
handled: "已处理",
ignored: "已忽略"
};
function createVideoPlatformState() {
return {
source: "demo",
loaded: false,
loadingModule: "",
mineId: "",
overview: null,
modules: {},
channels: [],
status: {},
events: {},
eventPage: 1,
eventPageSize: 5,
snapshots: {},
tables: {},
filters: { quick: "today", start: "", end: "", type: "", scope: "" }
};
}
function videoPlatformState() {
if (!state.videoPlatform) state.videoPlatform = createVideoPlatformState();
return state.videoPlatform;
}
function formatDateTime(value) {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).replace("T", " ").slice(0, 19);
const pad = (number) => String(number).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function formatClock(value) {
if (!value) return "--:--:--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).slice(11, 19) || "--:--:--";
const pad = (number) => String(number).padStart(2, "0");
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function toLocalInputValue(date) {
const pad = (number) => String(number).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function videoModuleKeyFor(viewKey) {
return VIDEO_MODULE_KEYS[viewKey] || "door";
}
function videoModuleViewKey(moduleKey) {
return VIDEO_MODULE_VIEW_KEYS[moduleKey] || "door";
}
function videoStatusLabel(status) {
return status === "online" ? "在线" : status === "offline" ? "离线" : "异常";
}
function videoSourceCount(monitoring, fallback = 0) {
const source = (monitoring?.sources || []).find((item) =>
String(item.source_type || "").toLowerCase().includes("video")
);
return Number(source?.point_count || fallback || 0);
}
function videoPlatformChannels(viewKey = state.videoModule) {
const view = videoPlatformState();
const moduleKey = videoModuleKeyFor(viewKey);
return view.channels.filter((channel) => channel.module === moduleKey);
}
// --- 后端数据归一化 -------------------------------------------------------
function normalizeVideoChannel(item) {
const status = String(item.status || "online");
return {
id: String(item.id),
code: item.code || "",
name: item.name || "",
module: item.module || "",
group: item.area_group || "",
location: item.location || "",
status,
streamUrl: item.stream_url || "",
playbackUrl: item.media_available && item.playback_url ? item.playback_url : "",
snapshotUrl: item.snapshot_url || "",
mediaAvailable: Boolean(item.media_available),
resolution: item.resolution || "",
isOnline: status === "online"
};
}
function normalizeVideoModuleConfig(item) {
return {
key: item.key || "",
viewKey: videoModuleViewKey(item.key),
title: item.title || "",
statusTitle: item.status_title || "",
typeFilterLabel: item.type_filter_label || "类型",
typeFilterOptions: item.type_filter_options?.length ? item.type_filter_options : ["全部类型"],
scopeFilterLabel: item.scope_filter_label || "设备",
scopeFilterOptions: item.scope_filter_options?.length ? item.scope_filter_options : ["全部设备"],
tabs: (item.record_tabs || []).map((tab) => ({
recordType: tab.record_type,
label: tab.label,
columns: tab.columns || []
}))
};
}
function normalizeVideoStatus(payload) {
return (payload?.metrics || []).map((item) => ({
label: item.label || "",
value: Number(item.value || 0),
unit: item.unit || "",
tone: item.tone || ""
}));
}
function normalizeVideoEvent(item) {
return {
id: String(item.id),
time: formatClock(item.occurred_at),
eventType: item.event_type || "分析事件",
description: item.description || item.title || "",
tone: VIDEO_EVENT_TONES[String(item.level || "info")] || "gray",
status: VIDEO_EVENT_STATUS_LABELS[String(item.status || "")] || "",
channelName: item.channel_name || "",
imageUrl: item.snapshot_image_url || ""
};
}
function normalizeVideoSnapshot(item) {
const available = Boolean(item.media_available);
return {
id: String(item.id),
name: item.channel_name || item.title || "",
type: item.event_type || "识别抓拍",
detail: [
item.plate_no,
item.person_name,
item.gap_mm != null ? `缝隙${item.gap_mm}mm` : ""
]
.filter(Boolean)
.join(" · "),
time: formatClock(item.captured_at),
imageUrl: available ? item.image_url : "",
thumbUrl: available ? item.thumbnail_url || item.image_url : ""
};
}
function videoPersonText(item) {
const count = Number(item.person_count || 0);
const name = item.person_name || "";
if (name && count > 1) return `${name}(${count}人)`;
if (name) return `${name}(1人)`;
return count ? `${count}人` : "--";
}
function videoStayText(item) {
const text = item?.properties?.stay_text;
if (text) return text;
return item?.stay_seconds ? formatDuration(item.stay_seconds) : "--";
}
function videoRecordRow(recordType, item, index) {
const order = index + 1;
switch (recordType) {
case "vehicle_passage":
return [order, item.device_name, item.plate_no || "--", formatDateTime(item.occurred_at), item.result || "--"];
case "person_passage":
return [order, item.device_name, videoPersonText(item), formatDateTime(item.occurred_at), item.result || "--"];
case "door_unclosed":
return [order, item.device_name, formatDateTime(item.occurred_at), item.gap_mm != null ? `${item.gap_mm} mm` : "--", item.level || "--"];
case "person_track":
return [
order,
item.device_name,
item.area_name || "--",
item.person_name || "--",
item.team_name || "--",
formatDateTime(item.enter_time),
item.leave_time ? formatDateTime(item.leave_time) : "在场",
videoStayText(item)
];
case "area_stay":
return [
order,
item.area_name || "--",
item.person_count ?? 0,
item.properties?.max_stay || "--",
item.properties?.min_stay || "--",
item.properties?.avg_stay || "--",
item.properties?.total_stay || "--"
];
case "person_warn":
return [order, item.device_name, item.area_name || "--", item.person_name || "--", item.warn_type || "--", formatDateTime(item.occurred_at), item.handle_status || "--"];
case "person_detect":
return [order, item.device_name, item.location || "--", formatDateTime(item.occurred_at), item.result || "--", item.device_status || "--"];
case "device_operation":
return [order, item.device_name, item.operation_type || "--", formatDateTime(item.occurred_at), item.compare_result || "--", item.device_status || "--"];
case "device_warn":
return [order, item.device_name, item.location || "--", item.warn_type || "--", formatDateTime(item.occurred_at), item.handle_status || "--"];
default:
return [order, item.device_name || "--", item.record_type_label || item.record_type || "--", formatDateTime(item.occurred_at)];
}
}
// --- 演示数据视图(后端不可用时与后端结构保持一致) -----------------------
function buildVideoDemoPlatform() {
const view = createVideoPlatformState();
view.source = "demo";
view.channels = VIDEO_CHANNELS.map((channel) => {
const viewKey = Object.keys(VIDEO_MODULE_GROUPS).find(
(key) => VIDEO_MODULE_GROUPS[key] === channel.group
) || "door";
return {
id: channel.id,
code: channel.id,
name: channel.name,
module: videoModuleKeyFor(viewKey),
group: channel.group,
location: channel.location,
status: channel.status,
streamUrl: "",
playbackUrl: "",
snapshotUrl: "",
mediaAvailable: false,
resolution: "",
isOnline: channel.status === "online"
};
});
Object.keys(VIDEO_MODULES).forEach((viewKey) => {
const moduleData = VIDEO_MODULES[viewKey];
const moduleKey = videoModuleKeyFor(viewKey);
const channels = view.channels.filter((channel) => channel.module === moduleKey);
const recordTypes = VIDEO_DEMO_RECORD_TYPES[viewKey] || [];
view.modules[viewKey] = {
key: moduleKey,
viewKey,
title: moduleData.title,
statusTitle: moduleData.statusTitle,
typeFilterLabel: moduleData.typeFilterLabel,
typeFilterOptions: moduleData.typeFilterOptions,
scopeFilterLabel: moduleData.scopeFilterLabel,
scopeFilterOptions: moduleData.scopeFilterOptions,
tabs: moduleData.tabs.map((label, index) => ({
recordType: recordTypes[index] || `demo-${index}`,
label,
columns: moduleData.columns
}))
};
view.status[viewKey] = moduleData.summary.map(([label, value, unit]) => ({
label,
value,
unit,
tone: ""
}));
view.events[viewKey] = moduleData.logs.map(([time, message, tone], index) => ({
id: `${viewKey}-event-${index}`,
time,
eventType: "",
description: message,
tone,
status: "",
channelName: "",
imageUrl: ""
}));
view.snapshots[viewKey] = {
items: channels.slice(0, 8).map((channel, index) => ({
id: `demo-snapshot-${viewKey}-${index}`,
name: channel.name,
type: moduleData.snapshotTypes[index % moduleData.snapshotTypes.length],
detail: channel.location,
time: `${String(11 + Math.floor(index / 4)).padStart(2, "0")}:${String((index * 7) % 60).padStart(2, "0")}:20`,
imageUrl: "",
thumbUrl: ""
})),
total: channels.length
};
view.tables[viewKey] = {};
view.modules[viewKey].tabs.forEach((tab) => {
view.tables[viewKey][tab.recordType] = {
columns: moduleData.columns,
rows: moduleData.rows,
total: moduleData.rows.length,
loaded: true
};
});
});
view.overview = {
channel_total: view.channels.length,
channel_configured: view.channels.length,
channel_online: view.channels.filter((item) => item.isOnline).length,
event_open_count: Object.values(view.events).reduce((sum, list) => sum + list.length, 0)
};
view.loaded = true;
return view;
}
// --- 数据加载 -------------------------------------------------------------
function videoFilterQuery() {
const view = videoPlatformState();
const params = new URLSearchParams();
const start = view.filters.start ? new Date(view.filters.start) : null;
const end = view.filters.end ? new Date(view.filters.end) : null;
if (start && !Number.isNaN(start.getTime())) params.set("start", start.toISOString());
if (end && !Number.isNaN(end.getTime())) params.set("end", end.toISOString());
return params;
}
function applyVideoDefaultRange(options = {}) {
const view = videoPlatformState();
if (view.filters.start && view.filters.end && !options.reset) return;
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
if (view.filters.quick === "week") start.setDate(start.getDate() - 6);
view.filters.start = toLocalInputValue(start);
view.filters.end = toLocalInputValue(now);
}
async function fetchVideoRecords(viewKey, recordType) {
const view = videoPlatformState();
const config = view.modules[viewKey];
const tab = (config?.tabs || []).find((item) => item.recordType === recordType);
const params = videoFilterQuery();
params.set("module", videoModuleKeyFor(viewKey));
params.set("record_type", recordType);
params.set("page", "1");
params.set("page_size", "50");
const page = await request(withMine(`/video-platform/records?${params.toString()}`));
view.tables[viewKey] = view.tables[viewKey] || {};
view.tables[viewKey][recordType] = {
columns: tab?.columns || [],
rows: (page?.items || []).map((item, index) => videoRecordRow(recordType, item, index)),
total: Number(page?.total || 0),
loaded: true
};
}
async function loadVideoPlatformModule(viewKey) {
const view = videoPlatformState();
const moduleKey = videoModuleKeyFor(viewKey);
const config = view.modules[viewKey];
const params = videoFilterQuery();
params.set("module", moduleKey);
params.set("page", "1");
params.set("page_size", "12");
const [status, events, snapshots] = await Promise.all([
request(withMine(`/video-platform/modules/${moduleKey}/summary`)),
request(withMine(`/video-platform/events?module=${moduleKey}&limit=50`)),
request(withMine(`/video-platform/snapshots?${params.toString()}`))
]);
view.status[viewKey] = normalizeVideoStatus(status);
view.events[viewKey] = (events || []).map(normalizeVideoEvent);
view.eventPage = 1;
view.snapshots[viewKey] = {
items: (snapshots?.items || []).map(normalizeVideoSnapshot),
total: Number(snapshots?.total || 0)
};
const firstTab = config?.tabs?.[0]?.recordType;
if (firstTab) await fetchVideoRecords(viewKey, firstTab);
}
async function ensureVideoModuleLoaded(viewKey = state.videoModule) {
const view = videoPlatformState();
if (view.source !== "api" || view.status[viewKey]) return;
view.loadingModule = viewKey;
renderVideoPlatform();
try {
await loadVideoPlatformModule(viewKey);
} catch (error) {
state.errors.push(`video-platform:${viewKey}: ${error?.message || error}`);
window.showToast?.("视频平台数据读取失败,请稍后重试", "warning");
} finally {
view.loadingModule = "";
renderVideoPlatform();
}
}
async function loadVideoPlatform(viewKey = state.videoModule) {
const view = videoPlatformState();
renderVideoPlatform();
try {
if (!state.apiAvailable || !state.mine?.id) throw new Error("api unavailable");
const [overview, modules, channels] = await Promise.all([
request(withMine("/video-platform/overview")),
request(withMine("/video-platform/modules")),
request(withMine("/video-platform/channels?limit=200"))
]);
const moduleConfigs = (modules || []).map(normalizeVideoModuleConfig);
if (!moduleConfigs.length || !(channels || []).length) throw new Error("empty response");
applyVideoDefaultRange();
view.overview = {
channel_total: Number(overview?.channel_total || channels.length),
channel_configured: Number(overview?.channel_configured || channels.length),
channel_online: Number(overview?.channel_online || 0),
event_open_count: Number(overview?.event_open_count || 0)
};
view.modules = {};
moduleConfigs.forEach((config) => {
view.modules[config.viewKey] = config;
});
view.channels = (channels || []).map(normalizeVideoChannel);
view.mineId = state.mine.id;
view.source = "api";
view.loaded = true;
const activeViewKey = view.modules[viewKey] ? viewKey : Object.keys(view.modules)[0];
state.videoModule = activeViewKey;
await loadVideoPlatformModule(activeViewKey);
state.videoSelectedChannel = videoPlatformChannels(activeViewKey)[0]?.id || state.videoSelectedChannel;
renderVideoPlatform();
} catch (error) {
state.errors.push(`video-platform: ${error?.message || error}`);
state.videoPlatform = buildVideoDemoPlatform();
renderVideoPlatform();
}
}
async function ensureVideoRecordTab(index) {
const view = videoPlatformState();
const config = view.modules[state.videoModule];
const tab = config?.tabs?.[index];
if (!tab || view.source !== "api") return;
if (view.tables[state.videoModule]?.[tab.recordType]?.loaded) return;
try {
await fetchVideoRecords(state.videoModule, tab.recordType);
} catch (error) {
state.errors.push(`video-platform:records: ${error?.message || error}`);
}
}
async function searchVideoPlatform(options = {}) {
const view = videoPlatformState();
if (options.fromInputs !== false) {
const startInput = document.querySelector("#videoFilterStart");
const endInput = document.querySelector("#videoFilterEnd");
const typeSelect = document.querySelector("#videoRecordType");
const scopeSelect = document.querySelector("#videoDeviceScope");
if (startInput?.value) view.filters.start = startInput.value;
if (endInput?.value) view.filters.end = endInput.value;
if (typeSelect?.value) view.filters.type = typeSelect.value;
if (scopeSelect?.value) view.filters.scope = scopeSelect.value;
}
if (view.source !== "api") {
renderVideoPlatform();
window.showToast?.("后端不可用,已展示本地演示抓拍", "warning");
return;
}
const config = view.modules[state.videoModule];
const tabs = config?.tabs || [];
const tab = tabs[Math.min(state.videoRecordTab, Math.max(tabs.length - 1, 0))];
view.loadingModule = state.videoModule;
renderVideoPlatform();
try {
const params = videoFilterQuery();
params.set("module", videoModuleKeyFor(state.videoModule));
if (view.filters.type && view.filters.type !== (config?.typeFilterOptions?.[0] || "全部类型")) {
params.set("event_type", view.filters.type);
}
const scopeChannel = view.channels.find((item) => item.name === view.filters.scope);
if (scopeChannel) params.set("channel_id", scopeChannel.id);
params.set("page", "1");
params.set("page_size", "12");
const snapshots = await request(withMine(`/video-platform/snapshots?${params.toString()}`));
view.snapshots[state.videoModule] = {
items: (snapshots?.items || []).map(normalizeVideoSnapshot),
total: Number(snapshots?.total || 0)
};
if (tab) await fetchVideoRecords(state.videoModule, tab.recordType);
if (!options.silent) {
window.showToast?.(`检索完成,命中 ${formatNumber(view.snapshots[state.videoModule].total)} 张抓拍图像`);
}
} catch (error) {
state.errors.push(`video-platform:search: ${error?.message || error}`);
window.showToast?.("视频检索失败,请稍后重试", "warning");
} finally {
view.loadingModule = "";
renderVideoPlatform();
}
}
// --- 页面渲染 -------------------------------------------------------------
function videoEmptyState(icon, message) {
return ``;
}
function videoLoadingText(viewKey, text, emptyText) {
return videoPlatformState().loadingModule === viewKey ? text : emptyText;
}
function renderVideoSummary() {
const grid = document.querySelector("#videoSummaryGrid");
if (!grid) return;
const view = videoPlatformState();
const moduleData = view.modules[state.videoModule];
const configured = view.overview?.channel_configured ?? videoPlatformChannels().length;
const total = Math.max(videoSourceCount(state.monitoring, configured), configured);
const online = view.overview?.channel_online
?? videoPlatformChannels().filter((item) => item.isOnline).length;
const eventCount = view.overview?.event_open_count
?? (view.events[state.videoModule] || []).length;
grid.innerHTML = `
视频通道${formatNumber(total)}视频平台接入总量
已配置通道${formatNumber(configured)}当前页面可管理通道
在线通道${formatNumber(online)}视频连接状态正常
识别事件${formatNumber(eventCount)}当前智能分析事件
`;
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)} 路`;
}
const badge = document.querySelector("#view-video-platform .toolbar-actions .subtle-badge");
if (badge) {
const isApi = view.source === "api";
badge.classList.toggle("success", isApi);
badge.innerHTML = `${isApi ? "视频服务在线" : "演示数据"}`;
}
}
function renderVideoStatusOverview() {
const container = document.querySelector("#videoStatusOverview");
if (!container) return;
const view = videoPlatformState();
const moduleData = view.modules[state.videoModule];
const title = document.querySelector("#videoStatusTitle");
if (title) title.textContent = moduleData?.statusTitle || "状态总览";
const metrics = view.status[state.videoModule] || [];
if (!metrics.length) {
container.innerHTML = videoEmptyState(
"activity",
videoLoadingText(state.videoModule, "正在读取状态数据", "暂无状态数据")
);
window.injectIcons?.(container);
return;
}
container.innerHTML = metrics
.map(
(metric, index) => `
${formatNumber(metric.value)}
${escapeHtml(metric.label)}
${escapeHtml(metric.unit || "")}
`
)
.join("");
}
function filteredVideoChannels() {
const keyword = state.videoSearch.trim().toLowerCase();
const channels = videoPlatformChannels();
if (!keyword) return channels;
return channels.filter((channel) =>
`${channel.name} ${channel.location} ${channel.code}`.toLowerCase().includes(keyword)
);
}
function renderVideoDeviceTree() {
const tree = document.querySelector("#videoDeviceTree");
if (!tree) return;
const view = videoPlatformState();
const channels = filteredVideoChannels();
const count = document.querySelector("#videoChannelCount");
const configured = view.overview?.channel_configured ?? videoPlatformChannels().length;
const total = Math.max(videoSourceCount(state.monitoring, configured), configured);
if (count) count.textContent = `${formatNumber(channels.length)} / ${formatNumber(total)} 路`;
if (!channels.length) {
tree.innerHTML = videoEmptyState(
"search",
videoLoadingText(state.videoModule, "正在读取视频通道", "没有匹配的视频通道")
);
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();
});
});
}
function videoWallChannels() {
const all = videoPlatformChannels().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 = videoPlatformState().modules[state.videoModule];
const layoutNames = { 1: "单画面监控", 2: "双画面监控", 4: "四分屏监控", 6: "六分屏监控", 9: "九分屏监控" };
if (title) title.textContent = `${moduleData?.title || "工业视频平台"} · ${layoutNames[state.videoLayout] || "实时监控"}`;
const channels = videoWallChannels();
if (!channels.length) {
wall.innerHTML = videoEmptyState(
"video",
videoLoadingText(state.videoModule, "正在读取视频通道", "当前模块暂无视频通道")
);
window.injectIcons?.(wall);
return;
}
wall.innerHTML = channels
.map(
(channel) => `
${
channel.playbackUrl
? `
`
: `
${escapeHtml(channel.name)}
${channel.mediaAvailable ? "视频文件不可用" : "等待视频流接入"}
`
}
${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 = videoPlatformState().channels.find((item) => item.id === channelId);
if (!channel) return;
const tile = document.querySelector(`[data-video-tile="${channelId}"]`);
if (action === "fullscreen") {
tile?.requestFullscreen?.();
return;
}
const player = tile?.querySelector("video");
if (action === "play") {
if (!player) {
window.showToast?.(`${channel.name} 未配置可播放的视频流`, "warning");
return;
}
if (player.paused) {
player.play().catch(() => {});
} else {
player.pause();
}
return;
}
if (action === "mute") {
if (!player) {
window.showToast?.(`${channel.name} 未配置可播放的视频流`, "warning");
return;
}
player.muted = !player.muted;
window.showToast?.(`${channel.name} 已${player.muted ? "静音" : "取消静音"}`);
return;
}
if (action === "snapshot") {
if (!channel.snapshotUrl) {
window.showToast?.(`${channel.name} 暂无识别抓拍图像`, "warning");
return;
}
window.open(channel.snapshotUrl, "_blank", "noopener");
}
}
function renderVideoEventPagination(total) {
const footer = document.querySelector("#videoEventPagination");
if (!footer) return;
const view = videoPlatformState();
const pageCount = Math.max(1, Math.ceil(total / view.eventPageSize));
if (!total) {
footer.innerHTML = "";
return;
}
const current = Math.min(Math.max(view.eventPage, 1), pageCount);
view.eventPage = current;
const pageButtons = Array.from({ length: pageCount }, (_, index) => {
const page = index + 1;
return ``;
}).join("");
footer.innerHTML = `
共 ${formatNumber(total)} 条 · 第 ${current}/${pageCount} 页
${pageButtons}
`;
footer.querySelectorAll("[data-video-event-page]").forEach((button) => {
button.addEventListener("click", () => {
const page = Number(button.dataset.videoEventPage);
if (!Number.isFinite(page) || page < 1 || page > pageCount || page === current) return;
videoPlatformState().eventPage = page;
renderVideoEvents();
});
});
}
function renderVideoEvents() {
const list = document.querySelector("#videoEventList");
const count = document.querySelector("#videoEventCount");
if (!list) return;
const view = videoPlatformState();
const events = view.events[state.videoModule] || [];
if (count) count.textContent = `${formatNumber(events.length)} 条`;
renderVideoEventPagination(events.length);
if (!events.length) {
list.innerHTML = videoEmptyState(
"shield-alert",
videoLoadingText(state.videoModule, "正在读取分析事件", "暂无视频分析事件")
);
window.injectIcons?.(list);
return;
}
const pageCount = Math.max(1, Math.ceil(events.length / view.eventPageSize));
const current = Math.min(Math.max(view.eventPage, 1), pageCount);
view.eventPage = current;
const pageEvents = events.slice(
(current - 1) * view.eventPageSize,
current * view.eventPageSize
);
list.scrollTop = 0;
list.innerHTML = pageEvents
.map(
(item) => `
${escapeHtml(item.eventType)}${item.status ? ` · ${escapeHtml(item.status)}` : ""}
${escapeHtml(item.channelName ? `${item.channelName}:` : "")}${escapeHtml(item.description)}
`
)
.join("");
window.injectIcons?.(list);
}
function renderVideoThumbnails() {
const grid = document.querySelector("#videoThumbnailGrid");
const count = document.querySelector("#videoThumbnailCount");
if (!grid) return;
const view = videoPlatformState();
const snapshot = view.snapshots[state.videoModule] || { items: [], total: 0 };
if (count) count.textContent = `${formatNumber(snapshot.total || snapshot.items.length)} 张`;
if (!snapshot.items.length) {
grid.innerHTML = videoEmptyState(
"camera",
videoLoadingText(state.videoModule, "正在读取识别抓拍", "当前筛选条件下暂无抓拍图像")
);
window.injectIcons?.(grid);
return;
}
grid.innerHTML = snapshot.items
.map(
(item) => `
${item.thumbUrl ? `
})
` : `
`}
${escapeHtml(item.name)}
${escapeHtml(item.type)}${item.detail ? `|${escapeHtml(item.detail)}` : ""}
`
)
.join("");
window.injectIcons?.(grid);
}
function renderVideoFilterOptions() {
const view = videoPlatformState();
const config = view.modules[state.videoModule];
const typeLabel = document.querySelector("#videoRecordFilterLabel");
const typeSelect = document.querySelector("#videoRecordType");
const scopeLabel = document.querySelector("#videoScopeFilterLabel");
const scopeSelect = document.querySelector("#videoDeviceScope");
if (typeLabel) typeLabel.textContent = config?.typeFilterLabel || "类型";
if (typeSelect) {
const options = config?.typeFilterOptions?.length ? config.typeFilterOptions : ["全部类型"];
if (!options.includes(view.filters.type)) view.filters.type = options[0];
typeSelect.innerHTML = options
.map((option) => ``)
.join("");
typeSelect.value = view.filters.type;
}
if (scopeLabel) scopeLabel.textContent = config?.scopeFilterLabel || "设备";
if (scopeSelect) {
const options = config?.scopeFilterOptions?.length ? config.scopeFilterOptions : ["全部设备"];
if (!options.includes(view.filters.scope)) view.filters.scope = options[0];
scopeSelect.innerHTML = options
.map((option) => ``)
.join("");
scopeSelect.value = view.filters.scope;
}
const startInput = document.querySelector("#videoFilterStart");
const endInput = document.querySelector("#videoFilterEnd");
if (startInput && view.filters.start) startInput.value = view.filters.start;
if (endInput && view.filters.end) endInput.value = view.filters.end;
document.querySelectorAll("[data-video-quick-range]").forEach((button) => {
button.classList.toggle(
"active",
button.dataset.videoQuickRange === view.filters.quick
);
});
}
function renderVideoRecords() {
const tabs = document.querySelector("#videoRecordTabs");
const table = document.querySelector("#videoRecordsTable");
const title = document.querySelector("#videoRecordTitle");
if (!tabs || !table) return;
const view = videoPlatformState();
const config = view.modules[state.videoModule];
const moduleTabs = config?.tabs || [];
if (!moduleTabs.length) {
tabs.innerHTML = "";
table.innerHTML = videoEmptyState(
"list",
videoLoadingText(state.videoModule, "正在读取记录定义", "暂无记录定义")
);
window.injectIcons?.(table);
return;
}
const activeIndex = Math.min(state.videoRecordTab, moduleTabs.length - 1);
state.videoRecordTab = activeIndex;
const tab = moduleTabs[activeIndex];
if (title) title.textContent = tab.label;
tabs.innerHTML = moduleTabs
.map(
(item, index) => ``
)
.join("");
tabs.querySelectorAll("[data-video-record-tab]").forEach((button) => {
button.addEventListener("click", async () => {
state.videoRecordTab = Number(button.dataset.videoRecordTab);
renderVideoRecords();
await ensureVideoRecordTab(state.videoRecordTab);
renderVideoRecords();
});
});
const data = view.tables[state.videoModule]?.[tab.recordType];
const columns = data?.columns?.length ? data.columns : tab.columns;
if (!data?.rows?.length) {
table.innerHTML = videoEmptyState(
"list",
videoLoadingText(state.videoModule, "正在读取记录", "当前条件下暂无记录")
);
window.injectIcons?.(table);
return;
}
table.innerHTML = `
${columns.map((column) => `| ${escapeHtml(column)} | `).join("")}
${data.rows
.map(
(row) => `${row.map((value) => `| ${escapeHtml(value)} | `).join("")}
`
)
.join("")}
`;
}
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() {
renderVideoSummary();
renderVideoFilterOptions();
renderVideoStatusOverview();
renderVideoDeviceTree();
renderVideoWall();
renderVideoEvents();
renderVideoThumbnails();
renderVideoRecords();
document.querySelectorAll("[data-video-module]").forEach((button) => {
button.classList.toggle("active", button.dataset.videoModule === state.videoModule);
});
const note = document.querySelector("#videoStreamNote");
if (note) {
const playable = videoPlatformChannels().some((channel) => channel.playbackUrl);
note.textContent = playable
? "视频文件按后端登记的磁盘路径实时读取,分屏内可播放/暂停、静音、抓图与全屏;抓拍图像点击可查看原图。"
: "视频流地址尚未配置,当前展示通道状态与控制界面。";
}
window.requestAnimationFrame(syncVideoPanelHeights);
}
function installVideoPlatformActions() {
document.querySelector("[data-refresh-video-platform]")?.addEventListener("click", async () => {
await loadVideoPlatform(state.videoModule);
const isApi = videoPlatformState().source === "api";
window.showToast?.(
isApi ? "视频通道与识别数据已刷新" : "后端不可用,已使用本地演示数据",
isApi ? undefined : "warning"
);
});
document.querySelectorAll("[data-video-module]").forEach((button) => {
button.addEventListener("click", async () => {
state.videoModule = button.dataset.videoModule || "door";
state.videoRecordTab = 0;
videoPlatformState().eventPage = 1;
state.videoSelectedChannel = videoPlatformChannels()[0]?.id || state.videoSelectedChannel;
renderVideoPlatform();
await ensureVideoModuleLoaded(state.videoModule);
state.videoSelectedChannel = videoPlatformChannels()[0]?.id || state.videoSelectedChannel;
renderVideoPlatform();
});
});
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();
});
const startInput = document.querySelector("#videoFilterStart");
const endInput = document.querySelector("#videoFilterEnd");
[startInput, endInput].forEach((input) => {
input?.addEventListener("change", () => {
const view = videoPlatformState();
if (startInput?.value) view.filters.start = startInput.value;
if (endInput?.value) view.filters.end = endInput.value;
view.filters.quick = "";
renderVideoFilterOptions();
});
});
document.querySelectorAll("[data-video-quick-range]").forEach((button) => {
button.addEventListener("click", async () => {
const view = videoPlatformState();
view.filters.quick = button.dataset.videoQuickRange === "week" ? "week" : "today";
applyVideoDefaultRange({ reset: true });
await searchVideoPlatform({ silent: true });
window.showToast?.(
view.filters.quick === "week" ? "已切换为近 7 天范围" : "已切换为今日范围"
);
});
});
document.querySelector("[data-video-search]")?.addEventListener("click", () => {
searchVideoPlatform();
});
document.querySelector("[data-video-reset]")?.addEventListener("click", async () => {
const view = videoPlatformState();
state.videoSearch = "";
if (search) search.value = "";
state.videoRecordTab = 0;
view.eventPage = 1;
view.filters = { quick: "today", start: "", end: "", type: "", scope: "" };
applyVideoDefaultRange({ reset: true });
await searchVideoPlatform({ silent: true, fromInputs: false });
window.showToast?.("筛选条件已重置");
});
window.addEventListener("resize", () => {
window.requestAnimationFrame(syncVideoPanelHeights);
});
}
async function openVideoPlatform() {
window.navigateTo?.("video-platform");
renderVideoPlatform();
const view = videoPlatformState();
if (!view.loaded) {
await loadVideoPlatform(state.videoModule);
return;
}
await ensureVideoModuleLoaded(state.videoModule);
renderVideoPlatform();
}
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}>`);
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 = `
问题:${escapeHtml(question)}
思考过程思考中
${sources.map((source) => ``).join("")}
`;
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);
applyVideoDefaultRange({ reset: true });
if (state.apiAvailable) {
loadVideoPlatform(state.videoModule);
} else {
state.videoPlatform = buildVideoDemoPlatform();
renderVideoPlatform();
}
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
};
})();