/ ===== 植物选型库 + 苗木配置表(单价默认留空,支持导入价格清单) ===== /
window.PlantUI = (function () {
/ ---------- 品种名规范化:用于价格清单匹配 ---------- /
function normName(s) {
return String(s || "")
.replace(/[((].*?[))]/g, "") // 去括号注释
.replace(/[\s\u3000]/g, "") // 去空白
.replace(/[·・.。,,'"'"]/g, "")
.toLowerCase();}
/* 从价格清单取价:完全匹配 → 规格同时匹配优先 /
function lookupPrice(name, spec) {
const key = normName(name);
const hitList = S.priceBook[key];
if (!hitList || !hitList.length) return null;
const bySpec = hitList.find(h => h.spec && normName(h.spec) === normName(spec));
return (bySpec || hitList[0]).price;}
/ ---------- 单行派生量 ---------- /
function derive(r) {
const loss = 1 + (+r.loss || 0) / 100;
const q = +r.qty || 0;
const den = +r.density || 0;
const crown = Math.PI * Math.pow((+r.cw || 0) / 2, 2);
if (r.unit === "m²") {
r._byArea = den <= 0;
r._base = r._byArea ? q : q * den; // 计价基数:面积 或 株数
r._unitLabel = r._byArea ? "m²" : "株";
r._canopy = q; // 成片种植:覆盖面积即种植面积
} else {
r._byArea = false;
r._base = q;
r._unitLabel = "株";
r._canopy = q * crown; // 单株冠幅投影
}
r._final = r._base * loss;
r._sub = r._final * (+r.price || 0);
r._gv = r._canopy * (LEAF_DEPTH[r.type] || 0.5);
return r;}
/ ---------- 加入配置表 ---------- /
function addRow(p) {
const spec = p.sp || "";
const booked = lookupPrice(p.n, spec);
S.plants.push({
id: uid(),
name: p.n, latin: p.l || "", type: p.t || "乔木", spec,
cw: p.cw || 0, ev: p.ev ? 1 : 0, na: p.na ? 1 : 0, mo: p.mo || [],
unit: (p.t === "乔木" || p.t === "小乔木") ? "株" : "m²",
qty: 0,
density: p.d === undefined ? 0 : p.d,
loss: (p.t === "乔木" || p.t === "小乔木") ? 2 : 5,
price: booked !== null ? booked : 0, // 无价格清单时留空(0)
priceSrc: booked !== null ? "book" : "",
refPrice: p.p || 0 // 仅作输入框灰字占位
});
renderTable();
recalcAll();}
/ ---------- 植物库渲染 ---------- /
function renderLib() {
const kw = normName($("#libSearch").value);
const ft = $("#fType").value, fl = $("#fLight").value;
const fe = $("#fEver").value, fz = $("#fZone").value;
const fn = $("#fNative").checked, fs = $("#fSeason").checked;
const list = PLANT_DB.filter(p => {
if (kw && !(normName(p.n).includes(kw) || normName(p.l).includes(kw))) return false;
if (ft && p.t !== ft) return false;
if (fl && p.li !== fl) return false;
if (fe !== "" && String(p.ev ? 1 : 0) !== fe) return false;
if (fz && !p.z.includes(fz)) return false;
if (fn && !p.na) return false;
if (fs && !(p.mo && p.mo.length)) return false;
return true;
});
const box = $("#libList");
box.textContent = "";
list.forEach(p => {
const el = document.createElement("div");
el.className = "lib-item";
el.title = "点击加入苗木配置表";
const top = document.createElement("div");
top.className = "li-top";
const nm = document.createElement("span");
nm.className = "li-name"; nm.textContent = p.n;
const lt = document.createElement("span");
lt.className = "li-latin"; lt.textContent = p.l;
top.append(nm, lt);
const tags = document.createElement("div");
tags.className = "li-tags";
const mk = (txt, cls) => { const s = document.createElement("span"); s.className = "tag " + (cls || ""); s.textContent = txt; return s; };
tags.append(mk(p.t));
tags.append(mk(p.ev ? "常绿" : "落叶", p.ev ? "ev" : "de"));
tags.append(mk(p.li));
if (p.na) tags.append(mk("乡土", "na"));
if (p.se) tags.append(mk(p.se, "se"));
tags.append(mk(p.sp));
el.append(top, tags);
el.onclick = () => { addRow(p); toast("已加入:" + p.n + "(请填单价)"); };
box.appendChild(el);
});
$("#libCount").textContent = list.length;}
/ ---------- 配置表渲染 ---------- /
const TYPES = ["乔木","小乔木","灌木","竹类","观赏草","地被","藤本","草坪","水生"];
function renderTable() {
const tb = $("#plantTable tbody");
tb.textContent = "";
S.plants.forEach(r => {
derive(r);
const tr = document.createElement("tr");
tr.appendChild(cellInput(r, "name", "txt"));
tr.appendChild(cellSelect(r, "type", TYPES));
tr.appendChild(cellInput(r, "spec", "txt"));
tr.appendChild(cellNum(r, "cw", 0.1));
tr.appendChild(cellSelect(r, "unit", ["株", "m²"]));
tr.appendChild(cellNum(r, "qty", 1));
tr.appendChild(cellNum(r, "density", 0.5, r.unit !== "m²"));
tr.appendChild(cellNum(r, "loss", 1));
tr.appendChild(cellPrice(r));
const c1 = document.createElement("td");
c1.className = "calc-cell num";
c1.textContent = fmt(r._final, r._unitLabel === "m²" ? 1 : 0) + " " + r._unitLabel;
tr.appendChild(c1);
const c2 = document.createElement("td");
c2.className = "calc-cell num";
c2.textContent = r.price > 0 ? fmt(r._sub, 0) : "—";
tr.appendChild(c2);
const c3 = document.createElement("td");
const x = document.createElement("span");
x.className = "del"; x.textContent = "✕"; x.title = "删除";
x.onclick = () => { S.plants = S.plants.filter(p => p.id !== r.id); renderTable(); recalcAll(); };
c3.appendChild(x);
tr.appendChild(c3);
tb.appendChild(tr);
});
$("#plantCount").textContent = S.plants.length;
updateFoot();}
function cellInput(r, key, cls) {
const td = document.createElement("td");
const i = document.createElement("input");
i.type = "text"; i.className = cls || ""; i.value = r[key] || "";
i.oninput = () => { r[key] = i.value; };
i.onchange = () => { renderTable(); recalcAll(); };
td.appendChild(i);
return td;}
function cellNum(r, key, step, disabled) {
const td = document.createElement("td");
const i = document.createElement("input");
i.type = "number"; i.step = step; i.min = 0; i.value = r[key];
i.disabled = !!disabled;
if (disabled) i.title = "仅「m²」计量时使用";
i.oninput = () => { r[key] = +i.value || 0; derive(r); updateFoot(); };
i.onchange = () => { renderTable(); recalcAll(); };
td.appendChild(i);
return td;}
function cellSelect(r, key, opts) {
const td = document.createElement("td");
const s = document.createElement("select");
opts.forEach(o => s.add(new Option(o, o)));
s.value = r[key];
s.onchange = () => { r[key] = s.value; renderTable(); recalcAll(); };
td.appendChild(s);
return td;}
/* 单价单元格:留空 + 灰字占位(内置估值)+ 来源着色 /
function cellPrice(r) {
const td = document.createElement("td");
const i = document.createElement("input");
i.type = "number"; i.step = 0.1; i.min = 0;
i.value = r.price > 0 ? r.price : "";
i.placeholder = r.refPrice ? "估≈" + r.refPrice : "待填";
i.className = r.priceSrc === "book" ? "from-book" : (r.price > 0 ? "" : "empty-price");
i.title = r.priceSrc === "book"
? "来自导入的价格清单"
: (r.price > 0 ? "手工填写" : "尚未填写单价;灰字为无来源的经验估值,仅供量级参考");
i.oninput = () => {
r.price = +i.value || 0;
r.priceSrc = r.price > 0 ? "manual" : "";
derive(r); updateFoot();
};
i.onchange = () => { renderTable(); recalcAll(); };
td.appendChild(i);
return td;}
function updateFoot() {
const st = stats();
$("#cntTree").textContent = fmt0(st.tree) + " 株";
$("#cntShrub").textContent = fmt0(st.shrubQty) + " 株" + (st.shrubArea ? " / " + fmt(st.shrubArea, 0) + " m²" : "");
$("#cntGround").textContent = fmt(st.groundArea, 0) + " m²";
$("#cntCanopy").textContent = fmt(st.canopy, 0) + " m²";
$("#cntPlantCost").textContent = st.missing ? money(st.cost) + "(" + st.missing + " 项缺单价)" : money(st.cost);}
/* 苗木汇总统计 /
function stats() {
const o = {tree:0, shrubQty:0, shrubArea:0, groundArea:0, canopy:0, cost:0, gv:0,
ever:0, deci:0, native:0, total:0, missing:0, months:new Array(13).fill(0)};
S.plants.forEach(r => {
derive(r);
o.canopy += r._canopy;
o.gv += r._gv;
o.cost += r._sub;
if (r.price <= 0 && r.qty > 0) o.missing++;
const isTree = r.type === "乔木" || r.type === "小乔木";
if (isTree) o.tree += r._final;
else if (r.type === "灌木" || r.type === "竹类") {
if (r._unitLabel === "株") o.shrubQty += r._final;
if (r.unit === "m²") o.shrubArea += (+r.qty || 0);
} else o.groundArea += (+r.qty || 0);
const w = isTree ? (r._final || 1) : 1; // 常绿落叶比按乔木株数加权
if (isTree) { r.ev ? (o.ever += w) : (o.deci += w); }
if (r.na) o.native++;
o.total++;
(r.mo || []).forEach(m => { if (m >= 1 && m <= 12) o.months[m]++; });
});
return o;}
/ ---------- C:价格清单导入 / 模板 ---------- /
function parseCsv(text) {
const rows = [];
text = text.replace(/^\uFEFF/, "");
text.split(/\r?\n/).forEach(line => {
if (!line.trim()) return;
const cells = [];
let cur = "", q = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (q) {
if (ch === '"' && line[i + 1] === '"') { cur += '"'; i++; }
else if (ch === '"') q = false;
else cur += ch;
} else if (ch === '"') q = true;
else if (ch === "," || ch === "\t" || ch === ";") { cells.push(cur); cur = ""; }
else cur += ch;
}
cells.push(cur);
rows.push(cells.map(c => c.trim()));
});
return rows;}
function importPriceCsv(text) {
const rows = parseCsv(text);
if (!rows.length) { toast("文件为空"); return; }
// 跳过表头
if (/品种|名称|苗木|name/i.test(rows[0][0] || "")) rows.shift();
const book = {};
let n = 0, bad = 0;
rows.forEach(c => {
const name = c[0];
const spec = c[1] || "";
const price = parseFloat(String(c[2] || "").replace(/[^\d.]/g, ""));
if (!name || !isFinite(price) || price <= 0) { bad++; return; }
const k = normName(name);
(book[k] = book[k] || []).push({price, spec, note:c[3] || "", raw:name});
n++;
});
if (!n) { toast("未解析到有效记录,请检查列顺序:品种,规格,单价"); return; }
S.priceBook = Object.assign(S.priceBook, book);
// 套用到现有配置行
let hit = 0;
const miss = [];
S.plants.forEach(r => {
const p = lookupPrice(r.name, r.spec);
if (p !== null) { r.price = p; r.priceSrc = "book"; hit++; }
else if (r.priceSrc !== "manual") miss.push(r.name);
});
renderTable(); syncPriceBookState(); recalcAll();
let msg = "价格清单已导入 " + n + " 条;配置表匹配 " + hit + " 行";
if (bad) msg += ";忽略无效行 " + bad;
if (miss.length) msg += ";未匹配:" + miss.slice(0, 4).join("、") + (miss.length > 4 ? " 等" + miss.length + "项" : "");
toast(msg);}
function downloadTemplate() {
const lines = ["品种,规格,单价(元),备注(价格来源/询价日期)"];
const seen = new Set();
// 已在配置表中的品种优先,方便直接填价回传
S.plants.forEach(r => {
const k = normName(r.name);
if (seen.has(k)) return;
seen.add(k);
lines.push([csvCell(r.name), csvCell(r.spec), "", ""].join(","));
});
if (seen.size === 0) {
["香樟", "银杏", "红花檵木", "麦冬", "马尼拉草"].forEach(n => {
const p = PLANT_DB.find(x => x.n === n);
lines.push([csvCell(n), csvCell(p ? p.sp : ""), "", ""].join(","));
});
}
Exporter.download("苗木价格清单模板.csv", "\uFEFF" + lines.join("\r\n"), "text/csv;charset=utf-8");
toast("模板已下载:填入当地信息价后再导入");}
const csvCell = v => /[",\n]/.test(String(v)) ? '"' + String(v).replace(/"/g, '""') + '"' : String(v);
/* B 的显式逃生口:用户主动确认后才填入内置经验估值 /
function fillRefPrices() {
const targets = S.plants.filter(r => r.price <= 0 && r.refPrice > 0);
if (!targets.length) { toast("没有可填充的空单价行"); return; }
const ok = confirm(
"即将把 " + targets.length + " 行空白单价填为内置经验估值。\n\n" +
"这些数字没有定额或造价信息价来源,仅供量级参考,不可用于报价、投标、结算。\n\n确定继续?"
);
if (!ok) return;
targets.forEach(r => { r.price = r.refPrice; r.priceSrc = "ref"; });
renderTable(); recalcAll();
toast("已填入经验估值(" + targets.length + " 行),请务必替换为真实询价");}
function syncPriceBookState() {
const n = Object.keys(S.priceBook || {}).length;
const el = $("#priceBookState");
el.textContent = n ? "已导入价格清单 " + n + " 个品种" : "当前未导入价格清单";
el.classList.toggle("on", n > 0);}
/ ---------- 绑定 ---------- /
function bind() {
const types = Array.from(new Set(PLANT_DB.map(p => p.t)));
types.forEach(t => $("#fType").add(new Option(t, t)));
Array.from(new Set(PLANT_DB.map(p => p.li))).forEach(l => $("#fLight").add(new Option(l, l)));
Array.from(new Set(PLANT_DB.flatMap(p => p.z))).forEach(z => $("#fZone").add(new Option(z, z)));
["#libSearch", "#fType", "#fLight", "#fEver", "#fZone", "#fNative", "#fSeason"]
.forEach(s => $(s).addEventListener("input", renderLib));
$("#btnAddCustom").onclick = () => {
addRow({n:"自定义品种", l:"", t:"乔木", cw:3, ev:1, na:1, mo:[], sp:"", p:0, d:0});
toast("已添加空行,请填写品种与单价");
};
$("#btnImportPrice").onclick = () => $("#filePrice").click();
$("#filePrice").onchange = e => {
const f = e.target.files[0];
if (!f) return;
const fr = new FileReader();
fr.onload = () => importPriceCsv(fr.result);
fr.readAsText(f, "utf-8");
e.target.value = "";
};
$("#btnPriceTpl").onclick = downloadTemplate;
$("#btnFillRef").onclick = fillRefPrices;
renderLib();
renderTable();
syncPriceBookState();}
return {bind, renderLib, renderTable, stats, derive, syncPriceBookState, lookupPrice, normName};
})();