app.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. const endpoints = {
  2. orders: "/api/orders",
  3. risks: "/api/risks",
  4. flow: "/api/flow",
  5. nodeTimes: "/api/node-times",
  6. cutoffs: "/api/order-cutoffs",
  7. exceptionMap: "/api/exception-map",
  8. };
  9. const labels = {
  10. orderNo: "订单号",
  11. customerName: "客户",
  12. status: "状态",
  13. currentNodeName: "当前节点",
  14. ownerName: "责任人",
  15. riskName: "风险",
  16. exceptionName: "异常",
  17. affectedNodeName: "影响节点",
  18. sequence: "顺序",
  19. nodeName: "节点",
  20. nextNodeName: "下一节点",
  21. deadlineName: "关键时限",
  22. plannedTime: "计划时间",
  23. cutoffTime: "截止时间",
  24. actualTime: "实际完成",
  25. isCompleted: "完成",
  26. isDelayed: "超时",
  27. customsCutoffTime: "截关",
  28. terminalCutoffTime: "截港",
  29. vgmCutoffTime: "VGM截止",
  30. estimatedSailingTime: "预计开船",
  31. };
  32. let currentRunId = null;
  33. let operationsLoaded = false;
  34. let marketOptions = null;
  35. let mockCatalog = null;
  36. let activeCatalog = "routes";
  37. document.querySelectorAll(".view-tab").forEach(button => {
  38. button.addEventListener("click", () => switchView(button.dataset.view));
  39. });
  40. document.getElementById("inquiryForm").addEventListener("submit", submitInquiry);
  41. document.getElementById("recommendBtn").addEventListener("click", submitInquiry);
  42. document.getElementById("refreshBtn").addEventListener("click", loadOperations);
  43. document.getElementById("auditBtn").addEventListener("click", openAudit);
  44. document.getElementById("generateMockBtn").addEventListener("click", generateMockBatch);
  45. document.getElementById("resetMockBtn").addEventListener("click", resetMockData);
  46. document.getElementById("origin").addEventListener("change", () => syncRouteControls(true));
  47. document.getElementById("destination").addEventListener("change", () => applyRouteDefaults(true));
  48. document.querySelectorAll(".catalog-tab").forEach(button => {
  49. button.addEventListener("click", () => selectCatalog(button.dataset.catalog));
  50. });
  51. document.getElementById("closeAuditBtn").addEventListener("click", () => {
  52. document.getElementById("auditDialog").close();
  53. });
  54. initialize();
  55. async function initialize() {
  56. try {
  57. const [health, options] = await Promise.all([
  58. fetchJson("/health"),
  59. fetchJson("/api/market/form-options"),
  60. ]);
  61. document.getElementById("systemStatus").textContent =
  62. `本地知识图谱 · ${numberFormat(health.triple_count)} triples`;
  63. marketOptions = options;
  64. populateInquiryForm(options);
  65. await requestRecommendation(options.defaults);
  66. } catch (error) {
  67. renderMarketError(error.message);
  68. }
  69. }
  70. function switchView(view) {
  71. document.querySelectorAll(".view-tab").forEach(button => {
  72. button.classList.toggle("is-active", button.dataset.view === view);
  73. });
  74. document.querySelectorAll(".app-view").forEach(section => {
  75. section.classList.toggle("is-active", section.id === `${view}View`);
  76. });
  77. if (view === "operations" && !operationsLoaded) loadOperations();
  78. if (view === "mock" && !mockCatalog) loadMockCatalog();
  79. }
  80. function populateInquiryForm(options) {
  81. populateSelect("customer", options.customers, "id", "name", options.defaults.customer);
  82. populateSelect("origin", options.origins, null, null, options.defaults.origin);
  83. syncRouteControls(false, options.defaults.destination);
  84. const defaultRoute = options.routes.find(route =>
  85. route.origin === options.defaults.origin && route.destination === options.defaults.destination
  86. );
  87. if (defaultRoute) {
  88. populateSelect(
  89. "routeType",
  90. defaultRoute.route_types,
  91. null,
  92. null,
  93. options.defaults.preferred_route_type,
  94. );
  95. }
  96. document.getElementById("cargoName").value = options.defaults.cargo_name;
  97. document.getElementById("cargoVolume").value = options.defaults.cargo_volume;
  98. document.getElementById("evaluationDate").value = options.defaults.evaluation_date;
  99. document.getElementById("departureDate").value = options.defaults.requested_departure_date;
  100. document.getElementById("maxTransitDays").value = options.defaults.max_transit_days;
  101. document.getElementById("targetAmount").value = options.defaults.target_amount;
  102. document.getElementById("dangerousGoods").checked = options.defaults.dangerous_goods;
  103. populateMockRouteSelect();
  104. }
  105. function syncRouteControls(updateDefaults, selectedDestination) {
  106. if (!marketOptions) return;
  107. const origin = document.getElementById("origin").value;
  108. const destinations = marketOptions.routes
  109. .filter(route => route.origin === origin)
  110. .map(route => route.destination);
  111. const preferredDestination = selectedDestination && destinations.includes(selectedDestination)
  112. ? selectedDestination
  113. : destinations[0];
  114. populateSelect("destination", destinations, null, null, preferredDestination);
  115. applyRouteDefaults(updateDefaults);
  116. }
  117. function applyRouteDefaults(updateValues) {
  118. if (!marketOptions) return;
  119. const origin = document.getElementById("origin").value;
  120. const destination = document.getElementById("destination").value;
  121. const route = marketOptions.routes.find(item => item.origin === origin && item.destination === destination);
  122. if (!route) return;
  123. const currentRouteType = document.getElementById("routeType").value;
  124. const preferredRouteType = route.route_types.includes(currentRouteType)
  125. ? currentRouteType
  126. : route.route_types[0];
  127. populateSelect("routeType", route.route_types, null, null, preferredRouteType);
  128. if (updateValues) {
  129. document.getElementById("targetAmount").value = route.target_amount;
  130. document.getElementById("departureDate").value = route.requested_departure_date;
  131. document.getElementById("maxTransitDays").value = Math.ceil(route.max_transit_days);
  132. }
  133. }
  134. function populateMockRouteSelect() {
  135. if (!marketOptions) return;
  136. const allRoutes = marketOptions.routes.map((route, index) => ({
  137. id: String(index),
  138. name: `${route.origin} → ${route.destination}`,
  139. }));
  140. const indiaRoutes = allRoutes.filter(route => route.name.includes("印度"));
  141. const routes = indiaRoutes.length ? indiaRoutes : allRoutes;
  142. populateSelect("mockRoute", routes, "id", "name", routes[0]?.id || "");
  143. }
  144. function populateSelect(id, values, valueKey, labelKey, selectedValue) {
  145. const select = document.getElementById(id);
  146. select.innerHTML = values.map(item => {
  147. const value = valueKey ? item[valueKey] : item;
  148. const label = labelKey ? item[labelKey] : item;
  149. return `<option value="${escapeHtml(value)}"${value === selectedValue ? " selected" : ""}>${escapeHtml(label)}</option>`;
  150. }).join("");
  151. }
  152. async function submitInquiry(event) {
  153. event.preventDefault();
  154. const form = new FormData(document.getElementById("inquiryForm"));
  155. const inquiry = {
  156. customer: form.get("customer"),
  157. origin: form.get("origin"),
  158. destination: form.get("destination"),
  159. cargo_name: form.get("cargo_name"),
  160. cargo_volume: Number(form.get("cargo_volume")),
  161. evaluation_date: form.get("evaluation_date"),
  162. requested_departure_date: form.get("requested_departure_date"),
  163. max_transit_days: Number(form.get("max_transit_days")),
  164. target_amount: Number(form.get("target_amount")),
  165. preferred_route_type: form.get("preferred_route_type"),
  166. dangerous_goods: document.getElementById("dangerousGoods").checked,
  167. salesperson_adjustment: 0,
  168. };
  169. await requestRecommendation(inquiry);
  170. }
  171. async function requestRecommendation(inquiry) {
  172. setRecommendationLoading(true);
  173. try {
  174. const result = await fetchJson("/api/market/recommend", {
  175. method: "POST",
  176. headers: { "Content-Type": "application/json" },
  177. body: JSON.stringify(inquiry),
  178. });
  179. currentRunId = result.run_id;
  180. renderMarketResult(result);
  181. } catch (error) {
  182. currentRunId = null;
  183. renderMarketError(error.message);
  184. } finally {
  185. setRecommendationLoading(false);
  186. }
  187. }
  188. function setRecommendationLoading(loading) {
  189. const button = document.getElementById("recommendBtn");
  190. button.disabled = loading;
  191. button.querySelector("span:first-child").textContent = loading ? "正在计算" : "生成市场方案";
  192. document.getElementById("inquiryState").textContent = loading ? "决策计算中" : "已标准化";
  193. if (loading) {
  194. document.getElementById("recommendationBand").className = "recommendation-band is-loading";
  195. document.getElementById("recommendationBand").innerHTML = `
  196. <div class="loading-block"><span class="loading-line"></span><span class="loading-line short"></span></div>`;
  197. }
  198. }
  199. function renderMarketResult(result) {
  200. const inquiry = result.normalized_inquiry;
  201. const recommendation = result.recommendation;
  202. document.getElementById("decisionTitle").textContent = `${inquiry.customer_name} · ${inquiry.route}`;
  203. document.getElementById("inquiryState").textContent = `任务 ${result.run_id}`;
  204. document.getElementById("auditBtn").disabled = false;
  205. renderPipeline(result.pipeline);
  206. renderRecommendation(result);
  207. renderCandidates(result.candidates);
  208. renderRejected(result.rejected_candidates);
  209. }
  210. function renderPipeline(pipeline) {
  211. const steps = [
  212. ["DISCOVER", pipeline.discovered, "发现候选", ""],
  213. ["REJECT", pipeline.rejected, "硬条件淘汰", "is-rejected"],
  214. ["FEASIBLE", pipeline.feasible, "通过可执行校验", "is-accent"],
  215. ["SOLUTIONS", pipeline.generated_solutions, "生成市场方案", "is-accent"],
  216. ];
  217. document.getElementById("pipeline").innerHTML = steps.map(([code, value, label, className]) => `
  218. <div class="pipeline-step ${className}">
  219. <span>${code}</span><strong>${escapeHtml(value)}</strong><em>${label}</em>
  220. </div>`).join("");
  221. }
  222. function renderRecommendation(result) {
  223. const recommendation = result.recommendation;
  224. const top = result.candidates.find(item => item.id === recommendation.candidate_id);
  225. const pricing = top.pricing;
  226. const context = result.market_context;
  227. const confidence = Math.round(recommendation.confidence_score * 100);
  228. const band = document.getElementById("recommendationBand");
  229. band.className = "recommendation-band";
  230. band.innerHTML = `
  231. <div class="recommendation-copy">
  232. <span class="recommendation-label">AI MARKET RECOMMENDATION</span>
  233. <h2>${escapeHtml(recommendation.solution_type)} · ${escapeHtml(recommendation.supplier_name)}</h2>
  234. <p>${escapeHtml(recommendation.summary)}</p>
  235. <div class="recommendation-tags">
  236. <span>${escapeHtml(top.route_type)}</span>
  237. <span>${escapeHtml(top.transit_days)} 天</span>
  238. <span>与 ${escapeHtml(top.supplier_name)} ${escapeHtml(top.relationship.tier)}</span>
  239. <span>${escapeHtml(top.spot_space.summary)}</span>
  240. <span>低价机会 ${percent(top.relationship.low_price_access_probability)}</span>
  241. <span>${escapeHtml(context.strategy_name)}</span>
  242. </div>
  243. </div>
  244. <div class="quote-block">
  245. <span>客户最终建议报价</span>
  246. <div class="quote-amount">${money(pricing.final_quote_amount)}<small>${escapeHtml(pricing.currency)}</small></div>
  247. <div class="quote-range">授权区间 ${money(pricing.allowed_quote_lower_amount)} – ${money(pricing.allowed_quote_upper_amount)}</div>
  248. <div class="confidence-row">
  249. <span>置信度 ${confidence}%</span>
  250. <div class="confidence-track"><span style="width:${confidence}%"></span></div>
  251. </div>
  252. </div>`;
  253. }
  254. function renderCandidates(candidates) {
  255. document.getElementById("candidateMeta").textContent = `${candidates.length} 套可执行方案 · 方案、供应商合作与现舱优先`;
  256. document.getElementById("candidateGrid").innerHTML = candidates.map((candidate, index) => {
  257. const typeClass = candidate.solution_type === "关系优选"
  258. ? "type-relationship"
  259. : candidate.solution_type === "价格参考"
  260. ? "type-price"
  261. : candidate.solution_type === "时效参考" ? "type-speed" : "type-primary";
  262. const riskClass = candidate.pricing.cost_risk_status.includes("底线")
  263. ? "is-danger"
  264. : candidate.pricing.cost_risk_status.includes("安全") ? "is-safe" : "";
  265. return `
  266. <article class="candidate-card ${typeClass}" style="animation-delay:${index * 70}ms">
  267. <div class="candidate-head">
  268. <div class="candidate-type-row">
  269. <span class="candidate-type">${escapeHtml(candidate.solution_type)}</span>
  270. <span class="candidate-rank">RANK ${candidate.rank}</span>
  271. </div>
  272. <h3>${escapeHtml(candidate.product_name)}</h3>
  273. <p class="candidate-supplier">${escapeHtml(candidate.supplier_name)} · ${escapeHtml(candidate.offering_channel)}</p>
  274. </div>
  275. <div class="candidate-facts">
  276. <div><span>预计时效</span><strong>${escapeHtml(candidate.transit_days)} 天</strong></div>
  277. <div><span>方案保障</span><strong>${candidate.solution_assurance_score}</strong></div>
  278. <div><span>供应商合作等级</span><strong>${escapeHtml(candidate.relationship.tier)}</strong></div>
  279. <div><span>现舱</span><strong>${escapeHtml(candidate.spot_space.status)}${candidate.spot_space.teu ? ` · ${escapeHtml(candidate.spot_space.teu)} TEU` : ""}</strong></div>
  280. </div>
  281. <div class="score-list">
  282. ${scoreRow("方案保障", candidate.solution_assurance_score)}
  283. ${scoreRow("关系优势", candidate.relationship_advantage_score)}
  284. ${scoreRow("现舱保障", candidate.spot_space_score)}
  285. ${scoreRow("供应履约", candidate.supplier_execution_score)}
  286. ${scoreRow("客户适配", candidate.customer_fit_score)}
  287. ${scoreRow("运输时效", candidate.transit_score)}
  288. ${scoreRow("价格参考", candidate.price_score)}
  289. </div>
  290. <div class="relationship-panel">
  291. <div class="relationship-supplier"><span>我方与供应商 ${escapeHtml(candidate.relationship.supplier_name)}(${escapeHtml(candidate.relationship.supplier_channel)})的合作关系</span><strong>${escapeHtml(candidate.relationship.tier)} · 供应商合作亲密度 ${percent(candidate.relationship.strength)}</strong></div>
  292. <div class="relationship-metric"><span>低价获取概率</span><strong>${percent(candidate.relationship.low_price_access_probability)}</strong></div>
  293. <div class="relationship-metric"><span>优先舱位概率</span><strong>${percent(candidate.relationship.priority_space_probability)}</strong></div>
  294. <div class="relationship-metric"><span>现舱状态</span><strong>${escapeHtml(candidate.spot_space.status)}</strong></div>
  295. <p>${escapeHtml(candidate.relationship.evidence)}</p>
  296. </div>
  297. <div class="candidate-reason">${candidate.reasons.slice(0, 4).map(escapeHtml).join(";")}</div>
  298. <div class="candidate-price">
  299. <div><span>建议客户报价</span><strong>${money(candidate.pricing.final_quote_amount)}</strong></div>
  300. <em class="risk-label ${riskClass}">${escapeHtml(candidate.pricing.cost_risk_status)}</em>
  301. </div>
  302. </article>`;
  303. }).join("");
  304. }
  305. function scoreRow(label, value) {
  306. const width = Math.max(0, Math.min(100, Number(value)));
  307. return `<div class="score-row"><span>${label}</span><div class="score-track"><span style="width:${width}%"></span></div><strong>${value}</strong></div>`;
  308. }
  309. function renderRejected(rejected) {
  310. document.getElementById("rejectedMeta").textContent = `${rejected.length} 个淘汰候选`;
  311. document.getElementById("rejectedList").innerHTML = rejected.length
  312. ? rejected.map(item => `
  313. <div class="rejected-row">
  314. <strong>${escapeHtml(item.product_name)}</strong>
  315. <span>${escapeHtml(item.supplier_name)} · ${escapeHtml(item.quote_no)}</span>
  316. <div class="rejected-reasons">${item.reasons.map(reason => `<em>${escapeHtml(reason)}</em>`).join("")}</div>
  317. </div>`).join("")
  318. : `<div class="empty">本次没有候选被硬条件淘汰</div>`;
  319. }
  320. function renderMarketError(message) {
  321. document.getElementById("decisionTitle").textContent = "当前询价无法生成可执行方案";
  322. document.getElementById("auditBtn").disabled = true;
  323. document.getElementById("pipeline").innerHTML = "";
  324. const band = document.getElementById("recommendationBand");
  325. band.className = "recommendation-band";
  326. band.innerHTML = `<div class="recommendation-copy"><span class="recommendation-label">DECISION STOPPED</span><h2>${escapeHtml(message)}</h2></div>`;
  327. document.getElementById("candidateGrid").innerHTML = "";
  328. document.getElementById("candidateMeta").textContent = "0 套可执行方案";
  329. }
  330. async function openAudit() {
  331. if (!currentRunId) return;
  332. const dialog = document.getElementById("auditDialog");
  333. const target = document.getElementById("auditContent");
  334. target.innerHTML = `<div class="empty">正在读取审计记录</div>`;
  335. dialog.showModal();
  336. try {
  337. const audit = await fetchJson(`/api/market/recommendations/${encodeURIComponent(currentRunId)}/audit`);
  338. const explanation = audit.business_explanation;
  339. target.innerHTML = `
  340. <section class="audit-decision">
  341. <span>本次怎么选</span>
  342. <p>${escapeHtml(explanation.decision)}</p>
  343. </section>
  344. <section class="audit-section">
  345. <h3>为什么优先推荐</h3>
  346. <ol class="audit-reason-list">${explanation.why_recommended.map(reason => `<li>${escapeHtml(reason)}</li>`).join("")}</ol>
  347. </section>
  348. <section class="audit-section">
  349. <h3>其他方案为什么保留</h3>
  350. <div class="audit-alternatives">${explanation.alternatives.map(item => `<div><strong>${escapeHtml(item.title)}</strong><p>${escapeHtml(item.detail)}</p></div>`).join("") || "<p>本次没有其他可执行备选方案。</p>"}</div>
  351. </section>
  352. <section class="audit-section">
  353. <h3>没有采用的方案</h3>
  354. <ul class="audit-rejection-list">${explanation.rejected.map(reason => `<li>${escapeHtml(reason)}</li>`).join("") || "<li>本次没有方案因硬条件被淘汰。</li>"}</ul>
  355. </section>
  356. <section class="audit-basis"><strong>判断口径</strong><p>${escapeHtml(explanation.selection_basis)}</p></section>`;
  357. } catch (error) {
  358. target.innerHTML = `<div class="error">${escapeHtml(error.message)}</div>`;
  359. }
  360. }
  361. function auditItem(label, value) {
  362. return `<div class="audit-item"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
  363. }
  364. async function loadMockCatalog() {
  365. const target = document.getElementById("mockCatalogTable");
  366. target.innerHTML = `<div class="empty">正在读取本地 RDF 数据</div>`;
  367. try {
  368. mockCatalog = await fetchJson("/api/mock/catalog");
  369. renderMockCatalog();
  370. } catch (error) {
  371. target.innerHTML = `<div class="error">${escapeHtml(error.message)}</div>`;
  372. }
  373. }
  374. function renderMockCatalog() {
  375. if (!mockCatalog) return;
  376. const summary = mockCatalog.summary;
  377. const stats = [
  378. ["RDF 三元组", summary.triple_count, summary.in_memory_triple_count ? `+${summary.in_memory_triple_count} 内存` : "基础图", summary.in_memory_triple_count > 0],
  379. ["客户", summary.customer_count, "Customer", false],
  380. ["供应商", summary.supplier_count, "Supplier", false],
  381. ["产品", summary.product_count, "ServiceProduct", false],
  382. ["报价", summary.quote_count, "SupplierQuote", false],
  383. ["路线", summary.route_count, "Route", false],
  384. ["启用策略", summary.strategy_count, "Strategy", false],
  385. ];
  386. document.getElementById("mockSummary").innerHTML = stats.map(([label, value, note, hasMemory]) => `
  387. <div class="data-stat ${hasMemory ? "has-memory" : ""}">
  388. <span>${escapeHtml(label)}</span><strong>${numberFormat(value)}</strong><em>${escapeHtml(note)}</em>
  389. </div>`).join("");
  390. document.getElementById("mockSources").innerHTML = mockCatalog.loaded_files
  391. .map(source => `<code>${escapeHtml(source)}</code>`).join("");
  392. document.getElementById("systemStatus").textContent = summary.in_memory_triple_count
  393. ? `本地知识图谱 · ${numberFormat(summary.triple_count)} triples · +${summary.in_memory_triple_count} 内存`
  394. : `本地知识图谱 · ${numberFormat(summary.triple_count)} triples`;
  395. renderActiveCatalogTable();
  396. }
  397. function selectCatalog(catalog) {
  398. activeCatalog = catalog;
  399. document.querySelectorAll(".catalog-tab").forEach(button => {
  400. button.classList.toggle("is-active", button.dataset.catalog === catalog);
  401. });
  402. renderActiveCatalogTable();
  403. }
  404. function renderActiveCatalogTable() {
  405. if (!mockCatalog) return;
  406. const definitions = {
  407. routes: {
  408. columns: [
  409. ["origin", "起运地"], ["destination", "目的地"], ["transport_modes", "运输方式"],
  410. ["product_count", "产品"], ["supplier_count", "供应商"], ["quote_count", "全部报价"],
  411. ["current_quote_count", "当前有效"],
  412. ],
  413. rows: mockCatalog.routes,
  414. },
  415. customers: {
  416. columns: [
  417. ["name", "客户"], ["industry", "行业"], ["preferred_route", "主要路线"],
  418. ["repeat_purchase_count", "复购"], ["price_sensitivity", "价格敏感"],
  419. ["time_sensitivity", "时效敏感"], ["reliability_sensitivity", "可靠性敏感"],
  420. ],
  421. rows: mockCatalog.customers,
  422. },
  423. quotes: {
  424. columns: [
  425. ["quote_no", "报价编号"], ["route", "路线"], ["transport_mode", "方式"],
  426. ["product_name", "产品"], ["supplier_name", "供应商"],
  427. ["relationship_tier", "供应商合作等级"], ["relationship_strength", "供应商合作亲密度"],
  428. ["low_price_access_probability", "低价获取"], ["priority_space_probability", "优先舱位"],
  429. ["spot_space_status", "现舱状态"], ["spot_space_teu", "现舱 TEU"],
  430. ["relationship_evidence", "合作依据"], ["supplier_cost_amount", "采购金额"],
  431. ["valid_to", "有效至"], ["status", "状态"],
  432. ],
  433. rows: mockCatalog.quotes,
  434. },
  435. strategies: {
  436. columns: [
  437. ["name", "策略"], ["route", "适用路线"], ["objective", "目标"],
  438. ["adjustment_rate", "价格调整"], ["valid_to", "有效至"],
  439. ],
  440. rows: mockCatalog.strategies,
  441. },
  442. };
  443. const definition = definitions[activeCatalog];
  444. document.getElementById("mockCatalogTable").innerHTML = renderCatalogTable(
  445. definition.rows,
  446. definition.columns,
  447. );
  448. }
  449. function renderCatalogTable(rows, columns) {
  450. if (!rows.length) return `<div class="empty">当前分类没有数据</div>`;
  451. const head = columns.map(([, label]) => `<th>${escapeHtml(label)}</th>`).join("");
  452. const body = rows.map(row => `
  453. <tr>${columns.map(([key]) => `<td>${formatCatalogCell(key, row[key], row)}</td>`).join("")}</tr>`
  454. ).join("");
  455. return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
  456. }
  457. function formatCatalogCell(key, value, row) {
  458. if ([
  459. "price_sensitivity", "time_sensitivity", "reliability_sensitivity",
  460. "relationship_strength", "low_price_access_probability", "priority_space_probability",
  461. ].includes(key)) {
  462. const width = Math.round(Number(value || 0) * 100);
  463. return `<div class="sensitivity-cell"><span>${width}%</span><div class="sensitivity-track"><span style="width:${width}%"></span></div></div>`;
  464. }
  465. if (key === "supplier_cost_amount") {
  466. return `${money(value)} ${escapeHtml(row.currency || "CNY")}${row.runtime_mock ? ` <span class="runtime-mark">内存</span>` : ""}`;
  467. }
  468. if (key === "spot_space_teu") return value ? `${Number(value)} TEU` : "待确认";
  469. if (key === "status") {
  470. const className = value === "当前有效" ? "status-current" : value === "已过期" ? "status-expired" : "";
  471. return `<span class="${className}">${escapeHtml(value)}</span>`;
  472. }
  473. if (key === "adjustment_rate") return percent(value);
  474. return escapeHtml(value ?? "");
  475. }
  476. async function generateMockBatch() {
  477. if (!marketOptions?.routes?.length) return;
  478. const button = document.getElementById("generateMockBtn");
  479. const route = marketOptions.routes[Number(document.getElementById("mockRoute").value)];
  480. if (!route) return;
  481. button.disabled = true;
  482. button.querySelector("span:first-child").textContent = "正在写入内存图";
  483. document.getElementById("mockStatus").textContent = "正在生成供应商、产品、班期、报价和客户画像";
  484. try {
  485. const result = await fetchJson("/api/mock/generate", {
  486. method: "POST",
  487. headers: { "Content-Type": "application/json" },
  488. body: JSON.stringify({
  489. origin: route.origin,
  490. destination: route.destination,
  491. supplier_count: Number(document.getElementById("mockSupplierCount").value),
  492. customer_count: Number(document.getElementById("mockCustomerCount").value),
  493. evaluation_date: document.getElementById("evaluationDate").value || "2026-07-20",
  494. }),
  495. });
  496. mockCatalog = result.catalog;
  497. document.getElementById("mockStatus").textContent =
  498. `批次 ${result.batch_id} 已增加 ${result.added_triples} 条内存三元组`;
  499. await refreshMarketOptions();
  500. renderMockCatalog();
  501. } catch (error) {
  502. document.getElementById("mockStatus").textContent = error.message;
  503. } finally {
  504. button.disabled = false;
  505. button.querySelector("span:first-child").textContent = "添加一批 Mock 数据";
  506. }
  507. }
  508. async function resetMockData() {
  509. const button = document.getElementById("resetMockBtn");
  510. button.disabled = true;
  511. try {
  512. mockCatalog = await fetchJson("/api/mock/reset", { method: "POST" });
  513. document.getElementById("mockStatus").textContent = "动态数据已清空,已恢复本地 TTL 基础图";
  514. await refreshMarketOptions();
  515. await requestRecommendation(marketOptions.defaults);
  516. renderMockCatalog();
  517. } catch (error) {
  518. document.getElementById("mockStatus").textContent = error.message;
  519. } finally {
  520. button.disabled = false;
  521. }
  522. }
  523. async function refreshMarketOptions() {
  524. marketOptions = await fetchJson("/api/market/form-options");
  525. populateInquiryForm(marketOptions);
  526. }
  527. async function loadOperations() {
  528. await Promise.all([
  529. loadTable("orders", endpoints.orders, ["orderNo", "customerName", "status", "currentNodeName", "ownerName"]),
  530. loadTable("risks", endpoints.risks, ["orderNo", "riskName", "exceptionName", "affectedNodeName"]),
  531. loadFlow(),
  532. loadTable("nodeTimes", endpoints.nodeTimes, ["sequence", "nodeName", "deadlineName", "plannedTime", "cutoffTime", "actualTime", "isCompleted", "isDelayed"]),
  533. loadTable("cutoffs", endpoints.cutoffs, ["orderNo", "customsCutoffTime", "terminalCutoffTime", "vgmCutoffTime", "estimatedSailingTime"]),
  534. loadTable("exceptionMap", endpoints.exceptionMap, ["exceptionName", "affectedNodeName", "riskName"]),
  535. ]);
  536. operationsLoaded = true;
  537. }
  538. async function fetchJson(url, options) {
  539. const response = await fetch(url, options);
  540. if (!response.ok) {
  541. let detail = `请求失败:HTTP ${response.status}`;
  542. try {
  543. const payload = await response.json();
  544. detail = payload.detail || detail;
  545. } catch (_) {
  546. // Preserve the HTTP message when the response is not JSON.
  547. }
  548. throw new Error(detail);
  549. }
  550. return response.json();
  551. }
  552. async function fetchRows(url) {
  553. const data = await fetchJson(url);
  554. return data.rows || [];
  555. }
  556. async function loadTable(id, url, columns) {
  557. const target = document.getElementById(id);
  558. const counter = document.getElementById(`${id}Count`);
  559. try {
  560. const rows = await fetchRows(url);
  561. if (counter) counter.textContent = rows.length;
  562. target.innerHTML = renderTable(rows, columns);
  563. } catch (error) {
  564. target.innerHTML = `<div class="error">本地RDF数据读取失败</div>`;
  565. }
  566. }
  567. async function loadFlow() {
  568. const target = document.getElementById("flow");
  569. try {
  570. const rows = await fetchRows(endpoints.flow);
  571. if (!rows.length) {
  572. target.innerHTML = `<div class="empty">暂无流程数据</div>`;
  573. return;
  574. }
  575. target.innerHTML = rows.map(row => `
  576. <div class="step">
  577. <strong>${escapeHtml(row.sequence)}. ${escapeHtml(row.nodeName)}</strong>
  578. <span>${row.deadlineName ? `时限:${escapeHtml(row.deadlineName)}` : "无关键时限"}</span>
  579. <span>${row.nextNodeName ? `下一步:${escapeHtml(row.nextNodeName)}` : "流程结束"}</span>
  580. </div>`).join("");
  581. } catch (error) {
  582. target.innerHTML = `<div class="error">流程读取失败</div>`;
  583. }
  584. }
  585. function renderTable(rows, columns) {
  586. if (!rows.length) return `<div class="empty">暂无数据</div>`;
  587. const head = columns.map(column => `<th>${labels[column] || column}</th>`).join("");
  588. const body = rows.map(row => `
  589. <tr>${columns.map(column => `<td>${formatCell(column, row[column])}</td>`).join("")}</tr>`).join("");
  590. return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
  591. }
  592. function formatCell(column, value) {
  593. if (!value) return "";
  594. if (column === "isDelayed" && value === "true") return `<span class="badge-danger">是</span>`;
  595. if (column === "isDelayed" && value === "false") return "否";
  596. if (column === "isCompleted" && value === "true") return "是";
  597. if (column === "isCompleted" && value === "false") return `<span class="badge-warning">否</span>`;
  598. if (column.endsWith("Time")) return escapeHtml(value.replace("T", " ").replace("+08:00", ""));
  599. return escapeHtml(value);
  600. }
  601. function money(value) {
  602. return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value));
  603. }
  604. function numberFormat(value) {
  605. return new Intl.NumberFormat("zh-CN").format(Number(value));
  606. }
  607. function percent(value) {
  608. return `${Math.round(Number(value) * 100)}%`;
  609. }
  610. function shortDate(value) {
  611. return value ? value.slice(5).replace("-", "/") : "待确认";
  612. }
  613. function escapeHtml(value) {
  614. return String(value ?? "")
  615. .replaceAll("&", "&amp;")
  616. .replaceAll("<", "&lt;")
  617. .replaceAll(">", "&gt;")
  618. .replaceAll('"', "&quot;")
  619. .replaceAll("'", "&#039;");
  620. }