|
@@ -31,10 +31,503 @@ const labels = {
|
|
|
estimatedSailingTime: "预计开船",
|
|
estimatedSailingTime: "预计开船",
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-document.getElementById("refreshBtn").addEventListener("click", loadAll);
|
|
|
|
|
-loadAll();
|
|
|
|
|
|
|
+let currentRunId = null;
|
|
|
|
|
+let operationsLoaded = false;
|
|
|
|
|
+let marketOptions = null;
|
|
|
|
|
+let mockCatalog = null;
|
|
|
|
|
+let activeCatalog = "routes";
|
|
|
|
|
|
|
|
-async function loadAll() {
|
|
|
|
|
|
|
+document.querySelectorAll(".view-tab").forEach(button => {
|
|
|
|
|
+ button.addEventListener("click", () => switchView(button.dataset.view));
|
|
|
|
|
+});
|
|
|
|
|
+document.getElementById("inquiryForm").addEventListener("submit", submitInquiry);
|
|
|
|
|
+document.getElementById("recommendBtn").addEventListener("click", submitInquiry);
|
|
|
|
|
+document.getElementById("refreshBtn").addEventListener("click", loadOperations);
|
|
|
|
|
+document.getElementById("auditBtn").addEventListener("click", openAudit);
|
|
|
|
|
+document.getElementById("generateMockBtn").addEventListener("click", generateMockBatch);
|
|
|
|
|
+document.getElementById("resetMockBtn").addEventListener("click", resetMockData);
|
|
|
|
|
+document.getElementById("origin").addEventListener("change", () => syncRouteControls(true));
|
|
|
|
|
+document.getElementById("destination").addEventListener("change", () => applyRouteDefaults(true));
|
|
|
|
|
+document.querySelectorAll(".catalog-tab").forEach(button => {
|
|
|
|
|
+ button.addEventListener("click", () => selectCatalog(button.dataset.catalog));
|
|
|
|
|
+});
|
|
|
|
|
+document.getElementById("closeAuditBtn").addEventListener("click", () => {
|
|
|
|
|
+ document.getElementById("auditDialog").close();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+initialize();
|
|
|
|
|
+
|
|
|
|
|
+async function initialize() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const [health, options] = await Promise.all([
|
|
|
|
|
+ fetchJson("/health"),
|
|
|
|
|
+ fetchJson("/api/market/form-options"),
|
|
|
|
|
+ ]);
|
|
|
|
|
+ document.getElementById("systemStatus").textContent =
|
|
|
|
|
+ `本地知识图谱 · ${numberFormat(health.triple_count)} triples`;
|
|
|
|
|
+ marketOptions = options;
|
|
|
|
|
+ populateInquiryForm(options);
|
|
|
|
|
+ await requestRecommendation(options.defaults);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ renderMarketError(error.message);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function switchView(view) {
|
|
|
|
|
+ document.querySelectorAll(".view-tab").forEach(button => {
|
|
|
|
|
+ button.classList.toggle("is-active", button.dataset.view === view);
|
|
|
|
|
+ });
|
|
|
|
|
+ document.querySelectorAll(".app-view").forEach(section => {
|
|
|
|
|
+ section.classList.toggle("is-active", section.id === `${view}View`);
|
|
|
|
|
+ });
|
|
|
|
|
+ if (view === "operations" && !operationsLoaded) loadOperations();
|
|
|
|
|
+ if (view === "mock" && !mockCatalog) loadMockCatalog();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function populateInquiryForm(options) {
|
|
|
|
|
+ populateSelect("customer", options.customers, "id", "name", options.defaults.customer);
|
|
|
|
|
+ populateSelect("origin", options.origins, null, null, options.defaults.origin);
|
|
|
|
|
+ syncRouteControls(false, options.defaults.destination);
|
|
|
|
|
+ const defaultRoute = options.routes.find(route =>
|
|
|
|
|
+ route.origin === options.defaults.origin && route.destination === options.defaults.destination
|
|
|
|
|
+ );
|
|
|
|
|
+ if (defaultRoute) {
|
|
|
|
|
+ populateSelect(
|
|
|
|
|
+ "routeType",
|
|
|
|
|
+ defaultRoute.route_types,
|
|
|
|
|
+ null,
|
|
|
|
|
+ null,
|
|
|
|
|
+ options.defaults.preferred_route_type,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ document.getElementById("cargoName").value = options.defaults.cargo_name;
|
|
|
|
|
+ document.getElementById("cargoVolume").value = options.defaults.cargo_volume;
|
|
|
|
|
+ document.getElementById("evaluationDate").value = options.defaults.evaluation_date;
|
|
|
|
|
+ document.getElementById("departureDate").value = options.defaults.requested_departure_date;
|
|
|
|
|
+ document.getElementById("maxTransitDays").value = options.defaults.max_transit_days;
|
|
|
|
|
+ document.getElementById("targetAmount").value = options.defaults.target_amount;
|
|
|
|
|
+ document.getElementById("dangerousGoods").checked = options.defaults.dangerous_goods;
|
|
|
|
|
+ populateMockRouteSelect();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function syncRouteControls(updateDefaults, selectedDestination) {
|
|
|
|
|
+ if (!marketOptions) return;
|
|
|
|
|
+ const origin = document.getElementById("origin").value;
|
|
|
|
|
+ const destinations = marketOptions.routes
|
|
|
|
|
+ .filter(route => route.origin === origin)
|
|
|
|
|
+ .map(route => route.destination);
|
|
|
|
|
+ const preferredDestination = selectedDestination && destinations.includes(selectedDestination)
|
|
|
|
|
+ ? selectedDestination
|
|
|
|
|
+ : destinations[0];
|
|
|
|
|
+ populateSelect("destination", destinations, null, null, preferredDestination);
|
|
|
|
|
+ applyRouteDefaults(updateDefaults);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function applyRouteDefaults(updateValues) {
|
|
|
|
|
+ if (!marketOptions) return;
|
|
|
|
|
+ const origin = document.getElementById("origin").value;
|
|
|
|
|
+ const destination = document.getElementById("destination").value;
|
|
|
|
|
+ const route = marketOptions.routes.find(item => item.origin === origin && item.destination === destination);
|
|
|
|
|
+ if (!route) return;
|
|
|
|
|
+ const currentRouteType = document.getElementById("routeType").value;
|
|
|
|
|
+ const preferredRouteType = route.route_types.includes(currentRouteType)
|
|
|
|
|
+ ? currentRouteType
|
|
|
|
|
+ : route.route_types[0];
|
|
|
|
|
+ populateSelect("routeType", route.route_types, null, null, preferredRouteType);
|
|
|
|
|
+ if (updateValues) {
|
|
|
|
|
+ document.getElementById("targetAmount").value = route.target_amount;
|
|
|
|
|
+ document.getElementById("departureDate").value = route.requested_departure_date;
|
|
|
|
|
+ document.getElementById("maxTransitDays").value = Math.ceil(route.max_transit_days);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function populateMockRouteSelect() {
|
|
|
|
|
+ if (!marketOptions) return;
|
|
|
|
|
+ const routes = marketOptions.routes.map((route, index) => ({
|
|
|
|
|
+ id: String(index),
|
|
|
|
|
+ name: `${route.origin} → ${route.destination}`,
|
|
|
|
|
+ }));
|
|
|
|
|
+ populateSelect("mockRoute", routes, "id", "name", "0");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function populateSelect(id, values, valueKey, labelKey, selectedValue) {
|
|
|
|
|
+ const select = document.getElementById(id);
|
|
|
|
|
+ select.innerHTML = values.map(item => {
|
|
|
|
|
+ const value = valueKey ? item[valueKey] : item;
|
|
|
|
|
+ const label = labelKey ? item[labelKey] : item;
|
|
|
|
|
+ return `<option value="${escapeHtml(value)}"${value === selectedValue ? " selected" : ""}>${escapeHtml(label)}</option>`;
|
|
|
|
|
+ }).join("");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function submitInquiry(event) {
|
|
|
|
|
+ event.preventDefault();
|
|
|
|
|
+ const form = new FormData(document.getElementById("inquiryForm"));
|
|
|
|
|
+ const inquiry = {
|
|
|
|
|
+ customer: form.get("customer"),
|
|
|
|
|
+ origin: form.get("origin"),
|
|
|
|
|
+ destination: form.get("destination"),
|
|
|
|
|
+ cargo_name: form.get("cargo_name"),
|
|
|
|
|
+ cargo_volume: Number(form.get("cargo_volume")),
|
|
|
|
|
+ evaluation_date: form.get("evaluation_date"),
|
|
|
|
|
+ requested_departure_date: form.get("requested_departure_date"),
|
|
|
|
|
+ max_transit_days: Number(form.get("max_transit_days")),
|
|
|
|
|
+ target_amount: Number(form.get("target_amount")),
|
|
|
|
|
+ preferred_route_type: form.get("preferred_route_type"),
|
|
|
|
|
+ dangerous_goods: document.getElementById("dangerousGoods").checked,
|
|
|
|
|
+ salesperson_adjustment: 0,
|
|
|
|
|
+ };
|
|
|
|
|
+ await requestRecommendation(inquiry);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function requestRecommendation(inquiry) {
|
|
|
|
|
+ setRecommendationLoading(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await fetchJson("/api/market/recommend", {
|
|
|
|
|
+ method: "POST",
|
|
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
|
|
+ body: JSON.stringify(inquiry),
|
|
|
|
|
+ });
|
|
|
|
|
+ currentRunId = result.run_id;
|
|
|
|
|
+ renderMarketResult(result);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ currentRunId = null;
|
|
|
|
|
+ renderMarketError(error.message);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setRecommendationLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setRecommendationLoading(loading) {
|
|
|
|
|
+ const button = document.getElementById("recommendBtn");
|
|
|
|
|
+ button.disabled = loading;
|
|
|
|
|
+ button.querySelector("span:first-child").textContent = loading ? "正在计算" : "生成市场方案";
|
|
|
|
|
+ document.getElementById("inquiryState").textContent = loading ? "决策计算中" : "已标准化";
|
|
|
|
|
+ if (loading) {
|
|
|
|
|
+ document.getElementById("recommendationBand").className = "recommendation-band is-loading";
|
|
|
|
|
+ document.getElementById("recommendationBand").innerHTML = `
|
|
|
|
|
+ <div class="loading-block"><span class="loading-line"></span><span class="loading-line short"></span></div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderMarketResult(result) {
|
|
|
|
|
+ const inquiry = result.normalized_inquiry;
|
|
|
|
|
+ const recommendation = result.recommendation;
|
|
|
|
|
+ document.getElementById("decisionTitle").textContent = `${inquiry.customer_name} · ${inquiry.route}`;
|
|
|
|
|
+ document.getElementById("inquiryState").textContent = `任务 ${result.run_id}`;
|
|
|
|
|
+ document.getElementById("auditBtn").disabled = false;
|
|
|
|
|
+ renderPipeline(result.pipeline);
|
|
|
|
|
+ renderRecommendation(result);
|
|
|
|
|
+ renderCandidates(result.candidates);
|
|
|
|
|
+ renderRejected(result.rejected_candidates);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderPipeline(pipeline) {
|
|
|
|
|
+ const steps = [
|
|
|
|
|
+ ["DISCOVER", pipeline.discovered, "发现候选", ""],
|
|
|
|
|
+ ["REJECT", pipeline.rejected, "硬条件淘汰", "is-rejected"],
|
|
|
|
|
+ ["FEASIBLE", pipeline.feasible, "通过可执行校验", "is-accent"],
|
|
|
|
|
+ ["SOLUTIONS", pipeline.generated_solutions, "生成市场方案", "is-accent"],
|
|
|
|
|
+ ];
|
|
|
|
|
+ document.getElementById("pipeline").innerHTML = steps.map(([code, value, label, className]) => `
|
|
|
|
|
+ <div class="pipeline-step ${className}">
|
|
|
|
|
+ <span>${code}</span><strong>${escapeHtml(value)}</strong><em>${label}</em>
|
|
|
|
|
+ </div>`).join("");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderRecommendation(result) {
|
|
|
|
|
+ const recommendation = result.recommendation;
|
|
|
|
|
+ const top = result.candidates.find(item => item.id === recommendation.candidate_id);
|
|
|
|
|
+ const pricing = top.pricing;
|
|
|
|
|
+ const context = result.market_context;
|
|
|
|
|
+ const confidence = Math.round(recommendation.confidence_score * 100);
|
|
|
|
|
+ const band = document.getElementById("recommendationBand");
|
|
|
|
|
+ band.className = "recommendation-band";
|
|
|
|
|
+ band.innerHTML = `
|
|
|
|
|
+ <div class="recommendation-copy">
|
|
|
|
|
+ <span class="recommendation-label">AI MARKET RECOMMENDATION</span>
|
|
|
|
|
+ <h2>${escapeHtml(recommendation.solution_type)} · ${escapeHtml(recommendation.supplier_name)}</h2>
|
|
|
|
|
+ <p>${escapeHtml(recommendation.summary)}</p>
|
|
|
|
|
+ <div class="recommendation-tags">
|
|
|
|
|
+ <span>${escapeHtml(top.route_type)}</span>
|
|
|
|
|
+ <span>${escapeHtml(top.transit_days)} 天</span>
|
|
|
|
|
+ <span>可靠性 ${percent(top.reliability_rate)}</span>
|
|
|
|
|
+ <span>${escapeHtml(context.strategy_name)}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="quote-block">
|
|
|
|
|
+ <span>客户最终建议报价</span>
|
|
|
|
|
+ <div class="quote-amount">${money(pricing.final_quote_amount)}<small>${escapeHtml(pricing.currency)}</small></div>
|
|
|
|
|
+ <div class="quote-range">授权区间 ${money(pricing.allowed_quote_lower_amount)} – ${money(pricing.allowed_quote_upper_amount)}</div>
|
|
|
|
|
+ <div class="confidence-row">
|
|
|
|
|
+ <span>置信度 ${confidence}%</span>
|
|
|
|
|
+ <div class="confidence-track"><span style="width:${confidence}%"></span></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderCandidates(candidates) {
|
|
|
|
|
+ document.getElementById("candidateMeta").textContent = `${candidates.length} 套可执行方案 · 按综合得分排序`;
|
|
|
|
|
+ document.getElementById("candidateGrid").innerHTML = candidates.map((candidate, index) => {
|
|
|
|
|
+ const typeClass = candidate.solution_type === "经济方案"
|
|
|
|
|
+ ? "type-economy"
|
|
|
|
|
+ : candidate.solution_type === "时效方案" ? "type-speed" : "type-balanced";
|
|
|
|
|
+ const riskClass = candidate.pricing.cost_risk_status.includes("底线")
|
|
|
|
|
+ ? "is-danger"
|
|
|
|
|
+ : candidate.pricing.cost_risk_status.includes("安全") ? "is-safe" : "";
|
|
|
|
|
+ return `
|
|
|
|
|
+ <article class="candidate-card ${typeClass}" style="animation-delay:${index * 70}ms">
|
|
|
|
|
+ <div class="candidate-head">
|
|
|
|
|
+ <div class="candidate-type-row">
|
|
|
|
|
+ <span class="candidate-type">${escapeHtml(candidate.solution_type)}</span>
|
|
|
|
|
+ <span class="candidate-rank">RANK ${candidate.rank}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <h3>${escapeHtml(candidate.product_name)}</h3>
|
|
|
|
|
+ <p class="candidate-supplier">${escapeHtml(candidate.supplier_name)} · ${escapeHtml(candidate.offering_channel)}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="candidate-facts">
|
|
|
|
|
+ <div><span>预计时效</span><strong>${escapeHtml(candidate.transit_days)} 天</strong></div>
|
|
|
|
|
+ <div><span>预计开航</span><strong>${shortDate(candidate.departure_date)}</strong></div>
|
|
|
|
|
+ <div><span>综合得分</span><strong>${candidate.total_score}</strong></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="score-list">
|
|
|
|
|
+ ${scoreRow("客户匹配", candidate.customer_fit_score)}
|
|
|
|
|
+ ${scoreRow("价格竞争", candidate.price_score)}
|
|
|
|
|
+ ${scoreRow("运输时效", candidate.transit_score)}
|
|
|
|
|
+ ${scoreRow("供应履约", candidate.supplier_execution_score)}
|
|
|
|
|
+ ${scoreRow("产品策略", candidate.product_strategy_score)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="candidate-reason">${candidate.reasons.slice(0, 2).map(escapeHtml).join(";")}</div>
|
|
|
|
|
+ <div class="candidate-price">
|
|
|
|
|
+ <div><span>建议客户报价</span><strong>${money(candidate.pricing.final_quote_amount)}</strong></div>
|
|
|
|
|
+ <em class="risk-label ${riskClass}">${escapeHtml(candidate.pricing.cost_risk_status)}</em>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </article>`;
|
|
|
|
|
+ }).join("");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function scoreRow(label, value) {
|
|
|
|
|
+ const width = Math.max(0, Math.min(100, Number(value)));
|
|
|
|
|
+ return `<div class="score-row"><span>${label}</span><div class="score-track"><span style="width:${width}%"></span></div><strong>${value}</strong></div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderRejected(rejected) {
|
|
|
|
|
+ document.getElementById("rejectedMeta").textContent = `${rejected.length} 个淘汰候选`;
|
|
|
|
|
+ document.getElementById("rejectedList").innerHTML = rejected.length
|
|
|
|
|
+ ? rejected.map(item => `
|
|
|
|
|
+ <div class="rejected-row">
|
|
|
|
|
+ <strong>${escapeHtml(item.product_name)}</strong>
|
|
|
|
|
+ <span>${escapeHtml(item.supplier_name)} · ${escapeHtml(item.quote_no)}</span>
|
|
|
|
|
+ <div class="rejected-reasons">${item.reasons.map(reason => `<em>${escapeHtml(reason)}</em>`).join("")}</div>
|
|
|
|
|
+ </div>`).join("")
|
|
|
|
|
+ : `<div class="empty">本次没有候选被硬条件淘汰</div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderMarketError(message) {
|
|
|
|
|
+ document.getElementById("decisionTitle").textContent = "当前询价无法生成可执行方案";
|
|
|
|
|
+ document.getElementById("auditBtn").disabled = true;
|
|
|
|
|
+ document.getElementById("pipeline").innerHTML = "";
|
|
|
|
|
+ const band = document.getElementById("recommendationBand");
|
|
|
|
|
+ band.className = "recommendation-band";
|
|
|
|
|
+ band.innerHTML = `<div class="recommendation-copy"><span class="recommendation-label">DECISION STOPPED</span><h2>${escapeHtml(message)}</h2></div>`;
|
|
|
|
|
+ document.getElementById("candidateGrid").innerHTML = "";
|
|
|
|
|
+ document.getElementById("candidateMeta").textContent = "0 套可执行方案";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function openAudit() {
|
|
|
|
|
+ if (!currentRunId) return;
|
|
|
|
|
+ const dialog = document.getElementById("auditDialog");
|
|
|
|
|
+ const target = document.getElementById("auditContent");
|
|
|
|
|
+ target.innerHTML = `<div class="empty">正在读取审计记录</div>`;
|
|
|
|
|
+ dialog.showModal();
|
|
|
|
|
+ try {
|
|
|
|
|
+ const audit = await fetchJson(`/api/market/recommendations/${encodeURIComponent(currentRunId)}/audit`);
|
|
|
|
|
+ target.innerHTML = `
|
|
|
|
|
+ <div class="audit-grid">
|
|
|
|
|
+ ${auditItem("推荐任务", audit.run_id)}
|
|
|
|
|
+ ${auditItem("生成时间", audit.generated_at)}
|
|
|
|
|
+ ${auditItem("客户与路线", `${audit.input.customer_name} · ${audit.input.route}`)}
|
|
|
|
|
+ ${auditItem("候选结果", `${audit.candidate_count} 个通过 / ${audit.rejected_count} 个淘汰`)}
|
|
|
|
|
+ ${auditItem("市场策略", audit.market_context.strategy_name)}
|
|
|
|
|
+ ${auditItem("RDF决策断言", `${audit.rdf_assertions} 条`)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="audit-sources">
|
|
|
|
|
+ <h3>本次决策数据来源</h3>
|
|
|
|
|
+ ${audit.sources.map(source => `<code>${escapeHtml(source)}</code>`).join("")}
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ target.innerHTML = `<div class="error">${escapeHtml(error.message)}</div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function auditItem(label, value) {
|
|
|
|
|
+ return `<div class="audit-item"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadMockCatalog() {
|
|
|
|
|
+ const target = document.getElementById("mockCatalogTable");
|
|
|
|
|
+ target.innerHTML = `<div class="empty">正在读取本地 RDF 数据</div>`;
|
|
|
|
|
+ try {
|
|
|
|
|
+ mockCatalog = await fetchJson("/api/mock/catalog");
|
|
|
|
|
+ renderMockCatalog();
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ target.innerHTML = `<div class="error">${escapeHtml(error.message)}</div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderMockCatalog() {
|
|
|
|
|
+ if (!mockCatalog) return;
|
|
|
|
|
+ const summary = mockCatalog.summary;
|
|
|
|
|
+ const stats = [
|
|
|
|
|
+ ["RDF 三元组", summary.triple_count, summary.in_memory_triple_count ? `+${summary.in_memory_triple_count} 内存` : "基础图", summary.in_memory_triple_count > 0],
|
|
|
|
|
+ ["客户", summary.customer_count, "Customer", false],
|
|
|
|
|
+ ["供应商", summary.supplier_count, "Supplier", false],
|
|
|
|
|
+ ["产品", summary.product_count, "ServiceProduct", false],
|
|
|
|
|
+ ["报价", summary.quote_count, "SupplierQuote", false],
|
|
|
|
|
+ ["路线", summary.route_count, "Route", false],
|
|
|
|
|
+ ["启用策略", summary.strategy_count, "Strategy", false],
|
|
|
|
|
+ ];
|
|
|
|
|
+ document.getElementById("mockSummary").innerHTML = stats.map(([label, value, note, hasMemory]) => `
|
|
|
|
|
+ <div class="data-stat ${hasMemory ? "has-memory" : ""}">
|
|
|
|
|
+ <span>${escapeHtml(label)}</span><strong>${numberFormat(value)}</strong><em>${escapeHtml(note)}</em>
|
|
|
|
|
+ </div>`).join("");
|
|
|
|
|
+ document.getElementById("mockSources").innerHTML = mockCatalog.loaded_files
|
|
|
|
|
+ .map(source => `<code>${escapeHtml(source)}</code>`).join("");
|
|
|
|
|
+ document.getElementById("systemStatus").textContent = summary.in_memory_triple_count
|
|
|
|
|
+ ? `本地知识图谱 · ${numberFormat(summary.triple_count)} triples · +${summary.in_memory_triple_count} 内存`
|
|
|
|
|
+ : `本地知识图谱 · ${numberFormat(summary.triple_count)} triples`;
|
|
|
|
|
+ renderActiveCatalogTable();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function selectCatalog(catalog) {
|
|
|
|
|
+ activeCatalog = catalog;
|
|
|
|
|
+ document.querySelectorAll(".catalog-tab").forEach(button => {
|
|
|
|
|
+ button.classList.toggle("is-active", button.dataset.catalog === catalog);
|
|
|
|
|
+ });
|
|
|
|
|
+ renderActiveCatalogTable();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderActiveCatalogTable() {
|
|
|
|
|
+ if (!mockCatalog) return;
|
|
|
|
|
+ const definitions = {
|
|
|
|
|
+ routes: {
|
|
|
|
|
+ columns: [
|
|
|
|
|
+ ["origin", "起运地"], ["destination", "目的地"], ["transport_modes", "运输方式"],
|
|
|
|
|
+ ["product_count", "产品"], ["supplier_count", "供应商"], ["quote_count", "全部报价"],
|
|
|
|
|
+ ["current_quote_count", "当前有效"],
|
|
|
|
|
+ ],
|
|
|
|
|
+ rows: mockCatalog.routes,
|
|
|
|
|
+ },
|
|
|
|
|
+ customers: {
|
|
|
|
|
+ columns: [
|
|
|
|
|
+ ["name", "客户"], ["industry", "行业"], ["preferred_route", "主要路线"],
|
|
|
|
|
+ ["repeat_purchase_count", "复购"], ["price_sensitivity", "价格敏感"],
|
|
|
|
|
+ ["time_sensitivity", "时效敏感"], ["reliability_sensitivity", "可靠性敏感"],
|
|
|
|
|
+ ],
|
|
|
|
|
+ rows: mockCatalog.customers,
|
|
|
|
|
+ },
|
|
|
|
|
+ quotes: {
|
|
|
|
|
+ columns: [
|
|
|
|
|
+ ["quote_no", "报价编号"], ["route", "路线"], ["transport_mode", "方式"],
|
|
|
|
|
+ ["product_name", "产品"], ["supplier_name", "供应商"],
|
|
|
|
|
+ ["supplier_cost_amount", "采购金额"], ["valid_to", "有效至"], ["status", "状态"],
|
|
|
|
|
+ ],
|
|
|
|
|
+ rows: mockCatalog.quotes,
|
|
|
|
|
+ },
|
|
|
|
|
+ strategies: {
|
|
|
|
|
+ columns: [
|
|
|
|
|
+ ["name", "策略"], ["route", "适用路线"], ["objective", "目标"],
|
|
|
|
|
+ ["adjustment_rate", "价格调整"], ["valid_to", "有效至"],
|
|
|
|
|
+ ],
|
|
|
|
|
+ rows: mockCatalog.strategies,
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ const definition = definitions[activeCatalog];
|
|
|
|
|
+ document.getElementById("mockCatalogTable").innerHTML = renderCatalogTable(
|
|
|
|
|
+ definition.rows,
|
|
|
|
|
+ definition.columns,
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderCatalogTable(rows, columns) {
|
|
|
|
|
+ if (!rows.length) return `<div class="empty">当前分类没有数据</div>`;
|
|
|
|
|
+ const head = columns.map(([, label]) => `<th>${escapeHtml(label)}</th>`).join("");
|
|
|
|
|
+ const body = rows.map(row => `
|
|
|
|
|
+ <tr>${columns.map(([key]) => `<td>${formatCatalogCell(key, row[key], row)}</td>`).join("")}</tr>`
|
|
|
|
|
+ ).join("");
|
|
|
|
|
+ return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function formatCatalogCell(key, value, row) {
|
|
|
|
|
+ if (["price_sensitivity", "time_sensitivity", "reliability_sensitivity"].includes(key)) {
|
|
|
|
|
+ const width = Math.round(Number(value || 0) * 100);
|
|
|
|
|
+ return `<div class="sensitivity-cell"><span>${width}%</span><div class="sensitivity-track"><span style="width:${width}%"></span></div></div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (key === "supplier_cost_amount") {
|
|
|
|
|
+ return `${money(value)} ${escapeHtml(row.currency || "CNY")}${row.runtime_mock ? ` <span class="runtime-mark">内存</span>` : ""}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (key === "status") {
|
|
|
|
|
+ const className = value === "当前有效" ? "status-current" : value === "已过期" ? "status-expired" : "";
|
|
|
|
|
+ return `<span class="${className}">${escapeHtml(value)}</span>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (key === "adjustment_rate") return percent(value);
|
|
|
|
|
+ return escapeHtml(value ?? "");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function generateMockBatch() {
|
|
|
|
|
+ if (!marketOptions?.routes?.length) return;
|
|
|
|
|
+ const button = document.getElementById("generateMockBtn");
|
|
|
|
|
+ const route = marketOptions.routes[Number(document.getElementById("mockRoute").value)];
|
|
|
|
|
+ if (!route) return;
|
|
|
|
|
+ button.disabled = true;
|
|
|
|
|
+ button.querySelector("span:first-child").textContent = "正在写入内存图";
|
|
|
|
|
+ document.getElementById("mockStatus").textContent = "正在生成供应商、产品、班期、报价和客户画像";
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = await fetchJson("/api/mock/generate", {
|
|
|
|
|
+ method: "POST",
|
|
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ origin: route.origin,
|
|
|
|
|
+ destination: route.destination,
|
|
|
|
|
+ supplier_count: Number(document.getElementById("mockSupplierCount").value),
|
|
|
|
|
+ customer_count: Number(document.getElementById("mockCustomerCount").value),
|
|
|
|
|
+ evaluation_date: document.getElementById("evaluationDate").value || "2026-07-20",
|
|
|
|
|
+ }),
|
|
|
|
|
+ });
|
|
|
|
|
+ mockCatalog = result.catalog;
|
|
|
|
|
+ document.getElementById("mockStatus").textContent =
|
|
|
|
|
+ `批次 ${result.batch_id} 已增加 ${result.added_triples} 条内存三元组`;
|
|
|
|
|
+ await refreshMarketOptions();
|
|
|
|
|
+ renderMockCatalog();
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ document.getElementById("mockStatus").textContent = error.message;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ button.disabled = false;
|
|
|
|
|
+ button.querySelector("span:first-child").textContent = "添加一批 Mock 数据";
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function resetMockData() {
|
|
|
|
|
+ const button = document.getElementById("resetMockBtn");
|
|
|
|
|
+ button.disabled = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ mockCatalog = await fetchJson("/api/mock/reset", { method: "POST" });
|
|
|
|
|
+ document.getElementById("mockStatus").textContent = "动态数据已清空,已恢复本地 TTL 基础图";
|
|
|
|
|
+ await refreshMarketOptions();
|
|
|
|
|
+ await requestRecommendation(marketOptions.defaults);
|
|
|
|
|
+ renderMockCatalog();
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ document.getElementById("mockStatus").textContent = error.message;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ button.disabled = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function refreshMarketOptions() {
|
|
|
|
|
+ marketOptions = await fetchJson("/api/market/form-options");
|
|
|
|
|
+ populateInquiryForm(marketOptions);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadOperations() {
|
|
|
await Promise.all([
|
|
await Promise.all([
|
|
|
loadTable("orders", endpoints.orders, ["orderNo", "customerName", "status", "currentNodeName", "ownerName"]),
|
|
loadTable("orders", endpoints.orders, ["orderNo", "customerName", "status", "currentNodeName", "ownerName"]),
|
|
|
loadTable("risks", endpoints.risks, ["orderNo", "riskName", "exceptionName", "affectedNodeName"]),
|
|
loadTable("risks", endpoints.risks, ["orderNo", "riskName", "exceptionName", "affectedNodeName"]),
|
|
@@ -43,15 +536,26 @@ async function loadAll() {
|
|
|
loadTable("cutoffs", endpoints.cutoffs, ["orderNo", "customsCutoffTime", "terminalCutoffTime", "vgmCutoffTime", "estimatedSailingTime"]),
|
|
loadTable("cutoffs", endpoints.cutoffs, ["orderNo", "customsCutoffTime", "terminalCutoffTime", "vgmCutoffTime", "estimatedSailingTime"]),
|
|
|
loadTable("exceptionMap", endpoints.exceptionMap, ["exceptionName", "affectedNodeName", "riskName"]),
|
|
loadTable("exceptionMap", endpoints.exceptionMap, ["exceptionName", "affectedNodeName", "riskName"]),
|
|
|
]);
|
|
]);
|
|
|
|
|
+ operationsLoaded = true;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function fetchRows(url) {
|
|
|
|
|
- const response = await fetch(url);
|
|
|
|
|
|
|
+async function fetchJson(url, options) {
|
|
|
|
|
+ const response = await fetch(url, options);
|
|
|
if (!response.ok) {
|
|
if (!response.ok) {
|
|
|
- const detail = await response.text();
|
|
|
|
|
- throw new Error(detail || `HTTP ${response.status}`);
|
|
|
|
|
|
|
+ let detail = `请求失败:HTTP ${response.status}`;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const payload = await response.json();
|
|
|
|
|
+ detail = payload.detail || detail;
|
|
|
|
|
+ } catch (_) {
|
|
|
|
|
+ // Preserve the HTTP message when the response is not JSON.
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new Error(detail);
|
|
|
}
|
|
}
|
|
|
- const data = await response.json();
|
|
|
|
|
|
|
+ return response.json();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function fetchRows(url) {
|
|
|
|
|
+ const data = await fetchJson(url);
|
|
|
return data.rows || [];
|
|
return data.rows || [];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -63,7 +567,7 @@ async function loadTable(id, url, columns) {
|
|
|
if (counter) counter.textContent = rows.length;
|
|
if (counter) counter.textContent = rows.length;
|
|
|
target.innerHTML = renderTable(rows, columns);
|
|
target.innerHTML = renderTable(rows, columns);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- target.innerHTML = `<div class="error">读取失败:请确认 Fuseki 正在运行,并且 gesli 数据集已导入。</div>`;
|
|
|
|
|
|
|
+ target.innerHTML = `<div class="error">本地RDF数据读取失败</div>`;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -80,8 +584,7 @@ async function loadFlow() {
|
|
|
<strong>${escapeHtml(row.sequence)}. ${escapeHtml(row.nodeName)}</strong>
|
|
<strong>${escapeHtml(row.sequence)}. ${escapeHtml(row.nodeName)}</strong>
|
|
|
<span>${row.deadlineName ? `时限:${escapeHtml(row.deadlineName)}` : "无关键时限"}</span>
|
|
<span>${row.deadlineName ? `时限:${escapeHtml(row.deadlineName)}` : "无关键时限"}</span>
|
|
|
<span>${row.nextNodeName ? `下一步:${escapeHtml(row.nextNodeName)}` : "流程结束"}</span>
|
|
<span>${row.nextNodeName ? `下一步:${escapeHtml(row.nextNodeName)}` : "流程结束"}</span>
|
|
|
- </div>
|
|
|
|
|
- `).join("");
|
|
|
|
|
|
|
+ </div>`).join("");
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
target.innerHTML = `<div class="error">流程读取失败</div>`;
|
|
target.innerHTML = `<div class="error">流程读取失败</div>`;
|
|
|
}
|
|
}
|
|
@@ -91,10 +594,7 @@ function renderTable(rows, columns) {
|
|
|
if (!rows.length) return `<div class="empty">暂无数据</div>`;
|
|
if (!rows.length) return `<div class="empty">暂无数据</div>`;
|
|
|
const head = columns.map(column => `<th>${labels[column] || column}</th>`).join("");
|
|
const head = columns.map(column => `<th>${labels[column] || column}</th>`).join("");
|
|
|
const body = rows.map(row => `
|
|
const body = rows.map(row => `
|
|
|
- <tr>
|
|
|
|
|
- ${columns.map(column => `<td>${formatCell(column, row[column])}</td>`).join("")}
|
|
|
|
|
- </tr>
|
|
|
|
|
- `).join("");
|
|
|
|
|
|
|
+ <tr>${columns.map(column => `<td>${formatCell(column, row[column])}</td>`).join("")}</tr>`).join("");
|
|
|
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
|
return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -108,8 +608,24 @@ function formatCell(column, value) {
|
|
|
return escapeHtml(value);
|
|
return escapeHtml(value);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function money(value) {
|
|
|
|
|
+ return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(Number(value));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function numberFormat(value) {
|
|
|
|
|
+ return new Intl.NumberFormat("zh-CN").format(Number(value));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function percent(value) {
|
|
|
|
|
+ return `${Math.round(Number(value) * 100)}%`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function shortDate(value) {
|
|
|
|
|
+ return value ? value.slice(5).replace("-", "/") : "待确认";
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function escapeHtml(value) {
|
|
function escapeHtml(value) {
|
|
|
- return String(value)
|
|
|
|
|
|
|
+ return String(value ?? "")
|
|
|
.replaceAll("&", "&")
|
|
.replaceAll("&", "&")
|
|
|
.replaceAll("<", "<")
|
|
.replaceAll("<", "<")
|
|
|
.replaceAll(">", ">")
|
|
.replaceAll(">", ">")
|